From 959fcfea6b9ea20f714031d9d52561c393d71f2f Mon Sep 17 00:00:00 2001 From: rwv Date: Tue, 21 Apr 2026 20:27:35 -0700 Subject: [PATCH 1/7] feat: add JWK PEM converter tool --- packages/tool-registry/package.json | 1 + .../src/generated/page-loaders.ts | 1 + .../tool-registry/src/generated/registry.ts | 270 ++++++ .../src/generated/search-index.ts | 135 +++ .../src/generated/static-paths.ts | 92 +++ pnpm-lock.yaml | 39 + pnpm-workspace.yaml | 1 + tools/jwk-pem-converter/client.test.tsx | 169 ++++ tools/jwk-pem-converter/client.tsx | 307 +++++++ tools/jwk-pem-converter/client/constants.ts | 30 + tools/jwk-pem-converter/client/helpers.ts | 104 +++ tools/jwk-pem-converter/client/types.ts | 55 ++ .../client/use-download-url.ts | 38 + .../components/input-card.tsx | 108 +++ .../components/jwk-panel.tsx | 130 +++ .../components/message-alert.tsx | 42 + .../components/mode-toggle.tsx | 37 + .../components/output-card.tsx | 77 ++ .../components/pem-panel.tsx | 65 ++ tools/jwk-pem-converter/core/jwk-pem-asn1.ts | 184 +++++ .../jwk-pem-converter/core/jwk-pem-base64.ts | 36 + .../core/jwk-pem-constants.ts | 28 + tools/jwk-pem-converter/core/jwk-pem-jwk.ts | 177 ++++ .../core/jwk-pem-pem-algorithms.ts | 227 ++++++ tools/jwk-pem-converter/core/jwk-pem-pem.ts | 221 +++++ tools/jwk-pem-converter/core/jwk-pem-types.ts | 42 + .../core/jwk-pem-webcrypto.ts | 73 ++ tools/jwk-pem-converter/core/jwk-pem.test.ts | 770 ++++++++++++++++++ tools/jwk-pem-converter/core/jwk-pem.ts | 70 ++ tools/jwk-pem-converter/index.astro | 45 + tools/jwk-pem-converter/manifest.ts | 19 + tools/jwk-pem-converter/messages/ar.json | 39 + tools/jwk-pem-converter/messages/de.json | 39 + tools/jwk-pem-converter/messages/en.json | 39 + tools/jwk-pem-converter/messages/es.json | 39 + tools/jwk-pem-converter/messages/fr.json | 39 + tools/jwk-pem-converter/messages/he.json | 39 + tools/jwk-pem-converter/messages/hi.json | 39 + tools/jwk-pem-converter/messages/id.json | 39 + tools/jwk-pem-converter/messages/it.json | 39 + tools/jwk-pem-converter/messages/ja.json | 39 + tools/jwk-pem-converter/messages/ko.json | 39 + tools/jwk-pem-converter/messages/ms.json | 39 + tools/jwk-pem-converter/messages/nl.json | 39 + tools/jwk-pem-converter/messages/no.json | 39 + tools/jwk-pem-converter/messages/pl.json | 39 + tools/jwk-pem-converter/messages/pt.json | 39 + tools/jwk-pem-converter/messages/ru.json | 39 + tools/jwk-pem-converter/messages/sv.json | 39 + tools/jwk-pem-converter/messages/th.json | 39 + tools/jwk-pem-converter/messages/tr.json | 39 + tools/jwk-pem-converter/messages/vi.json | 39 + tools/jwk-pem-converter/messages/zh-CN.json | 39 + tools/jwk-pem-converter/messages/zh-TW.json | 39 + tools/jwk-pem-converter/meta/ar.json | 4 + tools/jwk-pem-converter/meta/de.json | 4 + tools/jwk-pem-converter/meta/en.json | 4 + tools/jwk-pem-converter/meta/es.json | 4 + tools/jwk-pem-converter/meta/fr.json | 4 + tools/jwk-pem-converter/meta/he.json | 4 + tools/jwk-pem-converter/meta/hi.json | 4 + tools/jwk-pem-converter/meta/id.json | 4 + tools/jwk-pem-converter/meta/it.json | 4 + tools/jwk-pem-converter/meta/ja.json | 4 + tools/jwk-pem-converter/meta/ko.json | 4 + tools/jwk-pem-converter/meta/ms.json | 4 + tools/jwk-pem-converter/meta/nl.json | 4 + tools/jwk-pem-converter/meta/no.json | 4 + tools/jwk-pem-converter/meta/pl.json | 4 + tools/jwk-pem-converter/meta/pt.json | 4 + tools/jwk-pem-converter/meta/ru.json | 4 + tools/jwk-pem-converter/meta/sv.json | 4 + tools/jwk-pem-converter/meta/th.json | 4 + tools/jwk-pem-converter/meta/tr.json | 4 + tools/jwk-pem-converter/meta/vi.json | 4 + tools/jwk-pem-converter/meta/zh-CN.json | 4 + tools/jwk-pem-converter/meta/zh-TW.json | 4 + tools/jwk-pem-converter/package.json | 24 + tools/jwk-pem-converter/sections/intro/ar.md | 13 + tools/jwk-pem-converter/sections/intro/de.md | 13 + tools/jwk-pem-converter/sections/intro/en.md | 13 + tools/jwk-pem-converter/sections/intro/es.md | 13 + tools/jwk-pem-converter/sections/intro/fr.md | 13 + tools/jwk-pem-converter/sections/intro/he.md | 13 + tools/jwk-pem-converter/sections/intro/hi.md | 13 + tools/jwk-pem-converter/sections/intro/id.md | 13 + tools/jwk-pem-converter/sections/intro/it.md | 13 + tools/jwk-pem-converter/sections/intro/ja.md | 13 + tools/jwk-pem-converter/sections/intro/ko.md | 13 + tools/jwk-pem-converter/sections/intro/ms.md | 13 + tools/jwk-pem-converter/sections/intro/nl.md | 13 + tools/jwk-pem-converter/sections/intro/no.md | 13 + tools/jwk-pem-converter/sections/intro/pl.md | 13 + tools/jwk-pem-converter/sections/intro/pt.md | 13 + tools/jwk-pem-converter/sections/intro/ru.md | 13 + tools/jwk-pem-converter/sections/intro/sv.md | 13 + tools/jwk-pem-converter/sections/intro/th.md | 13 + tools/jwk-pem-converter/sections/intro/tr.md | 13 + tools/jwk-pem-converter/sections/intro/vi.md | 13 + .../jwk-pem-converter/sections/intro/zh-CN.md | 13 + .../jwk-pem-converter/sections/intro/zh-TW.md | 13 + 101 files changed, 4905 insertions(+) create mode 100644 tools/jwk-pem-converter/client.test.tsx create mode 100644 tools/jwk-pem-converter/client.tsx create mode 100644 tools/jwk-pem-converter/client/constants.ts create mode 100644 tools/jwk-pem-converter/client/helpers.ts create mode 100644 tools/jwk-pem-converter/client/types.ts create mode 100644 tools/jwk-pem-converter/client/use-download-url.ts create mode 100644 tools/jwk-pem-converter/components/input-card.tsx create mode 100644 tools/jwk-pem-converter/components/jwk-panel.tsx create mode 100644 tools/jwk-pem-converter/components/message-alert.tsx create mode 100644 tools/jwk-pem-converter/components/mode-toggle.tsx create mode 100644 tools/jwk-pem-converter/components/output-card.tsx create mode 100644 tools/jwk-pem-converter/components/pem-panel.tsx create mode 100644 tools/jwk-pem-converter/core/jwk-pem-asn1.ts create mode 100644 tools/jwk-pem-converter/core/jwk-pem-base64.ts create mode 100644 tools/jwk-pem-converter/core/jwk-pem-constants.ts create mode 100644 tools/jwk-pem-converter/core/jwk-pem-jwk.ts create mode 100644 tools/jwk-pem-converter/core/jwk-pem-pem-algorithms.ts create mode 100644 tools/jwk-pem-converter/core/jwk-pem-pem.ts create mode 100644 tools/jwk-pem-converter/core/jwk-pem-types.ts create mode 100644 tools/jwk-pem-converter/core/jwk-pem-webcrypto.ts create mode 100644 tools/jwk-pem-converter/core/jwk-pem.test.ts create mode 100644 tools/jwk-pem-converter/core/jwk-pem.ts create mode 100644 tools/jwk-pem-converter/index.astro create mode 100644 tools/jwk-pem-converter/manifest.ts create mode 100644 tools/jwk-pem-converter/messages/ar.json create mode 100644 tools/jwk-pem-converter/messages/de.json create mode 100644 tools/jwk-pem-converter/messages/en.json create mode 100644 tools/jwk-pem-converter/messages/es.json create mode 100644 tools/jwk-pem-converter/messages/fr.json create mode 100644 tools/jwk-pem-converter/messages/he.json create mode 100644 tools/jwk-pem-converter/messages/hi.json create mode 100644 tools/jwk-pem-converter/messages/id.json create mode 100644 tools/jwk-pem-converter/messages/it.json create mode 100644 tools/jwk-pem-converter/messages/ja.json create mode 100644 tools/jwk-pem-converter/messages/ko.json create mode 100644 tools/jwk-pem-converter/messages/ms.json create mode 100644 tools/jwk-pem-converter/messages/nl.json create mode 100644 tools/jwk-pem-converter/messages/no.json create mode 100644 tools/jwk-pem-converter/messages/pl.json create mode 100644 tools/jwk-pem-converter/messages/pt.json create mode 100644 tools/jwk-pem-converter/messages/ru.json create mode 100644 tools/jwk-pem-converter/messages/sv.json create mode 100644 tools/jwk-pem-converter/messages/th.json create mode 100644 tools/jwk-pem-converter/messages/tr.json create mode 100644 tools/jwk-pem-converter/messages/vi.json create mode 100644 tools/jwk-pem-converter/messages/zh-CN.json create mode 100644 tools/jwk-pem-converter/messages/zh-TW.json create mode 100644 tools/jwk-pem-converter/meta/ar.json create mode 100644 tools/jwk-pem-converter/meta/de.json create mode 100644 tools/jwk-pem-converter/meta/en.json create mode 100644 tools/jwk-pem-converter/meta/es.json create mode 100644 tools/jwk-pem-converter/meta/fr.json create mode 100644 tools/jwk-pem-converter/meta/he.json create mode 100644 tools/jwk-pem-converter/meta/hi.json create mode 100644 tools/jwk-pem-converter/meta/id.json create mode 100644 tools/jwk-pem-converter/meta/it.json create mode 100644 tools/jwk-pem-converter/meta/ja.json create mode 100644 tools/jwk-pem-converter/meta/ko.json create mode 100644 tools/jwk-pem-converter/meta/ms.json create mode 100644 tools/jwk-pem-converter/meta/nl.json create mode 100644 tools/jwk-pem-converter/meta/no.json create mode 100644 tools/jwk-pem-converter/meta/pl.json create mode 100644 tools/jwk-pem-converter/meta/pt.json create mode 100644 tools/jwk-pem-converter/meta/ru.json create mode 100644 tools/jwk-pem-converter/meta/sv.json create mode 100644 tools/jwk-pem-converter/meta/th.json create mode 100644 tools/jwk-pem-converter/meta/tr.json create mode 100644 tools/jwk-pem-converter/meta/vi.json create mode 100644 tools/jwk-pem-converter/meta/zh-CN.json create mode 100644 tools/jwk-pem-converter/meta/zh-TW.json create mode 100644 tools/jwk-pem-converter/package.json create mode 100644 tools/jwk-pem-converter/sections/intro/ar.md create mode 100644 tools/jwk-pem-converter/sections/intro/de.md create mode 100644 tools/jwk-pem-converter/sections/intro/en.md create mode 100644 tools/jwk-pem-converter/sections/intro/es.md create mode 100644 tools/jwk-pem-converter/sections/intro/fr.md create mode 100644 tools/jwk-pem-converter/sections/intro/he.md create mode 100644 tools/jwk-pem-converter/sections/intro/hi.md create mode 100644 tools/jwk-pem-converter/sections/intro/id.md create mode 100644 tools/jwk-pem-converter/sections/intro/it.md create mode 100644 tools/jwk-pem-converter/sections/intro/ja.md create mode 100644 tools/jwk-pem-converter/sections/intro/ko.md create mode 100644 tools/jwk-pem-converter/sections/intro/ms.md create mode 100644 tools/jwk-pem-converter/sections/intro/nl.md create mode 100644 tools/jwk-pem-converter/sections/intro/no.md create mode 100644 tools/jwk-pem-converter/sections/intro/pl.md create mode 100644 tools/jwk-pem-converter/sections/intro/pt.md create mode 100644 tools/jwk-pem-converter/sections/intro/ru.md create mode 100644 tools/jwk-pem-converter/sections/intro/sv.md create mode 100644 tools/jwk-pem-converter/sections/intro/th.md create mode 100644 tools/jwk-pem-converter/sections/intro/tr.md create mode 100644 tools/jwk-pem-converter/sections/intro/vi.md create mode 100644 tools/jwk-pem-converter/sections/intro/zh-CN.md create mode 100644 tools/jwk-pem-converter/sections/intro/zh-TW.md diff --git a/packages/tool-registry/package.json b/packages/tool-registry/package.json index a0775da40..6c9f8e783 100644 --- a/packages/tool-registry/package.json +++ b/packages/tool-registry/package.json @@ -70,6 +70,7 @@ "@tool/json-to-xml-converter": "workspace:*", "@tool/json-to-yaml-converter": "workspace:*", "@tool/jsonpath-tester": "workspace:*", + "@tool/jwk-pem-converter": "workspace:*", "@tool/keccak-hash-text-or-file": "workspace:*", "@tool/ksuid-generator": "workspace:*", "@tool/list-comparer": "workspace:*", diff --git a/packages/tool-registry/src/generated/page-loaders.ts b/packages/tool-registry/src/generated/page-loaders.ts index 40a16a704..d66264a97 100644 --- a/packages/tool-registry/src/generated/page-loaders.ts +++ b/packages/tool-registry/src/generated/page-loaders.ts @@ -81,6 +81,7 @@ export const toolPageLoaders: Readonly> = { "json-to-xml-converter": () => import("@tool/json-to-xml-converter/page"), "json-to-yaml-converter": () => import("@tool/json-to-yaml-converter/page"), "jsonpath-tester": () => import("@tool/jsonpath-tester/page"), + "jwk-pem-converter": () => import("@tool/jwk-pem-converter/page"), "keccak-hash-text-or-file": () => import("@tool/keccak-hash-text-or-file/page"), "ksuid-generator": () => import("@tool/ksuid-generator/page"), diff --git a/packages/tool-registry/src/generated/registry.ts b/packages/tool-registry/src/generated/registry.ts index 121d219f0..0ebaddaf5 100644 --- a/packages/tool-registry/src/generated/registry.ts +++ b/packages/tool-registry/src/generated/registry.ts @@ -7812,6 +7812,141 @@ export const toolRegistry: readonly ToolRegistryEntry[] = [ }, }, }, + { + slug: "jwk-pem-converter", + category: "web", + icon: "lock", + tags: [ + "jwk", + "pem", + "key", + "rsa", + "ec", + "okp", + "ed25519", + "x25519", + "crypto", + "convert", + "security", + ], + locales: { + ar: { + name: "محول JWK ↔ PEM", + description: + "حوّل مفاتيح JWK وPEM محليًا. يدعم RSA وEC وOKP ‏(Ed25519/X25519/Ed448/X448).", + }, + de: { + name: "JWK ↔ PEM Konverter", + description: + "Konvertiert JWK- und PEM-Schlüssel lokal. Unterstützt RSA, EC und OKP (Ed25519/X25519/Ed448/X448).", + }, + en: { + name: "JWK ↔ PEM Converter", + description: + "Convert JWK and PEM keys locally. Supports RSA, EC, and OKP (Ed25519/X25519/Ed448/X448).", + }, + es: { + name: "Conversor JWK ↔ PEM", + description: + "Convierte claves JWK y PEM localmente. Compatible con RSA, EC y OKP (Ed25519/X25519/Ed448/X448).", + }, + fr: { + name: "Convertisseur JWK ↔ PEM", + description: + "Convertit les clés JWK et PEM localement. Prend en charge RSA, EC et OKP (Ed25519/X25519/Ed448/X448).", + }, + he: { + name: "ממיר JWK ↔ PEM", + description: + "ממיר מפתחות JWK ו‑PEM מקומית. תומך ב‑RSA, EC ו‑OKP ‏(Ed25519/X25519/Ed448/X448).", + }, + hi: { + name: "JWK ↔ PEM कन्वर्टर", + description: + "JWK और PEM कुंजियों को लोकली कन्वर्ट करें। RSA, EC और OKP (Ed25519/X25519/Ed448/X448) समर्थित।", + }, + id: { + name: "Konverter JWK ↔ PEM", + description: + "Konversi kunci JWK dan PEM secara lokal. Mendukung RSA, EC, dan OKP (Ed25519/X25519/Ed448/X448).", + }, + it: { + name: "Convertitore JWK ↔ PEM", + description: + "Converte chiavi JWK e PEM in locale. Supporta RSA, EC e OKP (Ed25519/X25519/Ed448/X448).", + }, + ja: { + name: "JWK ↔ PEM 変換", + description: + "JWK と PEM の鍵をローカルで変換します。RSA、EC、OKP(Ed25519/X25519/Ed448/X448)に対応。", + }, + ko: { + name: "JWK ↔ PEM 변환기", + description: + "JWK와 PEM 키를 로컬에서 변환합니다. RSA, EC, OKP(Ed25519/X25519/Ed448/X448) 지원.", + }, + ms: { + name: "Penukar JWK ↔ PEM", + description: + "Tukar kunci JWK dan PEM secara tempatan. Menyokong RSA, EC dan OKP (Ed25519/X25519/Ed448/X448).", + }, + nl: { + name: "JWK ↔ PEM-converter", + description: + "Converteer JWK- en PEM-sleutels lokaal. Ondersteunt RSA, EC en OKP (Ed25519/X25519/Ed448/X448).", + }, + no: { + name: "JWK ↔ PEM-konverter", + description: + "Konverter JWK- og PEM-nøkler lokalt. Støtter RSA, EC og OKP (Ed25519/X25519/Ed448/X448).", + }, + pl: { + name: "Konwerter JWK ↔ PEM", + description: + "Lokalna konwersja kluczy JWK i PEM. Obsługuje RSA, EC i OKP (Ed25519/X25519/Ed448/X448).", + }, + pt: { + name: "Conversor JWK ↔ PEM", + description: + "Converta chaves JWK e PEM localmente. Suporta RSA, EC e OKP (Ed25519/X25519/Ed448/X448).", + }, + ru: { + name: "Конвертер JWK ↔ PEM", + description: + "Локально преобразует ключи JWK и PEM. Поддерживает RSA, EC и OKP (Ed25519/X25519/Ed448/X448).", + }, + sv: { + name: "JWK ↔ PEM-omvandlare", + description: + "Konvertera JWK- och PEM-nycklar lokalt. Stöd för RSA, EC och OKP (Ed25519/X25519/Ed448/X448).", + }, + th: { + name: "ตัวแปลง JWK ↔ PEM", + description: + "แปลงคีย์ JWK และ PEM แบบโลคัล รองรับ RSA, EC และ OKP (Ed25519/X25519/Ed448/X448).", + }, + tr: { + name: "JWK ↔ PEM Dönüştürücü", + description: + "JWK ve PEM anahtarlarını yerelde dönüştürür. RSA, EC ve OKP (Ed25519/X25519/Ed448/X448) destekler.", + }, + vi: { + name: "Trình chuyển đổi JWK ↔ PEM", + description: + "Chuyển đổi khóa JWK và PEM cục bộ. Hỗ trợ RSA, EC và OKP (Ed25519/X25519/Ed448/X448).", + }, + "zh-CN": { + name: "JWK ↔ PEM 转换器", + description: + "本地转换 JWK 与 PEM 密钥,支持 RSA、EC 与 OKP(Ed25519/X25519/Ed448/X448)", + }, + "zh-TW": { + name: "JWK ↔ PEM 轉換器", + description: + "在本機轉換 JWK 與 PEM 金鑰,支援 RSA、EC 與 OKP(Ed25519/X25519/Ed448/X448)", + }, + }, + }, { slug: "keccak-hash-text-or-file", category: "crypto", @@ -24272,6 +24407,141 @@ export const toolRegistryBySlug: Record = { }, }, }, + "jwk-pem-converter": { + slug: "jwk-pem-converter", + category: "web", + icon: "lock", + tags: [ + "jwk", + "pem", + "key", + "rsa", + "ec", + "okp", + "ed25519", + "x25519", + "crypto", + "convert", + "security", + ], + locales: { + ar: { + name: "محول JWK ↔ PEM", + description: + "حوّل مفاتيح JWK وPEM محليًا. يدعم RSA وEC وOKP ‏(Ed25519/X25519/Ed448/X448).", + }, + de: { + name: "JWK ↔ PEM Konverter", + description: + "Konvertiert JWK- und PEM-Schlüssel lokal. Unterstützt RSA, EC und OKP (Ed25519/X25519/Ed448/X448).", + }, + en: { + name: "JWK ↔ PEM Converter", + description: + "Convert JWK and PEM keys locally. Supports RSA, EC, and OKP (Ed25519/X25519/Ed448/X448).", + }, + es: { + name: "Conversor JWK ↔ PEM", + description: + "Convierte claves JWK y PEM localmente. Compatible con RSA, EC y OKP (Ed25519/X25519/Ed448/X448).", + }, + fr: { + name: "Convertisseur JWK ↔ PEM", + description: + "Convertit les clés JWK et PEM localement. Prend en charge RSA, EC et OKP (Ed25519/X25519/Ed448/X448).", + }, + he: { + name: "ממיר JWK ↔ PEM", + description: + "ממיר מפתחות JWK ו‑PEM מקומית. תומך ב‑RSA, EC ו‑OKP ‏(Ed25519/X25519/Ed448/X448).", + }, + hi: { + name: "JWK ↔ PEM कन्वर्टर", + description: + "JWK और PEM कुंजियों को लोकली कन्वर्ट करें। RSA, EC और OKP (Ed25519/X25519/Ed448/X448) समर्थित।", + }, + id: { + name: "Konverter JWK ↔ PEM", + description: + "Konversi kunci JWK dan PEM secara lokal. Mendukung RSA, EC, dan OKP (Ed25519/X25519/Ed448/X448).", + }, + it: { + name: "Convertitore JWK ↔ PEM", + description: + "Converte chiavi JWK e PEM in locale. Supporta RSA, EC e OKP (Ed25519/X25519/Ed448/X448).", + }, + ja: { + name: "JWK ↔ PEM 変換", + description: + "JWK と PEM の鍵をローカルで変換します。RSA、EC、OKP(Ed25519/X25519/Ed448/X448)に対応。", + }, + ko: { + name: "JWK ↔ PEM 변환기", + description: + "JWK와 PEM 키를 로컬에서 변환합니다. RSA, EC, OKP(Ed25519/X25519/Ed448/X448) 지원.", + }, + ms: { + name: "Penukar JWK ↔ PEM", + description: + "Tukar kunci JWK dan PEM secara tempatan. Menyokong RSA, EC dan OKP (Ed25519/X25519/Ed448/X448).", + }, + nl: { + name: "JWK ↔ PEM-converter", + description: + "Converteer JWK- en PEM-sleutels lokaal. Ondersteunt RSA, EC en OKP (Ed25519/X25519/Ed448/X448).", + }, + no: { + name: "JWK ↔ PEM-konverter", + description: + "Konverter JWK- og PEM-nøkler lokalt. Støtter RSA, EC og OKP (Ed25519/X25519/Ed448/X448).", + }, + pl: { + name: "Konwerter JWK ↔ PEM", + description: + "Lokalna konwersja kluczy JWK i PEM. Obsługuje RSA, EC i OKP (Ed25519/X25519/Ed448/X448).", + }, + pt: { + name: "Conversor JWK ↔ PEM", + description: + "Converta chaves JWK e PEM localmente. Suporta RSA, EC e OKP (Ed25519/X25519/Ed448/X448).", + }, + ru: { + name: "Конвертер JWK ↔ PEM", + description: + "Локально преобразует ключи JWK и PEM. Поддерживает RSA, EC и OKP (Ed25519/X25519/Ed448/X448).", + }, + sv: { + name: "JWK ↔ PEM-omvandlare", + description: + "Konvertera JWK- och PEM-nycklar lokalt. Stöd för RSA, EC och OKP (Ed25519/X25519/Ed448/X448).", + }, + th: { + name: "ตัวแปลง JWK ↔ PEM", + description: + "แปลงคีย์ JWK และ PEM แบบโลคัล รองรับ RSA, EC และ OKP (Ed25519/X25519/Ed448/X448).", + }, + tr: { + name: "JWK ↔ PEM Dönüştürücü", + description: + "JWK ve PEM anahtarlarını yerelde dönüştürür. RSA, EC ve OKP (Ed25519/X25519/Ed448/X448) destekler.", + }, + vi: { + name: "Trình chuyển đổi JWK ↔ PEM", + description: + "Chuyển đổi khóa JWK và PEM cục bộ. Hỗ trợ RSA, EC và OKP (Ed25519/X25519/Ed448/X448).", + }, + "zh-CN": { + name: "JWK ↔ PEM 转换器", + description: + "本地转换 JWK 与 PEM 密钥,支持 RSA、EC 与 OKP(Ed25519/X25519/Ed448/X448)", + }, + "zh-TW": { + name: "JWK ↔ PEM 轉換器", + description: + "在本機轉換 JWK 與 PEM 金鑰,支援 RSA、EC 與 OKP(Ed25519/X25519/Ed448/X448)", + }, + }, + }, "keccak-hash-text-or-file": { slug: "keccak-hash-text-or-file", category: "crypto", diff --git a/packages/tool-registry/src/generated/search-index.ts b/packages/tool-registry/src/generated/search-index.ts index f50df751c..914440ade 100644 --- a/packages/tool-registry/src/generated/search-index.ts +++ b/packages/tool-registry/src/generated/search-index.ts @@ -7812,6 +7812,141 @@ export const toolSearchIndex: readonly ToolSearchIndexEntry[] = [ }, }, }, + { + slug: "jwk-pem-converter", + category: "web", + icon: "lock", + tags: [ + "jwk", + "pem", + "key", + "rsa", + "ec", + "okp", + "ed25519", + "x25519", + "crypto", + "convert", + "security", + ], + locales: { + ar: { + name: "محول JWK ↔ PEM", + description: + "حوّل مفاتيح JWK وPEM محليًا. يدعم RSA وEC وOKP ‏(Ed25519/X25519/Ed448/X448).", + }, + de: { + name: "JWK ↔ PEM Konverter", + description: + "Konvertiert JWK- und PEM-Schlüssel lokal. Unterstützt RSA, EC und OKP (Ed25519/X25519/Ed448/X448).", + }, + en: { + name: "JWK ↔ PEM Converter", + description: + "Convert JWK and PEM keys locally. Supports RSA, EC, and OKP (Ed25519/X25519/Ed448/X448).", + }, + es: { + name: "Conversor JWK ↔ PEM", + description: + "Convierte claves JWK y PEM localmente. Compatible con RSA, EC y OKP (Ed25519/X25519/Ed448/X448).", + }, + fr: { + name: "Convertisseur JWK ↔ PEM", + description: + "Convertit les clés JWK et PEM localement. Prend en charge RSA, EC et OKP (Ed25519/X25519/Ed448/X448).", + }, + he: { + name: "ממיר JWK ↔ PEM", + description: + "ממיר מפתחות JWK ו‑PEM מקומית. תומך ב‑RSA, EC ו‑OKP ‏(Ed25519/X25519/Ed448/X448).", + }, + hi: { + name: "JWK ↔ PEM कन्वर्टर", + description: + "JWK और PEM कुंजियों को लोकली कन्वर्ट करें। RSA, EC और OKP (Ed25519/X25519/Ed448/X448) समर्थित।", + }, + id: { + name: "Konverter JWK ↔ PEM", + description: + "Konversi kunci JWK dan PEM secara lokal. Mendukung RSA, EC, dan OKP (Ed25519/X25519/Ed448/X448).", + }, + it: { + name: "Convertitore JWK ↔ PEM", + description: + "Converte chiavi JWK e PEM in locale. Supporta RSA, EC e OKP (Ed25519/X25519/Ed448/X448).", + }, + ja: { + name: "JWK ↔ PEM 変換", + description: + "JWK と PEM の鍵をローカルで変換します。RSA、EC、OKP(Ed25519/X25519/Ed448/X448)に対応。", + }, + ko: { + name: "JWK ↔ PEM 변환기", + description: + "JWK와 PEM 키를 로컬에서 변환합니다. RSA, EC, OKP(Ed25519/X25519/Ed448/X448) 지원.", + }, + ms: { + name: "Penukar JWK ↔ PEM", + description: + "Tukar kunci JWK dan PEM secara tempatan. Menyokong RSA, EC dan OKP (Ed25519/X25519/Ed448/X448).", + }, + nl: { + name: "JWK ↔ PEM-converter", + description: + "Converteer JWK- en PEM-sleutels lokaal. Ondersteunt RSA, EC en OKP (Ed25519/X25519/Ed448/X448).", + }, + no: { + name: "JWK ↔ PEM-konverter", + description: + "Konverter JWK- og PEM-nøkler lokalt. Støtter RSA, EC og OKP (Ed25519/X25519/Ed448/X448).", + }, + pl: { + name: "Konwerter JWK ↔ PEM", + description: + "Lokalna konwersja kluczy JWK i PEM. Obsługuje RSA, EC i OKP (Ed25519/X25519/Ed448/X448).", + }, + pt: { + name: "Conversor JWK ↔ PEM", + description: + "Converta chaves JWK e PEM localmente. Suporta RSA, EC e OKP (Ed25519/X25519/Ed448/X448).", + }, + ru: { + name: "Конвертер JWK ↔ PEM", + description: + "Локально преобразует ключи JWK и PEM. Поддерживает RSA, EC и OKP (Ed25519/X25519/Ed448/X448).", + }, + sv: { + name: "JWK ↔ PEM-omvandlare", + description: + "Konvertera JWK- och PEM-nycklar lokalt. Stöd för RSA, EC och OKP (Ed25519/X25519/Ed448/X448).", + }, + th: { + name: "ตัวแปลง JWK ↔ PEM", + description: + "แปลงคีย์ JWK และ PEM แบบโลคัล รองรับ RSA, EC และ OKP (Ed25519/X25519/Ed448/X448).", + }, + tr: { + name: "JWK ↔ PEM Dönüştürücü", + description: + "JWK ve PEM anahtarlarını yerelde dönüştürür. RSA, EC ve OKP (Ed25519/X25519/Ed448/X448) destekler.", + }, + vi: { + name: "Trình chuyển đổi JWK ↔ PEM", + description: + "Chuyển đổi khóa JWK và PEM cục bộ. Hỗ trợ RSA, EC và OKP (Ed25519/X25519/Ed448/X448).", + }, + "zh-CN": { + name: "JWK ↔ PEM 转换器", + description: + "本地转换 JWK 与 PEM 密钥,支持 RSA、EC 与 OKP(Ed25519/X25519/Ed448/X448)", + }, + "zh-TW": { + name: "JWK ↔ PEM 轉換器", + description: + "在本機轉換 JWK 與 PEM 金鑰,支援 RSA、EC 與 OKP(Ed25519/X25519/Ed448/X448)", + }, + }, + }, { slug: "keccak-hash-text-or-file", category: "crypto", diff --git a/packages/tool-registry/src/generated/static-paths.ts b/packages/tool-registry/src/generated/static-paths.ts index 60101d8f2..de002ec20 100644 --- a/packages/tool-registry/src/generated/static-paths.ts +++ b/packages/tool-registry/src/generated/static-paths.ts @@ -5797,6 +5797,98 @@ export const toolStaticPaths: readonly ToolStaticPathEntry[] = [ slug: "jsonpath-tester", language: "zh-TW", }, + { + slug: "jwk-pem-converter", + language: "ar", + }, + { + slug: "jwk-pem-converter", + language: "de", + }, + { + slug: "jwk-pem-converter", + language: "en", + }, + { + slug: "jwk-pem-converter", + language: "es", + }, + { + slug: "jwk-pem-converter", + language: "fr", + }, + { + slug: "jwk-pem-converter", + language: "he", + }, + { + slug: "jwk-pem-converter", + language: "hi", + }, + { + slug: "jwk-pem-converter", + language: "id", + }, + { + slug: "jwk-pem-converter", + language: "it", + }, + { + slug: "jwk-pem-converter", + language: "ja", + }, + { + slug: "jwk-pem-converter", + language: "ko", + }, + { + slug: "jwk-pem-converter", + language: "ms", + }, + { + slug: "jwk-pem-converter", + language: "nl", + }, + { + slug: "jwk-pem-converter", + language: "no", + }, + { + slug: "jwk-pem-converter", + language: "pl", + }, + { + slug: "jwk-pem-converter", + language: "pt", + }, + { + slug: "jwk-pem-converter", + language: "ru", + }, + { + slug: "jwk-pem-converter", + language: "sv", + }, + { + slug: "jwk-pem-converter", + language: "th", + }, + { + slug: "jwk-pem-converter", + language: "tr", + }, + { + slug: "jwk-pem-converter", + language: "vi", + }, + { + slug: "jwk-pem-converter", + language: "zh-CN", + }, + { + slug: "jwk-pem-converter", + language: "zh-TW", + }, { slug: "keccak-hash-text-or-file", language: "ar", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6dbd6f0d..0adf62640 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ catalogs: '@faker-js/faker': specifier: ^10.3.0 version: 10.4.0 + '@noble/ed25519': + specifier: ^3.0.0 + version: 3.1.0 '@noble/hashes': specifier: ^2.2.0 version: 2.2.0 @@ -548,6 +551,9 @@ importers: '@tool/jsonpath-tester': specifier: workspace:* version: link:../../tools/jsonpath-tester + '@tool/jwk-pem-converter': + specifier: workspace:* + version: link:../../tools/jwk-pem-converter '@tool/keccak-hash-text-or-file': specifier: workspace:* version: link:../../tools/keccak-hash-text-or-file @@ -2538,6 +2544,34 @@ importers: specifier: 'catalog:' version: 4.1.2(@types/node@25.5.2)(happy-dom@20.8.9)(msw@2.12.14(@types/node@25.5.2)(typescript@5.9.3))(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + tools/jwk-pem-converter: + dependencies: + '@noble/ed25519': + specifier: 'catalog:' + version: 3.1.0 + '@workspace/tool-sdk': + specifier: workspace:* + version: link:../../packages/tool-sdk + '@workspace/ui': + specifier: workspace:* + version: link:../../packages/ui + astro: + specifier: 'catalog:' + version: 5.18.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + devDependencies: + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vitest: + specifier: 'catalog:' + version: 4.1.2(@types/node@25.5.2)(happy-dom@20.8.9)(msw@2.12.14(@types/node@25.5.2)(typescript@5.9.3))(vite@6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + tools/keccak-hash-text-or-file: dependencies: '@workspace/tool-sdk': @@ -5574,6 +5608,9 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/ed25519@3.1.0': + resolution: {integrity: sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -11536,6 +11573,8 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/ed25519@3.1.0': {} + '@noble/hashes@1.8.0': {} '@noble/hashes@2.2.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 19b603aa5..17b31364c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,7 @@ publicHoistPattern: # in the relevant package.json. Tool-local runtime deps belong here too — # the catalog is the version registry, not just a "shared deps" list. catalog: + "@noble/ed25519": ^3.0.0 "@faker-js/faker": ^10.3.0 "@astrojs/check": ^0.9.8 "@astrojs/mdx": ^4.3.14 diff --git a/tools/jwk-pem-converter/client.test.tsx b/tools/jwk-pem-converter/client.test.tsx new file mode 100644 index 000000000..3afab7a26 --- /dev/null +++ b/tools/jwk-pem-converter/client.test.tsx @@ -0,0 +1,169 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +import JwkPemConverterClient from "./client" +import { DEFAULT_JWK_INPUT, DEFAULT_PEM_INPUT } from "./client/constants" +import messagesCatalog from "./messages/en.json" +import meta from "./meta/en.json" + +const messages = { + meta, + ...messagesCatalog, +} as const + +beforeEach(() => { + vi.stubGlobal( + "URL", + Object.assign({}, globalThis.URL, { + createObjectURL: vi.fn(() => "blob:jwk-pem-output"), + revokeObjectURL: vi.fn(), + }) + ) + + window.localStorage.clear() +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +function getOutput() { + return screen.queryByLabelText( + messages.outputTitle + ) as HTMLTextAreaElement | null +} + +async function waitForOutputToContain(text: string) { + await waitFor(() => { + expect(getOutput()?.value).toContain(text) + }) +} + +describe("JwkPemConverterClient", () => { + test("renders the default JWK example and can clear and restore it", async () => { + render() + + expect( + (screen.getByLabelText(messages.jwkInputTitle) as HTMLTextAreaElement) + .value + ).toBe(DEFAULT_JWK_INPUT) + + await waitForOutputToContain("BEGIN PRIVATE KEY") + + fireEvent.click(screen.getByRole("button", { name: messages.clearLabel })) + + await waitFor(() => { + expect(getOutput()).toBeNull() + }) + + fireEvent.click( + screen.getByRole("button", { name: messages.useSampleLabel }) + ) + + await waitForOutputToContain("BEGIN PRIVATE KEY") + }) + + test("shows invalid JWK errors and can import a replacement file", async () => { + render() + + fireEvent.change(screen.getByLabelText(messages.jwkInputTitle), { + target: { value: "{" }, + }) + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + messages.errorInvalidJson + ) + }) + + const file = new File([DEFAULT_JWK_INPUT], "key.jwk.json", { + type: "application/json", + }) + + fireEvent.change(screen.getByLabelText(messages.importFromFileLabel), { + target: { files: [file] }, + }) + + await waitForOutputToContain("BEGIN PRIVATE KEY") + }) + + test("shows JWKS key selection controls and can switch to public output", async () => { + render() + + const jwks = JSON.stringify( + { + keys: [ + { + kid: "alpha", + crv: "Ed25519", + d: "IPR8baukbPNU-nM57_prOTFvP9b9QTXY6JYLO1mbWR4", + x: "cc2GnZtI8l9tvVNwDyRRebvDto9_DLG9_Zvm4XODEKE", + kty: "OKP", + }, + { + kid: "beta", + crv: "Ed25519", + d: "IPR8baukbPNU-nM57_prOTFvP9b9QTXY6JYLO1mbWR4", + x: "cc2GnZtI8l9tvVNwDyRRebvDto9_DLG9_Zvm4XODEKE", + kty: "OKP", + }, + ], + }, + null, + 2 + ) + + fireEvent.change(screen.getByLabelText(messages.jwkInputTitle), { + target: { value: jwks }, + }) + + expect(screen.getByText(messages.keySelectHint)).toBeTruthy() + + fireEvent.click( + screen.getByRole("radio", { name: messages.outputTypePublic }) + ) + + await waitForOutputToContain("BEGIN PUBLIC KEY") + }) + + test("converts PEM input, toggles compact JSON, and reports warnings", async () => { + render() + + fireEvent.click(screen.getByRole("radio", { name: messages.tabPemToJwk })) + + expect( + (screen.getByLabelText(messages.pemInputTitle) as HTMLTextAreaElement) + .value + ).toBe(DEFAULT_PEM_INPUT) + + await waitForOutputToContain('"kty": "OKP"') + + fireEvent.click(screen.getByRole("switch", { name: messages.prettyJson })) + + await waitFor(() => { + expect(getOutput()?.value).toContain('"kty":"OKP"') + expect(getOutput()?.value.includes("\n")).toBe(false) + }) + + fireEvent.change(screen.getByLabelText(messages.pemInputTitle), { + target: { + value: + `${DEFAULT_PEM_INPUT}\n` + + "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----", + }, + }) + + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toContain( + messages.errorUnsupportedPemLabel + ) + }) + }) +}) diff --git a/tools/jwk-pem-converter/client.tsx b/tools/jwk-pem-converter/client.tsx new file mode 100644 index 000000000..4efed339f --- /dev/null +++ b/tools/jwk-pem-converter/client.tsx @@ -0,0 +1,307 @@ +import { + startTransition, + useDeferredValue, + useEffect, + useRef, + useState, + type ChangeEvent, +} from "react" + +import { + jwkToPem, + pemToJwk, + type JwkPemError, + type PemOutputType, + type WarningEntry, +} from "./core/jwk-pem" +import { + DEFAULT_JWK_INPUT, + DEFAULT_PEM_INPUT, + DEFAULT_MODE, + STORAGE_KEYS, +} from "./client/constants" +import { + formatErrorMessage, + formatWarningMessages, + getJwkParseState, + normalizeJwkPemError, +} from "./client/helpers" +import { useDownloadUrl } from "./client/use-download-url" +import type { ConversionMode, JwkPemConverterMessages } from "./client/types" +import { JwkPanel } from "./components/jwk-panel" +import { MessageAlert } from "./components/message-alert" +import { ModeToggle } from "./components/mode-toggle" +import { OutputCard } from "./components/output-card" +import { PemPanel } from "./components/pem-panel" + +function JwkPemConverterClient({ + messages, +}: { + messages: JwkPemConverterMessages +}) { + const jwkFileInputRef = useRef(null) + const pemFileInputRef = useRef(null) + const [mode, setMode] = useState(DEFAULT_MODE) + const [jwkInput, setJwkInput] = useState(DEFAULT_JWK_INPUT) + const [pemInput, setPemInput] = useState(DEFAULT_PEM_INPUT) + const [selectedJwkIndex, setSelectedJwkIndex] = useState(0) + const [outputType, setOutputType] = useState("private") + const [prettyJson, setPrettyJson] = useState(true) + const [jwkError, setJwkError] = useState(null) + const [jwkOutput, setJwkOutput] = useState("") + const [pemError, setPemError] = useState(null) + const [pemOutput, setPemOutput] = useState("") + const [pemWarnings, setPemWarnings] = useState([]) + const deferredJwkInput = useDeferredValue(jwkInput) + const deferredPemInput = useDeferredValue(pemInput) + const jwkParseState = getJwkParseState(deferredJwkInput) + const jwkDownloadUrl = useDownloadUrl(jwkOutput, "application/x-pem-file") + const pemDownloadUrl = useDownloadUrl(pemOutput, "application/json") + + useEffect(() => { + /* v8 ignore next */ + if (typeof window === "undefined") return + + const storedMode = window.localStorage.getItem(STORAGE_KEYS.mode) + const storedJwkInput = window.localStorage.getItem(STORAGE_KEYS.jwkInput) + const storedPemInput = window.localStorage.getItem(STORAGE_KEYS.pemInput) + const storedOutputType = window.localStorage.getItem( + STORAGE_KEYS.outputType + ) + const storedPrettyJson = window.localStorage.getItem( + STORAGE_KEYS.prettyJson + ) + + if (storedMode === "jwk" || storedMode === "pem") setMode(storedMode) + if (storedJwkInput !== null) setJwkInput(storedJwkInput) + if (storedPemInput !== null) setPemInput(storedPemInput) + if (storedOutputType === "public" || storedOutputType === "private") { + setOutputType(storedOutputType) + } + if (storedPrettyJson === "true" || storedPrettyJson === "false") { + setPrettyJson(storedPrettyJson === "true") + } + }, []) + + useEffect(() => { + /* v8 ignore next */ + if (typeof window === "undefined") return + window.localStorage.setItem(STORAGE_KEYS.mode, mode) + }, [mode]) + + useEffect(() => { + /* v8 ignore next */ + if (typeof window === "undefined") return + window.localStorage.setItem(STORAGE_KEYS.jwkInput, jwkInput) + }, [jwkInput]) + + useEffect(() => { + /* v8 ignore next */ + if (typeof window === "undefined") return + window.localStorage.setItem(STORAGE_KEYS.pemInput, pemInput) + }, [pemInput]) + + useEffect(() => { + /* v8 ignore next */ + if (typeof window === "undefined") return + window.localStorage.setItem(STORAGE_KEYS.outputType, outputType) + }, [outputType]) + + useEffect(() => { + /* v8 ignore next */ + if (typeof window === "undefined") return + window.localStorage.setItem(STORAGE_KEYS.prettyJson, String(prettyJson)) + }, [prettyJson]) + + useEffect(() => { + if (jwkParseState.state !== "parsed") { + setSelectedJwkIndex(0) + return + } + + if (selectedJwkIndex >= jwkParseState.keys.length) { + setSelectedJwkIndex(0) + } + }, [jwkParseState, selectedJwkIndex]) + + useEffect(() => { + let cancelled = false + + async function run() { + const nextParseState = getJwkParseState(deferredJwkInput) + if (nextParseState.state !== "parsed") { + setJwkError( + nextParseState.state === "error" ? nextParseState.error : null + ) + setJwkOutput("") + return + } + + const selectedKey = + nextParseState.keys[selectedJwkIndex] ?? nextParseState.keys[0] ?? null + + if (!selectedKey) { + setJwkError(null) + setJwkOutput("") + return + } + + try { + const pem = await jwkToPem(selectedKey, outputType) + + if (cancelled) return + + setJwkError(null) + setJwkOutput(pem) + } catch (error) { + if (cancelled) return + + setJwkError(normalizeJwkPemError(error, "errorInvalidJwk")) + setJwkOutput("") + } + } + + void run() + + return () => { + cancelled = true + } + }, [deferredJwkInput, outputType, selectedJwkIndex]) + + useEffect(() => { + let cancelled = false + + async function run() { + if (deferredPemInput.trim() === "") { + setPemError(null) + setPemWarnings([]) + setPemOutput("") + return + } + + try { + const result = await pemToJwk(deferredPemInput) + const output = JSON.stringify( + result.jwk, + null, + prettyJson ? 2 : undefined + ) + + if (cancelled) return + + setPemError(null) + setPemWarnings(result.warnings) + setPemOutput(output) + } catch (error) { + if (cancelled) return + + setPemError(normalizeJwkPemError(error, "errorInvalidPem")) + setPemWarnings([]) + setPemOutput("") + } + } + + void run() + + return () => { + cancelled = true + } + }, [deferredPemInput, prettyJson]) + + const activeError = mode === "jwk" ? jwkError : pemError + const errorMessage = formatErrorMessage(messages, activeError) + const warningMessages = + mode === "pem" ? formatWarningMessages(messages, pemWarnings) : [] + const outputValue = mode === "jwk" ? jwkOutput : pemOutput + const downloadName = + mode === "jwk" + ? outputType === "public" + ? "public-key.pem" + : "private-key.pem" + : pemOutput.includes('"keys"') + ? "jwks.json" + : "key.jwk.json" + const downloadUrl = mode === "jwk" ? jwkDownloadUrl : pemDownloadUrl + + return ( +
+ + + {mode === "jwk" ? ( + { + startTransition(() => { + setJwkInput(value) + }) + }} + setOutputType={setOutputType} + setSelectedJwkIndex={setSelectedJwkIndex} + onFileChange={(event) => { + void handleFileChange(event, setJwkInput) + }} + /> + ) : ( + { + startTransition(() => { + setPemInput(value) + }) + }} + setPrettyJson={setPrettyJson} + onFileChange={(event) => { + void handleFileChange(event, setPemInput) + }} + /> + )} + + + + + +
+ ) +} + +async function handleFileChange( + event: ChangeEvent, + setter: (value: string) => void +) { + const file = event.target.files?.[0] + event.target.value = "" + + if (!file) { + return + } + + const nextText = await file.text() + + startTransition(() => { + setter(nextText) + }) +} + +export default JwkPemConverterClient diff --git a/tools/jwk-pem-converter/client/constants.ts b/tools/jwk-pem-converter/client/constants.ts new file mode 100644 index 000000000..6daf6dbf5 --- /dev/null +++ b/tools/jwk-pem-converter/client/constants.ts @@ -0,0 +1,30 @@ +import type { ConversionMode } from "./types" + +const DEFAULT_MODE = "jwk" satisfies ConversionMode +const DEFAULT_JWK_INPUT = `{ + "crv": "Ed25519", + "d": "IPR8baukbPNU-nM57_prOTFvP9b9QTXY6JYLO1mbWR4", + "x": "cc2GnZtI8l9tvVNwDyRRebvDto9_DLG9_Zvm4XODEKE", + "kty": "OKP" +}` +const DEFAULT_PEM_INPUT = `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEICD0fG2rpGzzVPpzOe/6azkxbz/W/UE12OiWCztZm1ke +-----END PRIVATE KEY-----` +const JWK_FILE_ACCEPT = ".json,.jwk,.txt,application/json,text/plain" +const PEM_FILE_ACCEPT = ".pem,.key,.pub,.txt,application/x-pem-file,text/plain" +const STORAGE_KEYS = { + mode: "tools:jwk-pem-converter:tab", + jwkInput: "tools:jwk-pem-converter:jwk-input", + pemInput: "tools:jwk-pem-converter:pem-input", + outputType: "tools:jwk-pem-converter:output-type", + prettyJson: "tools:jwk-pem-converter:pretty-json", +} as const + +export { + DEFAULT_JWK_INPUT, + DEFAULT_MODE, + DEFAULT_PEM_INPUT, + JWK_FILE_ACCEPT, + PEM_FILE_ACCEPT, + STORAGE_KEYS, +} diff --git a/tools/jwk-pem-converter/client/helpers.ts b/tools/jwk-pem-converter/client/helpers.ts new file mode 100644 index 000000000..a4629af83 --- /dev/null +++ b/tools/jwk-pem-converter/client/helpers.ts @@ -0,0 +1,104 @@ +import { JwkPemError, parseJwkJson, type WarningEntry } from "../core/jwk-pem" + +import type { JwkPemConverterMessages } from "./types" + +type JwkParseState = + | { state: "empty" } + | { state: "parsed"; keys: JsonWebKey[] } + | { state: "error"; error: JwkPemError } + +function getJwkParseState(input: string): JwkParseState { + if (input.trim() === "") { + return { state: "empty" } + } + + try { + return { state: "parsed", keys: parseJwkJson(input) } + } catch (error) { + return { + state: "error", + error: normalizeJwkPemError(error, "errorInvalidJwk"), + } + } +} + +function isJwkSet( + jwk: JsonWebKey | { keys: JsonWebKey[] } +): jwk is { keys: JsonWebKey[] } { + return "keys" in jwk +} + +function normalizeJwkPemError(error: unknown, fallbackKey: string) { + if (error instanceof JwkPemError) { + return error + } + + return new JwkPemError(fallbackKey) +} + +function formatErrorMessage( + messages: JwkPemConverterMessages, + error: JwkPemError | null +) { + if (!error) { + return null + } + + const template = messages[error.key as keyof JwkPemConverterMessages] + return typeof template === "string" + ? formatTemplate(template, error.params) + : error.message +} + +function formatWarningMessages( + messages: JwkPemConverterMessages, + warnings: readonly WarningEntry[] +) { + return warnings.map((warning) => { + const template = messages[warning.key as keyof JwkPemConverterMessages] + + return typeof template === "string" + ? formatTemplate(template, warning.params) + : warning.key + }) +} + +function formatKeyLabel( + messages: JwkPemConverterMessages, + key: JsonWebKey, + index: number +) { + const type = key.kty ? key.kty : messages.unknownKey + const detail = key.crv ? ` ${key.crv}` : "" + const keyWithId = key as JsonWebKey & { kid?: string } + const keyId = + typeof keyWithId.kid === "string" && keyWithId.kid.trim() !== "" + ? ` (${keyWithId.kid})` + : ` #${index + 1}` + + return `${type}${detail}${keyId}` +} + +function formatTemplate( + template: string, + params?: Readonly> +) { + if (!params) { + return template + } + + return template.replaceAll( + /\{(\w+)\}/g, + (_, key: string) => params[key] ?? `{${key}}` + ) +} + +export { + formatErrorMessage, + formatKeyLabel, + formatWarningMessages, + getJwkParseState, + isJwkSet, + normalizeJwkPemError, +} +export type { JwkParseState } diff --git a/tools/jwk-pem-converter/client/types.ts b/tools/jwk-pem-converter/client/types.ts new file mode 100644 index 000000000..423016e0a --- /dev/null +++ b/tools/jwk-pem-converter/client/types.ts @@ -0,0 +1,55 @@ +type ConversionMode = "jwk" | "pem" + +type JwkPemConverterLocaleMessages = Readonly<{ + tabJwkToPem: string + tabPemToJwk: string + conversionErrorTitle: string + errorInvalidJson: string + errorInvalidJwk: string + errorMissingField: string + errorUnsupportedKty: string + errorUnsupportedCurve: string + errorMissingPrivateKey: string + errorMissingPublicKey: string + errorInvalidPem: string + errorUnsupportedPemLabel: string + errorUnsupportedAlgorithm: string + errorWebCryptoUnavailable: string + errorWebCryptoFailed: string + errorOkpPublicKeyMissing: string + warningsTitle: string + jwkInputTitle: string + jwkInputPlaceholder: string + jwkInputHint: string + keySelectLabel: string + keySelectHint: string + unknownKey: string + outputTypeLabel: string + outputTypePublic: string + outputTypePrivate: string + pemInputTitle: string + pemInputPlaceholder: string + pemInputHint: string + prettyJson: string + outputTitle: string + downloadButton: string + useSampleLabel: string + clearLabel: string + importFromFileLabel: string + copyResultLabel: string + copiedLabel: string +}> + +type JwkPemConverterMessages = Readonly<{ + meta: { + name: string + description: string + } +}> & + JwkPemConverterLocaleMessages + +export type { + ConversionMode, + JwkPemConverterLocaleMessages, + JwkPemConverterMessages, +} diff --git a/tools/jwk-pem-converter/client/use-download-url.ts b/tools/jwk-pem-converter/client/use-download-url.ts new file mode 100644 index 000000000..50dd08d57 --- /dev/null +++ b/tools/jwk-pem-converter/client/use-download-url.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef, useState } from "react" + +function useDownloadUrl(value: string, mimeType: string) { + const downloadUrlRef = useRef(null) + const [downloadUrl, setDownloadUrl] = useState(null) + + useEffect(() => { + if (downloadUrlRef.current) { + URL.revokeObjectURL(downloadUrlRef.current) + downloadUrlRef.current = null + } + + if (value === "") { + setDownloadUrl(null) + return + } + + const nextUrl = URL.createObjectURL( + new Blob([value], { + type: mimeType, + }) + ) + + downloadUrlRef.current = nextUrl + setDownloadUrl(nextUrl) + + return () => { + if (downloadUrlRef.current === nextUrl) { + URL.revokeObjectURL(nextUrl) + downloadUrlRef.current = null + } + } + }, [mimeType, value]) + + return downloadUrl +} + +export { useDownloadUrl } diff --git a/tools/jwk-pem-converter/components/input-card.tsx b/tools/jwk-pem-converter/components/input-card.tsx new file mode 100644 index 000000000..4fb7aa28c --- /dev/null +++ b/tools/jwk-pem-converter/components/input-card.tsx @@ -0,0 +1,108 @@ +import { type ChangeEvent, type ReactNode, type RefObject } from "react" + +import { + ToolPanelCard, + ToolPanelCardContent, + ToolPanelCardFooter, +} from "@workspace/ui/components/tool/tool-panel-card" +import { Button } from "@workspace/ui/components/ui/button" +import { + CardDescription, + CardHeader, + CardTitle, +} from "@workspace/ui/components/ui/card" +import { Textarea } from "@workspace/ui/components/ui/textarea" +import { FileText, RefreshCcw, Sparkles } from "@workspace/ui/icons" + +type InputCardProps = Readonly<{ + accept: string + ariaLabel: string + clearLabel: string + description: string + fileInputRef: RefObject + importFromFileLabel: string + invalid: boolean + placeholder: string + title: string + useSampleLabel: string + value: string + onChange: (value: string) => void + onClear: () => void + onFileChange: (event: ChangeEvent) => void + onUseSample: () => void + children?: ReactNode +}> + +function InputCard({ + accept, + ariaLabel, + children, + clearLabel, + description, + fileInputRef, + importFromFileLabel, + invalid, + placeholder, + title, + useSampleLabel, + value, + onChange, + onClear, + onFileChange, + onUseSample, +}: InputCardProps) { + return ( + + + {title} + {description} + + +