Release Crypto Tools 0.1.0
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
import "./styles.css";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { manifest } from "./toolbox/manifest";
|
||||
|
||||
const Workbench = lazy(async () => ({
|
||||
default: (await import("./components/Workbench")).Workbench,
|
||||
}));
|
||||
|
||||
export function App() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="loading" role="status">
|
||||
Preparing Crypto Tools…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Application failure", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<main className="fatal">
|
||||
<h1>Crypto Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
if (open && !node.open) node.showModal();
|
||||
if (!open && node.open) node.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="help-dialog"
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
aria-labelledby="help-title"
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first help</p>
|
||||
<h2 id="help-title">About Crypto Tools</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close help">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p>Inspect keys, certificates and signatures locally in the browser.</p>
|
||||
<p>
|
||||
All processing is performed in this browser. Imported data is treated as
|
||||
untrusted and bounded before parsing.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
checkCertificateHostname,
|
||||
inspectCryptoInput,
|
||||
type CryptoInspection,
|
||||
} from "../crypto/inspection";
|
||||
|
||||
const example = `{
|
||||
"kty": "EC",
|
||||
"crv": "P-256",
|
||||
"x": "f83OJ3D2xF4",
|
||||
"y": "x_FEzRu9f8",
|
||||
"kid": "example-public-key",
|
||||
"use": "sig"
|
||||
}`;
|
||||
|
||||
function reportJson(inspection: CryptoInspection): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
generatedAt: new Date().toISOString(),
|
||||
trustModel:
|
||||
"Explicit inputs only; browser and operating-system trust stores were not used.",
|
||||
findings: inspection.findings,
|
||||
chain: inspection.chain,
|
||||
items: inspection.items.map(({ id, type, title, facts, findings }) => ({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
facts,
|
||||
findings,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function downloadReport(inspection: CryptoInspection) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([reportJson(inspection)], { type: "application/json" }),
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = "crypto-tools-report.json";
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const [source, setSource] = useState(example);
|
||||
const [inspection, setInspection] = useState<CryptoInspection>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [hostname, setHostname] = useState("example.com");
|
||||
const generation = useRef(0);
|
||||
const certificates = useMemo(
|
||||
() => inspection?.items.filter((item) => item.certificate) ?? [],
|
||||
[inspection],
|
||||
);
|
||||
const hostnameResult = useMemo(
|
||||
() =>
|
||||
certificates[0]
|
||||
? checkCertificateHostname(certificates[0], hostname)
|
||||
: undefined,
|
||||
[certificates, hostname],
|
||||
);
|
||||
|
||||
const run = async (input: string | Uint8Array = source) => {
|
||||
const current = ++generation.current;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await inspectCryptoInput(input);
|
||||
if (generation.current === current) setInspection(next);
|
||||
} catch (reason) {
|
||||
if (generation.current === current)
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Inspection failed.",
|
||||
);
|
||||
} finally {
|
||||
if (generation.current === current) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openFile = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
if (file.size > 8 * 1024 * 1024) {
|
||||
setError("File exceeds the 8 MiB inspection limit.");
|
||||
return;
|
||||
}
|
||||
setSource(
|
||||
`Selected binary input: ${file.name} (${file.size.toLocaleString()} bytes)`,
|
||||
);
|
||||
const textual =
|
||||
/(?:json|jwk|jwks|pem|crt|cer|csr|key)$/iu.test(file.name) ||
|
||||
file.type.startsWith("text/") ||
|
||||
file.type === "application/json";
|
||||
await run(
|
||||
textual ? await file.text() : new Uint8Array(await file.arrayBuffer()),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="workbench">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Inspection-first cryptography</p>
|
||||
<h1>Crypto Tools</h1>
|
||||
<p>
|
||||
Inspect X.509 certificates, CSRs, CRLs, public/private-key
|
||||
containers, JWK and JWKS locally.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">Memory-only</span>
|
||||
</header>
|
||||
<section className="panel workspace" aria-labelledby="input-heading">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Input</p>
|
||||
<h2 id="input-heading">PEM, DER, JWK or JWKS</h2>
|
||||
</div>
|
||||
<label className="button file-button">
|
||||
Open file
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem,.der,.crt,.cer,.csr,.key,.json,.jwk,.jwks,.crl,.p7b,.p7c,.p12,.pfx"
|
||||
onChange={(event) => void openFile(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label="Cryptographic input"
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={() => void run()}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Inspecting…" : "Inspect locally"}
|
||||
</button>
|
||||
<span className="muted">8 MiB · 256 PEM-block hard limits</span>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
{inspection && (
|
||||
<>
|
||||
<section
|
||||
className="panel workspace"
|
||||
aria-labelledby="result-heading"
|
||||
aria-busy={busy}
|
||||
>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Last successful result</p>
|
||||
<h2 id="result-heading">
|
||||
{inspection.items.length} object
|
||||
{inspection.items.length === 1 ? "" : "s"}
|
||||
</h2>
|
||||
</div>
|
||||
<button type="button" onClick={() => downloadReport(inspection)}>
|
||||
Download JSON report
|
||||
</button>
|
||||
</div>
|
||||
<p className="notice">
|
||||
This is an explicit-input inspection. It does not use or imply
|
||||
trust from a browser or operating-system trust store, and it does
|
||||
not perform online revocation checks.
|
||||
</p>
|
||||
{inspection.findings.map((finding) => (
|
||||
<p
|
||||
className={`finding finding-${finding.severity}`}
|
||||
key={finding.message}
|
||||
>
|
||||
{finding.message}
|
||||
</p>
|
||||
))}
|
||||
<div className="item-list">
|
||||
{inspection.items.map((item) => (
|
||||
<article className="crypto-item" key={item.id}>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">{item.type}</p>
|
||||
<h3>{item.title}</h3>
|
||||
</div>
|
||||
</header>
|
||||
<dl className="facts">
|
||||
{Object.entries(item.facts).map(([name, value]) => (
|
||||
<div key={name}>
|
||||
<dt>{name}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{item.findings.map((finding) => (
|
||||
<p
|
||||
className={`finding finding-${finding.severity}`}
|
||||
key={finding.message}
|
||||
>
|
||||
{finding.message}
|
||||
</p>
|
||||
))}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{inspection.chain.length > 0 && (
|
||||
<section
|
||||
className="panel workspace"
|
||||
aria-labelledby="chain-heading"
|
||||
>
|
||||
<div>
|
||||
<p className="eyebrow">Explicit path</p>
|
||||
<h2 id="chain-heading">Issuer signatures</h2>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Certificate</th>
|
||||
<th>Issuer</th>
|
||||
<th>Signature</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inspection.chain.map((link) => (
|
||||
<tr key={`${link.child}-${link.issuer}`}>
|
||||
<td>{link.child}</td>
|
||||
<td>{link.issuer}</td>
|
||||
<td>
|
||||
{link.signatureValid
|
||||
? "Valid"
|
||||
: "Invalid / unsupported"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="muted">
|
||||
A valid signature link alone does not establish trust, purpose,
|
||||
revocation status, name validity, or complete RFC 5280 path
|
||||
validation.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
{certificates.length > 0 && (
|
||||
<section
|
||||
className="panel workspace"
|
||||
aria-labelledby="hostname-heading"
|
||||
>
|
||||
<div>
|
||||
<p className="eyebrow">Name check</p>
|
||||
<h2 id="hostname-heading">DNS subject-alternative name</h2>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>
|
||||
ASCII hostname checked against the first certificate
|
||||
</span>
|
||||
<input
|
||||
value={hostname}
|
||||
onChange={(event) => setHostname(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
{hostnameResult && (
|
||||
<p
|
||||
className={
|
||||
hostnameResult.valid ? "success" : "finding finding-warning"
|
||||
}
|
||||
>
|
||||
{hostnameResult.message}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<section className="panel workspace" aria-labelledby="boundary-heading">
|
||||
<div>
|
||||
<p className="eyebrow">v0.1 boundary</p>
|
||||
<h2 id="boundary-heading">What this release does not claim</h2>
|
||||
</div>
|
||||
<ul className="compact-list">
|
||||
<li>
|
||||
Private keys are identified and fingerprinted, never persisted,
|
||||
generated, or silently decrypted.
|
||||
</li>
|
||||
<li>
|
||||
PKCS #12/PFX, encrypted PKCS #8, CMS/JWS signature workflows and
|
||||
certificate issuance are deferred.
|
||||
</li>
|
||||
<li>
|
||||
No online OCSP, CRL download, Certificate Transparency query,
|
||||
intrusive scanning, or network request is made.
|
||||
</li>
|
||||
<li>
|
||||
Hostname checking uses DNS SAN values and intentionally does not
|
||||
fall back to legacy Common Name matching.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import "reflect-metadata";
|
||||
import {
|
||||
BasicConstraintsExtension,
|
||||
ExtendedKeyUsageExtension,
|
||||
KeyUsagesExtension,
|
||||
PemConverter,
|
||||
Pkcs10CertificateRequest,
|
||||
PublicKey,
|
||||
SubjectAlternativeNameExtension,
|
||||
X509Certificate,
|
||||
X509Crl,
|
||||
} from "@peculiar/x509";
|
||||
import { bytesToBase64Url, bytesToHex } from "@add-ideas/toolbox-helpers";
|
||||
|
||||
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_PEM_BLOCKS = 256;
|
||||
|
||||
export interface CryptoFinding {
|
||||
severity: "info" | "warning" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CryptoItem {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
facts: Record<string, string>;
|
||||
findings: CryptoFinding[];
|
||||
certificate?: X509Certificate;
|
||||
dnsNames?: string[];
|
||||
}
|
||||
|
||||
export interface CryptoInspection {
|
||||
items: CryptoItem[];
|
||||
findings: CryptoFinding[];
|
||||
chain: { child: string; issuer: string; signatureValid: boolean }[];
|
||||
}
|
||||
|
||||
export interface PemBlock {
|
||||
label: string;
|
||||
pem: string;
|
||||
bytes: Uint8Array;
|
||||
encrypted: boolean;
|
||||
}
|
||||
|
||||
const PEM_PATTERN =
|
||||
/-----BEGIN ([A-Z0-9][A-Z0-9 -]{0,80})-----([\s\S]*?)-----END \1-----/gu;
|
||||
|
||||
function buffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength,
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function boundedUtf8Bytes(value: string): number {
|
||||
const size = new TextEncoder().encode(value).byteLength;
|
||||
if (size > MAX_INPUT_BYTES)
|
||||
throw new Error("Input exceeds the 8 MiB inspection limit.");
|
||||
return size;
|
||||
}
|
||||
|
||||
export function parsePemBlocks(source: string): PemBlock[] {
|
||||
boundedUtf8Bytes(source);
|
||||
const blocks: PemBlock[] = [];
|
||||
for (const match of source.matchAll(PEM_PATTERN)) {
|
||||
if (blocks.length >= MAX_PEM_BLOCKS)
|
||||
throw new Error(`Input contains more than ${MAX_PEM_BLOCKS} PEM blocks.`);
|
||||
const label = match[1]!;
|
||||
const body = match[2]!;
|
||||
const encrypted =
|
||||
/(?:^|\n)(?:Proc-Type:\s*4,ENCRYPTED|DEK-Info:)/iu.test(body) ||
|
||||
label === "ENCRYPTED PRIVATE KEY";
|
||||
let rawData: ArrayBuffer;
|
||||
try {
|
||||
rawData = PemConverter.decodeFirst(match[0]);
|
||||
} catch {
|
||||
throw new Error(`The ${label} PEM block has invalid Base64 or framing.`);
|
||||
}
|
||||
const bytes = new Uint8Array(rawData);
|
||||
if (bytes.byteLength > MAX_INPUT_BYTES)
|
||||
throw new Error(`The ${label} block exceeds the 8 MiB limit.`);
|
||||
blocks.push({ label, pem: match[0], bytes, encrypted });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
async function sha256(bytes: BufferSource): Promise<string> {
|
||||
return (
|
||||
bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)))
|
||||
.toUpperCase()
|
||||
.match(/.{2}/gu)
|
||||
?.join(":") ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function algorithmName(algorithm: Algorithm): string {
|
||||
const details = algorithm as Algorithm & {
|
||||
namedCurve?: string;
|
||||
hash?: { name?: string };
|
||||
modulusLength?: number;
|
||||
};
|
||||
return [
|
||||
details.name,
|
||||
details.namedCurve,
|
||||
details.modulusLength ? `${details.modulusLength} bit` : "",
|
||||
details.hash?.name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function validityFindings(
|
||||
certificate: X509Certificate,
|
||||
now: Date,
|
||||
): CryptoFinding[] {
|
||||
if (now < certificate.notBefore)
|
||||
return [
|
||||
{
|
||||
severity: "warning",
|
||||
message: `Not valid before ${certificate.notBefore.toISOString()}.`,
|
||||
},
|
||||
];
|
||||
if (now > certificate.notAfter)
|
||||
return [
|
||||
{
|
||||
severity: "error",
|
||||
message: `Expired on ${certificate.notAfter.toISOString()}.`,
|
||||
},
|
||||
];
|
||||
const days = Math.ceil(
|
||||
(certificate.notAfter.getTime() - now.getTime()) / 86_400_000,
|
||||
);
|
||||
return days <= 30
|
||||
? [
|
||||
{
|
||||
severity: "warning",
|
||||
message: `Expires in ${days} day${days === 1 ? "" : "s"}.`,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
function extensionFacts(certificate: X509Certificate): Record<string, string> {
|
||||
const facts: Record<string, string> = {};
|
||||
const basic = certificate.getExtension(BasicConstraintsExtension);
|
||||
if (basic)
|
||||
facts["Basic constraints"] = basic.ca
|
||||
? `Certificate authority${basic.pathLength === undefined ? "" : `; path length ${basic.pathLength}`}`
|
||||
: "End entity";
|
||||
const usages = certificate.getExtension(KeyUsagesExtension);
|
||||
if (usages) facts["Key usage bits"] = `0x${usages.usages.toString(16)}`;
|
||||
const extended = certificate.getExtension(ExtendedKeyUsageExtension);
|
||||
if (extended) facts["Extended key usages"] = extended.usages.join(", ");
|
||||
facts.Extensions = String(certificate.extensions.length);
|
||||
return facts;
|
||||
}
|
||||
|
||||
async function inspectCertificate(
|
||||
pemOrBytes: string | Uint8Array,
|
||||
index: number,
|
||||
now: Date,
|
||||
): Promise<CryptoItem> {
|
||||
const certificate = new X509Certificate(
|
||||
typeof pemOrBytes === "string" ? pemOrBytes : buffer(pemOrBytes),
|
||||
);
|
||||
const san = certificate.getExtension(SubjectAlternativeNameExtension);
|
||||
const dnsNames =
|
||||
san?.names.items
|
||||
.filter((name) => name.type === "dns")
|
||||
.map((name) => name.value) ?? [];
|
||||
const selfSigned = await certificate.isSelfSigned().catch(() => false);
|
||||
return {
|
||||
id: `certificate-${index}`,
|
||||
type: "X.509 certificate",
|
||||
title: certificate.subject || `Certificate ${index + 1}`,
|
||||
facts: {
|
||||
Subject: certificate.subject || "(empty)",
|
||||
Issuer: certificate.issuer || "(empty)",
|
||||
Serial: certificate.serialNumber,
|
||||
"Valid from": certificate.notBefore.toISOString(),
|
||||
"Valid until": certificate.notAfter.toISOString(),
|
||||
"Public key": algorithmName(certificate.publicKey.algorithm),
|
||||
"Signature algorithm": algorithmName(certificate.signatureAlgorithm),
|
||||
"SHA-256 fingerprint": await sha256(certificate.rawData),
|
||||
"DNS names": dnsNames.join(", ") || "—",
|
||||
"Self-signed": selfSigned
|
||||
? "Yes (signature verified)"
|
||||
: "No or unverifiable",
|
||||
...extensionFacts(certificate),
|
||||
},
|
||||
findings: validityFindings(certificate, now),
|
||||
certificate,
|
||||
dnsNames,
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectBlock(
|
||||
block: PemBlock,
|
||||
index: number,
|
||||
now: Date,
|
||||
): Promise<CryptoItem> {
|
||||
if (block.label === "CERTIFICATE" || block.label === "X509 CERTIFICATE")
|
||||
return inspectCertificate(block.pem, index, now);
|
||||
if (
|
||||
block.label === "CERTIFICATE REQUEST" ||
|
||||
block.label === "NEW CERTIFICATE REQUEST"
|
||||
) {
|
||||
const request = new Pkcs10CertificateRequest(block.pem);
|
||||
return {
|
||||
id: `csr-${index}`,
|
||||
type: "PKCS #10 certificate request",
|
||||
title: request.subject || `Certificate request ${index + 1}`,
|
||||
facts: {
|
||||
Subject: request.subject || "(empty)",
|
||||
"Public key": algorithmName(request.publicKey.algorithm),
|
||||
"Signature algorithm": algorithmName(request.signatureAlgorithm),
|
||||
Extensions: String(request.extensions.length),
|
||||
"Signature valid": (await request.verify()) ? "Yes" : "No",
|
||||
"SHA-256 fingerprint": await sha256(request.rawData),
|
||||
},
|
||||
findings: [],
|
||||
};
|
||||
}
|
||||
if (block.label === "X509 CRL") {
|
||||
const crl = new X509Crl(block.pem);
|
||||
return {
|
||||
id: `crl-${index}`,
|
||||
type: "X.509 certificate revocation list",
|
||||
title: crl.issuer || `CRL ${index + 1}`,
|
||||
facts: {
|
||||
Issuer: crl.issuer,
|
||||
"This update": crl.thisUpdate.toISOString(),
|
||||
"Next update": crl.nextUpdate?.toISOString() ?? "—",
|
||||
"Revoked entries": String(crl.entries.length),
|
||||
"SHA-256 fingerprint": await sha256(crl.rawData),
|
||||
},
|
||||
findings:
|
||||
crl.nextUpdate && crl.nextUpdate < now
|
||||
? [
|
||||
{
|
||||
severity: "warning",
|
||||
message: "The CRL next-update time has passed.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
if (block.label === "PUBLIC KEY") {
|
||||
const key = new PublicKey(block.pem);
|
||||
return {
|
||||
id: `public-${index}`,
|
||||
type: "SubjectPublicKeyInfo",
|
||||
title: `Public key ${index + 1}`,
|
||||
facts: {
|
||||
Algorithm: algorithmName(key.algorithm),
|
||||
"SHA-256 SPKI fingerprint": await sha256(key.rawData),
|
||||
},
|
||||
findings: [],
|
||||
};
|
||||
}
|
||||
if (block.label.includes("PRIVATE KEY")) {
|
||||
return {
|
||||
id: `private-${index}`,
|
||||
type: block.label,
|
||||
title: `Private key ${index + 1}`,
|
||||
facts: {
|
||||
Encrypted: block.encrypted ? "Yes" : "No",
|
||||
Size: `${block.bytes.byteLength.toLocaleString()} bytes`,
|
||||
"SHA-256 fingerprint": await sha256(buffer(block.bytes)),
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
severity: block.encrypted ? "info" : "warning",
|
||||
message: block.encrypted
|
||||
? "Encrypted private-key material was identified but not decrypted."
|
||||
: "Unencrypted private-key material is sensitive. It is not persisted by this app.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: `pem-${index}`,
|
||||
type: block.label,
|
||||
title: `${block.label} ${index + 1}`,
|
||||
facts: {
|
||||
Size: `${block.bytes.byteLength.toLocaleString()} bytes`,
|
||||
"SHA-256 fingerprint": await sha256(buffer(block.bytes)),
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
severity: "warning",
|
||||
message:
|
||||
"This PEM object is identified but its internal structure is not supported in v0.1.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
type ExtendedJsonWebKey = JsonWebKey & {
|
||||
kid?: string;
|
||||
use?: string;
|
||||
key_ops?: string[];
|
||||
};
|
||||
type JsonWebKeySet = { keys: ExtendedJsonWebKey[] };
|
||||
|
||||
function isJwks(value: unknown): value is JsonWebKeySet {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
Array.isArray((value as { keys?: unknown }).keys)
|
||||
);
|
||||
}
|
||||
|
||||
function isJwk(value: unknown): value is ExtendedJsonWebKey {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as { kty?: unknown }).kty === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function thumbprintMembers(key: ExtendedJsonWebKey): Record<string, string> {
|
||||
if (key.kty === "RSA" && key.e && key.n)
|
||||
return { e: key.e, kty: key.kty, n: key.n };
|
||||
if (key.kty === "EC" && key.crv && key.x && key.y)
|
||||
return { crv: key.crv, kty: key.kty, x: key.x, y: key.y };
|
||||
if (key.kty === "OKP" && key.crv && key.x)
|
||||
return { crv: key.crv, kty: key.kty, x: key.x };
|
||||
if (key.kty === "oct" && key.k) return { k: key.k, kty: key.kty };
|
||||
throw new Error(
|
||||
`JWK ${key.kid ?? "without a kid"} lacks the RFC 7638 members for ${key.kty ?? "an unknown key type"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function inspectJwk(
|
||||
key: ExtendedJsonWebKey,
|
||||
index: number,
|
||||
): Promise<CryptoItem> {
|
||||
const canonical = JSON.stringify(thumbprintMembers(key));
|
||||
const digest = new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)),
|
||||
);
|
||||
const privateMembers = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"].filter(
|
||||
(name) => name in key,
|
||||
);
|
||||
return {
|
||||
id: `jwk-${index}`,
|
||||
type: "JSON Web Key",
|
||||
title: key.kid || `${key.kty ?? "Unknown"} key ${index + 1}`,
|
||||
facts: {
|
||||
Type: key.kty ?? "—",
|
||||
Curve: key.crv ?? "—",
|
||||
Algorithm: key.alg ?? "—",
|
||||
Use: key.use ?? "—",
|
||||
Operations: key.key_ops?.join(", ") ?? "—",
|
||||
"RFC 7638 SHA-256 thumbprint": bytesToBase64Url(digest, false),
|
||||
"Contains private material": privateMembers.length
|
||||
? `Yes (${privateMembers.join(", ")})`
|
||||
: "No",
|
||||
},
|
||||
findings: privateMembers.length
|
||||
? [
|
||||
{
|
||||
severity: "warning",
|
||||
message:
|
||||
"This JWK contains private or symmetric key material. It remains in memory only.",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectJson(source: string): Promise<CryptoItem[]> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(source);
|
||||
} catch {
|
||||
throw new Error("JSON input is not valid JSON.");
|
||||
}
|
||||
const candidates: unknown[] = isJwks(parsed) ? parsed.keys : [parsed];
|
||||
if (!candidates.every(isJwk))
|
||||
throw new Error(
|
||||
"JSON input must be a JWK or a JWKS containing only JWK objects.",
|
||||
);
|
||||
const keys = candidates;
|
||||
if (keys.length > 1_000)
|
||||
throw new Error("JWKS contains more than 1,000 keys.");
|
||||
return Promise.all(keys.map((key, index) => inspectJwk(key, index)));
|
||||
}
|
||||
|
||||
async function linkCertificates(
|
||||
items: CryptoItem[],
|
||||
): Promise<CryptoInspection["chain"]> {
|
||||
const certificates = items.filter(
|
||||
(item): item is CryptoItem & { certificate: X509Certificate } =>
|
||||
!!item.certificate,
|
||||
);
|
||||
const result: CryptoInspection["chain"] = [];
|
||||
for (const child of certificates) {
|
||||
if (child.certificate.subject === child.certificate.issuer) continue;
|
||||
const issuer = certificates.find(
|
||||
(candidate) => candidate.certificate.subject === child.certificate.issuer,
|
||||
);
|
||||
if (!issuer) continue;
|
||||
result.push({
|
||||
child: child.title,
|
||||
issuer: issuer.title,
|
||||
signatureValid: await child.certificate
|
||||
.verify({
|
||||
publicKey: issuer.certificate.publicKey,
|
||||
signatureOnly: true,
|
||||
})
|
||||
.catch(() => false),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function inspectCryptoInput(
|
||||
input: string | Uint8Array,
|
||||
now = new Date(),
|
||||
): Promise<CryptoInspection> {
|
||||
if (typeof input !== "string" && input.byteLength > MAX_INPUT_BYTES)
|
||||
throw new Error("Input exceeds the 8 MiB inspection limit.");
|
||||
const findings: CryptoFinding[] = [];
|
||||
let items: CryptoItem[];
|
||||
if (typeof input === "string" && input.trimStart().startsWith("{")) {
|
||||
boundedUtf8Bytes(input);
|
||||
items = await inspectJson(input);
|
||||
} else if (typeof input === "string") {
|
||||
const blocks = parsePemBlocks(input);
|
||||
if (!blocks.length)
|
||||
throw new Error("No supported PEM or JWK/JWKS object was found.");
|
||||
items = await Promise.all(
|
||||
blocks.map((block, index) => inspectBlock(block, index, now)),
|
||||
);
|
||||
} else {
|
||||
const candidates: ((bytes: Uint8Array) => Promise<CryptoItem>)[] = [
|
||||
(bytes) => inspectCertificate(bytes, 0, now),
|
||||
async (bytes) =>
|
||||
inspectBlock(
|
||||
{
|
||||
label: "CERTIFICATE REQUEST",
|
||||
pem: PemConverter.encode(buffer(bytes), "CERTIFICATE REQUEST"),
|
||||
bytes,
|
||||
encrypted: false,
|
||||
},
|
||||
0,
|
||||
now,
|
||||
),
|
||||
async (bytes) =>
|
||||
inspectBlock(
|
||||
{
|
||||
label: "X509 CRL",
|
||||
pem: PemConverter.encode(buffer(bytes), "X509 CRL"),
|
||||
bytes,
|
||||
encrypted: false,
|
||||
},
|
||||
0,
|
||||
now,
|
||||
),
|
||||
];
|
||||
let matched: CryptoItem | undefined;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
matched = await candidate(input);
|
||||
break;
|
||||
} catch {
|
||||
/* try the next DER model */
|
||||
}
|
||||
}
|
||||
if (!matched)
|
||||
throw new Error(
|
||||
"The binary input is not a supported DER certificate, CSR, or CRL. PKCS #12/PFX is identified but not decrypted in v0.1.",
|
||||
);
|
||||
items = [matched];
|
||||
}
|
||||
const chain = await linkCertificates(items);
|
||||
if (items.some((item) => item.certificate) && chain.length === 0)
|
||||
findings.push({
|
||||
severity: "info",
|
||||
message:
|
||||
"No complete issuer link was found. No browser or operating-system trust store is consulted.",
|
||||
});
|
||||
return { items, findings, chain };
|
||||
}
|
||||
|
||||
function dnsMatch(pattern: string, hostname: string): boolean {
|
||||
const left = pattern.toLowerCase().replace(/\.$/u, "");
|
||||
const right = hostname.toLowerCase().replace(/\.$/u, "");
|
||||
if (!left.includes("*")) return left === right;
|
||||
if (!left.startsWith("*.") || left.slice(2).includes("*")) return false;
|
||||
const suffix = left.slice(1);
|
||||
return (
|
||||
right.endsWith(suffix) && right.split(".").length === left.split(".").length
|
||||
);
|
||||
}
|
||||
|
||||
export function checkCertificateHostname(
|
||||
item: CryptoItem,
|
||||
hostname: string,
|
||||
): { valid: boolean; message: string } {
|
||||
const candidate = hostname.trim().replace(/\.$/u, "");
|
||||
const validHostname =
|
||||
candidate.length > 0 &&
|
||||
candidate.length <= 253 &&
|
||||
candidate
|
||||
.split(".")
|
||||
.every(
|
||||
(label) =>
|
||||
label.length > 0 &&
|
||||
label.length <= 63 &&
|
||||
/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u.test(label),
|
||||
);
|
||||
if (!validHostname)
|
||||
return {
|
||||
valid: false,
|
||||
message: "Enter a valid ASCII DNS hostname for this local check.",
|
||||
};
|
||||
if (!item.certificate)
|
||||
return { valid: false, message: "Select a certificate." };
|
||||
if (!item.dnsNames?.length)
|
||||
return {
|
||||
valid: false,
|
||||
message:
|
||||
"The certificate has no DNS subject-alternative names; legacy Common Name fallback is not used.",
|
||||
};
|
||||
const match = item.dnsNames.find((name) => dnsMatch(name, candidate));
|
||||
return match
|
||||
? { valid: true, message: `${candidate} matches ${match}.` }
|
||||
: {
|
||||
valid: false,
|
||||
message: `${candidate} does not match any DNS subject-alternative name.`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
const url = new URL("./sw.js", document.baseURI);
|
||||
void navigator.serviceWorker
|
||||
.register(url, { scope: new URL("./", document.baseURI).pathname })
|
||||
.catch(() => undefined);
|
||||
});
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
:root {
|
||||
--toolbox-background: #f6f7fb;
|
||||
--toolbox-surface: #fff;
|
||||
--toolbox-surface-soft: #eff1f7;
|
||||
--toolbox-text: #202332;
|
||||
--toolbox-muted: #656b7d;
|
||||
--toolbox-border: #d9dce7;
|
||||
--toolbox-accent: #5b4ec4;
|
||||
--toolbox-accent-hover: #493caf;
|
||||
--toolbox-accent-soft: #ece9ff;
|
||||
--toolbox-accent-contrast: #fff;
|
||||
--toolbox-focus: #137d75;
|
||||
--toolbox-danger: #b42342;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
min-width: 20rem;
|
||||
min-height: 100%;
|
||||
background: var(--toolbox-background);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
.button {
|
||||
min-height: 2.55rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled),
|
||||
.button:hover {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
:where(button, input, select, textarea, a):focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
textarea {
|
||||
min-height: 10rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
line-height: 1.48;
|
||||
}
|
||||
.toolbox-shell__main {
|
||||
width: min(100%, 90rem);
|
||||
padding: clamp(0.75rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hero,
|
||||
.panel {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
padding: clamp(1.1rem, 3vw, 2rem);
|
||||
}
|
||||
.hero h1,
|
||||
.panel h2,
|
||||
.panel h3,
|
||||
.help-dialog h2,
|
||||
.fatal h1 {
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.hero p:not(.eyebrow) {
|
||||
max-width: 52rem;
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 0.3rem;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.115em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.privacy-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.38rem 0.62rem;
|
||||
border-radius: 999px;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.capability-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.capability-grid article {
|
||||
padding: 0.9rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.72rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.capability-grid p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.48;
|
||||
}
|
||||
.workspace-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.workspace-tabs button[aria-selected="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.field > span {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.muted {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.result {
|
||||
padding: 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.68rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.loading,
|
||||
.fatal {
|
||||
width: min(100% - 2rem, 60rem);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.help-dialog {
|
||||
width: min(36rem, calc(100% - 2rem));
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
.help-dialog::backdrop {
|
||||
background: rgb(20 24 45 / 55%);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.panel-heading {
|
||||
align-items: center;
|
||||
}
|
||||
.file-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.file-button input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.primary {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.notice,
|
||||
.finding,
|
||||
.success {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.68rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
line-height: 1.48;
|
||||
}
|
||||
.finding-warning {
|
||||
border-color: #d9a72e;
|
||||
background: #fff8df;
|
||||
color: #725000;
|
||||
}
|
||||
.finding-error,
|
||||
.error {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 45%,
|
||||
var(--toolbox-border)
|
||||
);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 8%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
.finding-info {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.success {
|
||||
border-color: #55a875;
|
||||
background: #eaf8ef;
|
||||
color: #176137;
|
||||
}
|
||||
.item-list {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.crypto-item {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.crypto-item h3 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
}
|
||||
.facts div {
|
||||
min-width: 0;
|
||||
padding: 0.65rem;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.facts dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 780;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.facts dd {
|
||||
margin: 0.25rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.compact-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.6rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
th {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@media (max-width: 42rem) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
.privacy-pill {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.crypto-tools",
|
||||
"name": "Crypto Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect keys, certificates and signatures locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["security", "cryptography", "developer"],
|
||||
"tags": ["x509", "certificate", "jwk", "signature", "crypto"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": true,
|
||||
"workers": false,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/crypto-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/lotobo/crypto-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user