You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DateKeys-App/src/lib/components/InspectionReport.svelte

623 lines
18 KiB

<script lang="ts">
import type { Snippet } from 'svelte';
import type { Report, Comparison } from '$lib/inspector/report.ts';
import {
displayText,
errorGloss,
escapeInvisible,
formatByteCount,
formatDateTime,
formatInteger,
formatRelative,
policyGloss,
} from '$lib/inspector/format.ts';
import StepList from './StepList.svelte';
import ExtensionList from './ExtensionList.svelte';
let {
report,
nowMs,
timeZone,
opening,
}: {
report: Report;
/** When the report was made, for the distance to the unlock date. */
nowMs: number;
/** The viewer's time zone, for example "Europe/Madrid". */
timeZone: string | undefined;
/** The opening of the capsule, after steps 1 to 8. */
opening?: Snippet;
} = $props();
let copyStatus = $state('');
let jsonOpen = $state(false);
let copyButton: HTMLButtonElement | undefined = $state();
const unlockMs = $derived(report.unlock?.epochMs);
const fileName = $derived(displayText(report.fileName));
const shownJSON = $derived(escapeInvisible(report.json));
async function copyJSON(): Promise<void> {
copyStatus = '';
try {
await navigator.clipboard.writeText(report.json);
copyStatus = 'JSON copiado.';
return;
} catch {
// No Clipboard API (an insecure context, a denied permission): fall
// back to a selected textarea and the legacy copy command.
}
if (legacyCopy(report.json)) {
copyStatus = 'JSON copiado.';
} else {
jsonOpen = true;
copyStatus = 'El navegador no deja copiar desde la página: selecciona el JSON de abajo y cópialo.';
}
copyButton?.focus();
}
function legacyCopy(text: string): boolean {
const area = document.createElement('textarea');
area.value = text;
area.readOnly = true;
area.className = 'visually-hidden';
document.body.append(area);
area.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch {
ok = false;
}
area.remove();
return ok;
}
function stanzaTypes(list: readonly { type: string }[]): string {
return list.length === 0 ? 'ninguno' : list.map((s) => s.type).join(', ');
}
function comparisonState(c: Comparison): string {
return c.match ? 'coincide' : 'no coincide';
}
</script>
<section class="report" aria-labelledby="report-title">
<header class="report-head">
<div class="file">
<h2 id="report-title" tabindex="-1">{fileName.text}</h2>
<p class="muted">
{formatByteCount(report.size)}{#if report.readLength < report.size}. Se leyeron los primeros {formatByteCount(
report.readLength,
)}: el resto {report.prelude ? 'de PAYLOAD_AGE' : 'del fichero'} no interviene en los pasos 1 a 8{/if}.
</p>
</div>
<div class="copy">
<button class="button quiet" type="button" onclick={copyJSON} bind:this={copyButton}>Copiar JSON</button>
<p class="copy-status" role="status">{copyStatus}</p>
</div>
</header>
<div class={['verdict', report.valid ? 'valid' : 'invalid']}>
<div class="state">
{#if report.valid}
<p class="state-word">Estructura válida</p>
<p>
Supera los pasos 1 a 8 de §63 sin red y sin secretos. Es una comprobación de estructura: la cabecera pública solo
queda vinculada al control al abrir la cápsula (paso 15), y ni siquiera entonces prueba quién la escribió ni
cuándo (§55.1).
</p>
{#if report.prelude?.format === 1}
<p class="format-note">
Formato 1, el de la v0.8.2: hasta la fecha, esta cápsula deja ver cuántas credenciales tiene y la longitud exacta
de su contenido. Los formatos 2 y 3 ocultan las dos cosas (§55.2).
</p>
{/if}
{:else if report.failure}
<p class="state-word">Rechazada en el paso {report.failure.step}</p>
<p class="code">{report.failure.code}</p>
<p>{errorGloss(report.failure.code)}</p>
{/if}
</div>
<div class="when">
<p class="when-label">Fecha de apertura</p>
{#if report.unlock && unlockMs !== undefined}
<p class="date"><time datetime={report.unlock.rfc3339}>{formatDateTime(unlockMs, 'UTC')}</time></p>
<p>
En tu hora local{timeZone ? ` (${timeZone})` : ''}: {formatDateTime(unlockMs)}
</p>
<p class="muted">
Ronda {report.capsule?.round} de {report.profile?.network ?? report.capsule?.network}; {unlockMs <= nowMs
? `la fecha ya pasó, ${formatRelative(unlockMs, nowMs)}`
: `la fecha todavía no ha llegado: ${formatRelative(unlockMs, nowMs)}`}.
</p>
{:else if report.unlock}
<p class="date"><time datetime={report.unlock.rfc3339}>{report.unlock.rfc3339}</time></p>
{:else}
<p class="muted">
Sin calcular: sale de la DateKey en el paso 7, {report.steps[6]?.state === 'failed' ? 'que falló' : 'que no llegó a ejecutarse'}.
</p>
{/if}
</div>
</div>
{#if report.profileStatus === 'compromised'}
<p class="compromised">
La red de esta cápsula, {report.profile?.network ?? report.capsule?.network}, figura como comprometida en el registro de
perfiles de DateKeys: su contenido pudo leerse antes de la fecha (§71).
</p>
{/if}
{#if report.note && 'text' in report.note}
<figure class="note">
<blockquote>{report.note.text}</blockquote>
<figcaption>
<p>Nota pública del creador, sin comprobar: la puede leer cualquiera que tenga el fichero.</p>
<p>Nadie puede comprobar antes de la fecha quién creó la cápsula ni si va firmada.</p>
</figcaption>
</figure>
{:else if report.note}
<p class="note-unusable">La cápsula lleva una nota pública que no cumple las reglas de texto: no se muestra.</p>
{/if}
<section class="block" aria-labelledby="steps-title">
<h3 id="steps-title">Pasos 1 a 8</h3>
<StepList steps={report.steps} />
</section>
{@render opening?.()}
<div class="grid">
<section class="block" aria-labelledby="header-title">
<h3 id="header-title">Cabecera pública</h3>
{#if report.capsule}
{@const c = report.capsule}
<dl>
<div>
<dt>capsule_id</dt>
<dd class="mono">{c.capsuleId}</dd>
</div>
<div>
<dt>access_policy</dt>
<dd><span class="mono">{c.accessPolicy}</span> <span class="muted">{policyGloss(c.accessPolicy)}</span></dd>
</div>
<div>
<dt>DateKey</dt>
<dd class="mono">{c.dateKey}</dd>
</div>
<div>
<dt>DateKey decodificada</dt>
<dd class="mono">{c.dateKeyJSON}</dd>
</div>
<div>
<dt>Red (network)</dt>
<dd class="mono">{c.network}</dd>
</div>
<div>
<dt>Ronda</dt>
<dd class="mono">{c.round}</dd>
</div>
<div>
<dt>unlock_at</dt>
<dd class="mono">{report.unlock?.rfc3339 ?? 'sin calcular'}</dd>
</div>
</dl>
{:else}
<p class="muted">PUBLIC_HEADER no llegó a decodificarse.</p>
{/if}
</section>
<section class="block" aria-labelledby="profile-title">
<h3 id="profile-title">Perfil fijado</h3>
{#if report.profile}
{@const p = report.profile}
<dl>
<div>
<dt>profile_id</dt>
<dd class="mono">{p.id}</dd>
</div>
<div>
<dt>Proveedor y red</dt>
<dd><span class="mono">{p.provider}</span>, <span class="mono">{p.network}</span></dd>
</div>
<div>
<dt>Cadena (chain hash)</dt>
<dd class="mono">{p.chainHash}</dd>
</div>
<div>
<dt>Periodo</dt>
<dd>{p.period} s por ronda</dd>
</div>
<div>
<dt>Génesis</dt>
<dd class="mono">{p.genesis}</dd>
</div>
<div>
<dt>Esquema</dt>
<dd class="mono">{p.scheme}</dd>
</div>
</dl>
{:else if report.capsule}
<p class="muted">
La DateKey nombra el perfil <span class="mono">{report.capsule.network}</span>, que este lector no tiene fijado.
</p>
{:else}
<p class="muted">Se elige con la DateKey de PUBLIC_HEADER, que no llegó a decodificarse.</p>
{/if}
</section>
<section class="block" aria-labelledby="prelude-title">
<h3 id="prelude-title">Prelude</h3>
{#if report.prelude}
{@const p = report.prelude}
<dl>
<div>
<dt>Bytes</dt>
<dd class="mono">{p.hex}</dd>
</div>
<div>
<dt>Marca y formato</dt>
<dd><span class="mono">{p.magic}</span>, formato {p.format}{p.format === 1 ? ', el de la v0.8.2' : ''}</dd>
</div>
<div>
<dt>FLAGS y RESERVED</dt>
<dd><span class="mono">{p.flags}</span>, <span class="mono">{p.reserved}</span></dd>
</div>
<div>
<dt>PUBLIC_HEADER_LEN</dt>
<dd>{formatByteCount(p.publicHeaderLen)}</dd>
</div>
<div>
<dt>SEALED_CONTROL_LEN</dt>
<dd>{formatByteCount(p.sealedControlLen)}</dd>
</div>
<div>
<dt>PAYLOAD_AGE</dt>
<dd>
desde el byte {formatInteger(p.payloadOffset)}{#if p.payloadLength !== undefined}, {formatByteCount(
p.payloadLength,
)}{:else}, fuera del fichero{/if}
</dd>
</div>
</dl>
{:else}
<p class="muted">El prelude no se superó.</p>
{/if}
</section>
<section class="block" aria-labelledby="stanzas-title">
<h3 id="stanzas-title">Stanzas age</h3>
<dl>
<div>
<dt>OUTER_TIME_AGE</dt>
<dd>
{#if report.outerStanzas}
{report.outerStanzas.length}
{report.outerStanzas.length === 1 ? 'stanza' : 'stanzas'}: <span class="mono">{stanzaTypes(report.outerStanzas)}</span>
<span class="muted">(se exige exactamente uno, tlock)</span>
{:else}
<span class="muted">sin leer</span>
{/if}
</dd>
</div>
<div>
<dt>PAYLOAD_AGE</dt>
<dd>
{#if report.payloadStanzas}
{report.payloadStanzas.length}
{report.payloadStanzas.length === 1 ? 'stanza' : 'stanzas'}: <span class="mono">{stanzaTypes(report.payloadStanzas)}</span>
<span class="muted">(se exige exactamente uno, X25519)</span>
{:else}
<span class="muted">sin leer</span>
{/if}
</dd>
</div>
</dl>
<h4 class="sub">Stanza tlock frente al perfil fijado</h4>
{#if report.tlock}
<ul class="compare">
{#each report.tlock as c (c.label)}
<li class={c.match ? 'match' : 'mismatch'}>
<p class="compare-head">
<span>{c.label}</span>
<span class="verdict-word">{comparisonState(c)}</span>
</p>
<p><span class="muted">En el stanza:</span> <span class="mono">{c.found ?? 'falta'}</span></p>
<p><span class="muted">Esperado:</span> <span class="mono">{c.expected}</span></p>
</li>
{/each}
</ul>
{:else if report.outerStanzas}
<p class="muted">OUTER_TIME_AGE no trae ningún stanza tlock.</p>
{:else}
<p class="muted">Se compara cuando se leen la cabecera, el perfil y OUTER_TIME_AGE.</p>
{/if}
</section>
</div>
<section class="block" aria-labelledby="ext-title">
<h3 id="ext-title">Extensiones de PUBLIC_HEADER</h3>
<p class="prose muted">
Este inspector implementa el protocolo base V1 y no conoce ninguna extensión: una crítica hace fallar el paso 4 y
las no críticas se ignoran. Sus datos son bytes opacos para el protocolo.
</p>
{#if report.extensions}
<ExtensionList extensions={report.extensions} />
{:else}
<p class="muted">PUBLIC_HEADER no llegó a decodificarse.</p>
{/if}
</section>
<section class="block" aria-labelledby="json-title">
<h3 id="json-title">Salida de la CLI</h3>
<details bind:open={jsonOpen}>
<summary>JSON de <code>datekeys inspect -json</code></summary>
{#if shownJSON !== report.json}
<p class="json-note">
Los caracteres invisibles o de control se muestran escapados (<code>&#92;uXXXX</code>, el mismo JSON).
<strong>Copiar JSON</strong> copia la salida exacta de la CLI.
</p>
{/if}
<pre class="json">{shownJSON}</pre>
</details>
</section>
</section>
<style>
/* minmax(0, 1fr): a child's unbreakable content must not widen the track
beyond the screen. */
.report {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 2.5rem;
}
.report-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 1rem 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--rule);
}
.file h2 {
font-family: var(--mono);
font-size: var(--t-h2);
font-weight: 650;
letter-spacing: 0;
overflow-wrap: anywhere;
}
.file p {
margin-top: 0.25rem;
font-size: var(--t-small);
}
.copy {
display: grid;
justify-items: end;
gap: 0.25rem;
}
.copy-status {
font-size: var(--t-small);
color: var(--ink-muted);
min-height: 1.3em;
text-align: right;
max-width: 28rem;
}
/* The verdict: the one loud moment of the page. */
.verdict {
display: grid;
border: 1px solid var(--rule);
border-top: 6px solid var(--state);
border-radius: var(--radius);
background: var(--paper-2);
}
.verdict.valid {
--state: var(--pass);
}
.verdict.invalid {
--state: var(--fail);
}
@media (min-width: 820px) {
.verdict {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
}
.when {
border-top: 0;
border-left: 1px solid var(--rule);
}
}
.state,
.when {
padding: 1.25rem 1.25rem 1.5rem;
display: grid;
align-content: start;
gap: 0.5rem;
}
.when {
border-top: 1px solid var(--rule);
}
.format-note {
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
background: var(--caution-bg);
font-size: var(--t-small);
}
/* The note is the creator's voice, not the page's: the serif of the
letter, on the caution of what nobody has checked. */
.note {
margin: -1.25rem 0 0;
padding: 1rem 1.25rem;
border-left: 4px solid var(--orange);
border-radius: 0 var(--radius) var(--radius) 0;
background: var(--caution-bg);
}
.note blockquote {
margin: 0;
max-width: var(--measure);
font-family: var(--serif);
font-size: var(--t-lead);
line-height: 1.5;
overflow-wrap: anywhere;
}
.note figcaption {
margin-top: 0.6rem;
font-size: var(--t-small);
color: var(--orange-text);
}
.note figcaption p {
margin: 0;
}
.compromised {
margin: -1.25rem 0 0;
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
background: var(--fail-bg);
color: var(--fail);
}
.note-unusable {
margin: -1.25rem 0 0;
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
background: var(--caution-bg);
font-size: var(--t-small);
}
.state-word {
font-size: var(--t-h2);
font-weight: 700;
letter-spacing: -0.015em;
line-height: 1.2;
color: var(--state);
}
.code {
font-family: var(--mono);
font-weight: 600;
overflow-wrap: anywhere;
}
.when-label {
font-size: var(--t-small);
font-weight: 600;
color: var(--ink-muted);
}
.date {
font-size: var(--t-date);
font-weight: 650;
line-height: 1.15;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
text-wrap: balance;
}
.block {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
align-content: start;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 2.5rem 3rem;
}
@media (min-width: 900px) {
.grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
dl {
display: grid;
border-top: 1px solid var(--rule);
}
dl > div {
display: grid;
gap: 0.1rem 1rem;
padding-block: 0.55rem;
border-bottom: 1px solid var(--rule);
}
@media (min-width: 560px) {
dl > div {
grid-template-columns: 11rem minmax(0, 1fr);
}
}
dt {
font-size: var(--t-small);
font-weight: 600;
color: var(--ink-muted);
overflow-wrap: anywhere;
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
dd.mono,
dd .mono {
font-size: 0.85rem;
}
.sub {
font-size: var(--t-body);
margin-top: 0.5rem;
}
.compare {
list-style: none;
padding: 0;
display: grid;
gap: 0.6rem;
}
.compare li {
padding: 0.6rem 0.75rem;
border-left: 3px solid var(--pass);
background: var(--paper-2);
border-radius: 0 var(--radius) var(--radius) 0;
overflow-wrap: anywhere;
font-size: var(--t-small);
}
.compare li.mismatch {
border-left-color: var(--fail);
background: var(--fail-bg);
}
.compare-head {
display: flex;
justify-content: space-between;
gap: 1rem;
font-weight: 600;
font-size: var(--t-body);
}
.compare .verdict-word {
font-size: var(--t-small);
color: var(--pass);
}
.compare li.mismatch .verdict-word {
color: var(--fail);
}
.compare .mono {
font-size: 0.8rem;
}
details summary {
cursor: pointer;
font-weight: 600;
width: fit-content;
padding-block: 0.25rem;
}
.json-note {
margin-top: 0.75rem;
font-size: var(--t-small);
color: var(--ink-muted);
max-width: var(--measure);
}
.json {
margin-top: 0.75rem;
padding: 0.85rem 1rem;
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius);
font-size: 0.8rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>

Powered by TurnKey Linux.