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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions frontend/src/app/api/v2/certificates/pubkey/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

// PÚBLICO: clave pública de firma para verificar actas offline, sin cuenta.
export async function GET(request: NextRequest) {
return proxyToBackend(request, "/v2/certificates/pubkey", { method: "GET" });
}

export const maxDuration = 60;
16 changes: 16 additions & 0 deletions frontend/src/app/api/v2/certificates/verify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextRequest } from "next/server";

import { proxyToBackend } from "@/lib/server/proxy";

// PÚBLICO: la verificación es cripto pura; el proxy solo reenvía Authorization
// si viene, así que funciona sin sesión (un tercero verifica sin cuenta).
export async function POST(request: NextRequest) {
const body = await request.text();
return proxyToBackend(request, "/v2/certificates/verify", {
method: "POST",
body,
contentType: "application/json",
});
}

export const maxDuration = 60;
219 changes: 219 additions & 0 deletions frontend/src/app/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
"use client";

import { useMutation, useQuery } from "@tanstack/react-query";
import { ShieldCheck, ShieldX, KeyRound } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";

import { getCertificatePubkey, verifyCertificate } from "@/lib/api/endpoints";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";

type Payload = { canonical_json: Record<string, unknown>; signature: string };

/**
* Acepta o bien el acta completa que devuelve Mnemo ({..., canonical_json, signature})
* o bien un objeto mínimo {canonical_json, signature}. Lanza con un mensaje humano.
*/
function extractPayload(raw: string): Payload {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("El texto pegado no es JSON válido.");
}
if (typeof parsed !== "object" || parsed === null) {
throw new Error("El JSON debe ser el objeto del acta.");
}
const obj = parsed as Record<string, unknown>;
const canonical = (obj.canonical_json ?? obj) as Record<string, unknown>;
const signature = obj.signature;
if (typeof signature !== "string" || signature.length === 0) {
throw new Error("Falta el campo 'signature' del acta.");
}
if (typeof canonical !== "object" || canonical === null || !("schema" in canonical)) {
throw new Error("No se reconoce el 'canonical_json' del acta.");
}
return { canonical_json: canonical, signature };
}

function asString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}

const VERDICT_STYLE: Record<string, string> = {
apto: "bg-emerald-100 text-emerald-800 border-emerald-200",
"apto-con-reservas": "bg-amber-100 text-amber-800 border-amber-200",
"no-apto": "bg-red-100 text-red-800 border-red-200",
};

export default function VerifyPage() {
const [raw, setRaw] = useState("");
const [payload, setPayload] = useState<Payload | null>(null);

const pubkey = useQuery({
queryKey: ["cert-pubkey"],
queryFn: getCertificatePubkey,
staleTime: Infinity,
retry: false,
});

const verify = useMutation({
mutationFn: (p: Payload) => verifyCertificate(p),
onError: (e: Error) => toast.error(e.message),
});

function onVerify() {
let p: Payload;
try {
p = extractPayload(raw);
} catch (e) {
toast.error((e as Error).message);
setPayload(null);
verify.reset();
return;
}
setPayload(p);
verify.mutate(p);
}

const identity = (payload?.canonical_json.identity ?? {}) as Record<string, unknown>;
const verdict = asString(payload?.canonical_json.verdict) ?? "";
const valido = verify.data?.valido === true;
const invalido = verify.isSuccess && verify.data?.valido === false;

return (
<main className="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 md:py-12">
<Link href="/" className="text-sm text-zinc-500 hover:text-zinc-800">
← Volver al inicio
</Link>

<header className="mt-4 space-y-2">
<h1 className="text-2xl font-semibold text-zinc-900 sm:text-3xl">
Verificar un acta de calidad
</h1>
<p className="max-w-2xl text-sm text-zinc-600">
Pega el acta de aseguramiento de QA firmada por Mnemo. Comprobamos la firma{" "}
<span className="font-medium">Ed25519</span> contra la clave pública, sin
necesidad de cuenta. Si la firma es válida, el acta no ha sido alterada desde
que se emitió.
</p>
</header>

<Card className="mt-6">
<CardHeader>
<CardTitle className="text-base">El acta (JSON)</CardTitle>
<CardDescription>
Pega el certificado completo que recibiste o descargaste; se usan sus campos{" "}
<code className="font-mono text-xs">canonical_json</code> y{" "}
<code className="font-mono text-xs">signature</code>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Textarea
value={raw}
onChange={(e) => setRaw(e.target.value)}
rows={10}
spellCheck={false}
placeholder='{ "canonical_json": { ... }, "signature": "..." }'
className="font-mono text-xs"
aria-label="Acta en formato JSON"
/>
<Button onClick={onVerify} disabled={!raw.trim() || verify.isPending}>
{verify.isPending ? "Verificando…" : "Verificar firma"}
</Button>
</CardContent>
</Card>

{valido && (
<Card className="mt-6 border-emerald-200 bg-emerald-50/60">
<CardContent className="space-y-4 pt-6">
<div className="flex items-center gap-2 text-emerald-800">
<ShieldCheck className="h-6 w-6" />
<span className="text-lg font-semibold">Firma válida</span>
</div>
<p className="text-sm text-emerald-900/80">
El acta es auténtica y no ha sido modificada desde su emisión.
</p>
<dl className="grid grid-cols-1 gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
<Field label="Veredicto">
<Badge className={VERDICT_STYLE[verdict] ?? ""}>{verdict || "—"}</Badge>
</Field>
<Field label="Riesgo">
{String(payload?.canonical_json.risk_score ?? "—")}/100
</Field>
<Field label="Proyecto">{asString(identity.project) ?? "—"}</Field>
<Field label="Commit">
<span className="font-mono text-xs">
{(asString(identity.commit_sha) ?? "—").slice(0, 12)}
</span>
</Field>
<Field label="Run">
<span className="font-mono text-xs break-all">
{asString(identity.run_id) ?? "—"}
</span>
</Field>
<Field label="Emitido">{asString(identity.created_at) ?? "—"}</Field>
</dl>
</CardContent>
</Card>
)}

{invalido && (
<Card className="mt-6 border-red-200 bg-red-50/60">
<CardContent className="space-y-2 pt-6">
<div className="flex items-center gap-2 text-red-800">
<ShieldX className="h-6 w-6" />
<span className="text-lg font-semibold">Firma NO válida</span>
</div>
<p className="text-sm text-red-900/80">
El acta ha sido alterada, está incompleta, o fue firmada con otra clave. No
confíes en su contenido.
</p>
</CardContent>
</Card>
)}

<section className="mt-8">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-zinc-700">
<KeyRound className="h-4 w-4" />
Clave pública de firma
</div>
{pubkey.isError && (
<p className="text-sm text-zinc-500">
La clave pública no está disponible en este despliegue.
</p>
)}
{pubkey.data && (
<div className="space-y-2">
<p className="text-xs text-zinc-500">
Algoritmo: <span className="font-mono">{pubkey.data.algorithm}</span>. Puedes
verificar el acta también de forma offline con esta clave.
</p>
<pre className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-50 p-3 font-mono text-[11px] text-zinc-600">
{pubkey.data.public_key_pem}
</pre>
</div>
)}
</section>
</main>
);
}

function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<dt className="text-xs uppercase tracking-wide text-zinc-500">{label}</dt>
<dd className="text-zinc-900">{children}</dd>
</div>
);
}
35 changes: 35 additions & 0 deletions frontend/src/lib/api/__tests__/certificate-verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { getCertificatePubkey, verifyCertificate } from "@/lib/api/endpoints";

afterEach(() => vi.restoreAllMocks());

function mockFetch(json: unknown, ok = true, status = 200) {
return vi.spyOn(global, "fetch").mockResolvedValue({
ok,
status,
text: async () => JSON.stringify(json),
} as unknown as Response);
}

describe("endpoints públicos de verificación de certificados", () => {
it("verifyCertificate hace POST a /api/v2/certificates/verify SIN Authorization", async () => {
const spy = mockFetch({ valido: true });
const out = await verifyCertificate({ canonical_json: { schema: "mnemo.cert.v2" }, signature: "sig" });
const [url, init] = spy.mock.calls[0];
expect(String(url)).toBe("/api/v2/certificates/verify");
expect((init as RequestInit).method).toBe("POST");
expect(new Headers((init as RequestInit).headers).has("Authorization")).toBe(false);
expect(out.valido).toBe(true);
});

it("getCertificatePubkey hace GET a /api/v2/certificates/pubkey SIN Authorization", async () => {
const spy = mockFetch({ algorithm: "ed25519", public_key_pem: "-----BEGIN PUBLIC KEY-----" });
const out = await getCertificatePubkey();
const [url, init] = spy.mock.calls[0];
expect(String(url)).toBe("/api/v2/certificates/pubkey");
expect((init as RequestInit).method).toBe("GET");
expect(new Headers((init as RequestInit).headers).has("Authorization")).toBe(false);
expect(out.algorithm).toBe("ed25519");
});
});
15 changes: 15 additions & 0 deletions frontend/src/lib/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type {
BriefingResponse,
CalibrationMetrics,
Certificate,
CertificatePubkey,
CertificateVerifyResponse,
CoverageGap,
DefectFamilyResponse,
DefectLineageResponse,
Expand Down Expand Up @@ -148,6 +150,19 @@ export function getCertificate(token: string, runId: string) {
`/api/v2/certificates/${encodeURIComponent(runId)}`, "GET", { token });
}

// Verificación PÚBLICA — sin token: cualquiera (auditor, cliente, regulador)
// valida la firma de un acta sin cuenta en Mnemo.
export function verifyCertificate(body: {
canonical_json: Record<string, unknown>;
signature: string;
}) {
return apiRequest<CertificateVerifyResponse>("/api/v2/certificates/verify", "POST", { body });
}

export function getCertificatePubkey() {
return apiRequest<CertificatePubkey>("/api/v2/certificates/pubkey", "GET", {});
}

export function publishGate(token: string, runId: string) {
return apiRequest<GateResult>(
`/api/v2/gate/run/${encodeURIComponent(runId)}`, "POST", { token });
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,15 @@ export interface Certificate {
created_at?: string | null;
}

export interface CertificateVerifyResponse {
valido: boolean;
}

export interface CertificatePubkey {
algorithm: string;
public_key_pem: string;
}

export interface GateResult {
verdict: string;
conclusion: string;
Expand Down
Loading