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.
623 lines
18 KiB
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>\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>
|