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

553 lines
16 KiB

<script lang="ts">
Phase 2, step 8: the open action of /inspect After steps 1 to 8, a valid capsule whose date has passed on the device clock can be opened in the page: steps 9 to 18 of spec section 63 with open, loaded on demand with a dynamic import (opener.ts), so noble and age-encryption stay out of the first load of every page. - The release is supplied directly by the person (spec 63, step 10): drand's JSON answer or the bare signature, pasted after opening the drand URL the page links to, or the release in the record of an official fixture. The page never fetches it and reads only its round and signature (spec 11, 13). The CSP is unchanged. - time_and_key credentials: a .dkk (readAccessKey reads at most 12 bytes + 16 MiB + 1) or age identities, one per line. - The plaintext of the person's own file goes to a temporary OPFS file (tempfile.ts), committed only after step 18 (spec 56), offered for download and deleted on request, with another capsule, on pagehide and, if left over, on the next visit. One directory and one Web Lock per tab keep other tabs' clean-up away from files in use. Without OPFS, or when the browser refuses it, capsules up to 64 MiB open in memory. An opening in progress stops when another capsule is loaded. - opening.ts builds the page model of steps 9 to 18 as the reference records them; fixtures show their plaintext and compare its SHA-256 with their record. - licenses.txt: the notices of tlock-js (ibe.ts) and age (bech32.ts), the license of every package in the client bundle, Vite's and rolldown's runtime code, and the site's own license. check-build now fails if a notice is missing, or if a page loads noble, @scure/base or age-encryption with its first load. - The home page no longer says that the page never asks for keys. Checked in the browser on the production build: the time_only, time_and_key_portable (with its .dkk) and time_and_key_recipients (with a pasted identity) fixtures open with the SHA-256 of their records; a tampered signature fails at step 10 and a tampered STREAM chunk at step 17, with no download and no file left; an own file opens to OPFS, downloads without a CSP violation and is deleted with its lock; a left over directory goes on the next visit; no request leaves the origin. An adversarial review (four dimensions, each finding checked by a refuter) confirmed 15 findings, all fixed here. 2611 tests; coverage 100 % of the new modules, now a threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
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,
Phase 2, step 8: the open action of /inspect After steps 1 to 8, a valid capsule whose date has passed on the device clock can be opened in the page: steps 9 to 18 of spec section 63 with open, loaded on demand with a dynamic import (opener.ts), so noble and age-encryption stay out of the first load of every page. - The release is supplied directly by the person (spec 63, step 10): drand's JSON answer or the bare signature, pasted after opening the drand URL the page links to, or the release in the record of an official fixture. The page never fetches it and reads only its round and signature (spec 11, 13). The CSP is unchanged. - time_and_key credentials: a .dkk (readAccessKey reads at most 12 bytes + 16 MiB + 1) or age identities, one per line. - The plaintext of the person's own file goes to a temporary OPFS file (tempfile.ts), committed only after step 18 (spec 56), offered for download and deleted on request, with another capsule, on pagehide and, if left over, on the next visit. One directory and one Web Lock per tab keep other tabs' clean-up away from files in use. Without OPFS, or when the browser refuses it, capsules up to 64 MiB open in memory. An opening in progress stops when another capsule is loaded. - opening.ts builds the page model of steps 9 to 18 as the reference records them; fixtures show their plaintext and compare its SHA-256 with their record. - licenses.txt: the notices of tlock-js (ibe.ts) and age (bech32.ts), the license of every package in the client bundle, Vite's and rolldown's runtime code, and the site's own license. check-build now fails if a notice is missing, or if a page loads noble, @scure/base or age-encryption with its first load. - The home page no longer says that the page never asks for keys. Checked in the browser on the production build: the time_only, time_and_key_portable (with its .dkk) and time_and_key_recipients (with a pasted identity) fixtures open with the SHA-256 of their records; a tampered signature fails at step 10 and a tampered STREAM chunk at step 17, with no download and no file left; an own file opens to OPFS, downloads without a CSP violation and is deleted with its lock; a left over directory goes on the next visit; no request leaves the origin. An adversarial review (four dimensions, each finding checked by a refuter) confirmed 15 findings, all fixed here. 2611 tests; coverage 100 % of the new modules, now a threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
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;
Phase 2, step 8: the open action of /inspect After steps 1 to 8, a valid capsule whose date has passed on the device clock can be opened in the page: steps 9 to 18 of spec section 63 with open, loaded on demand with a dynamic import (opener.ts), so noble and age-encryption stay out of the first load of every page. - The release is supplied directly by the person (spec 63, step 10): drand's JSON answer or the bare signature, pasted after opening the drand URL the page links to, or the release in the record of an official fixture. The page never fetches it and reads only its round and signature (spec 11, 13). The CSP is unchanged. - time_and_key credentials: a .dkk (readAccessKey reads at most 12 bytes + 16 MiB + 1) or age identities, one per line. - The plaintext of the person's own file goes to a temporary OPFS file (tempfile.ts), committed only after step 18 (spec 56), offered for download and deleted on request, with another capsule, on pagehide and, if left over, on the next visit. One directory and one Web Lock per tab keep other tabs' clean-up away from files in use. Without OPFS, or when the browser refuses it, capsules up to 64 MiB open in memory. An opening in progress stops when another capsule is loaded. - opening.ts builds the page model of steps 9 to 18 as the reference records them; fixtures show their plaintext and compare its SHA-256 with their record. - licenses.txt: the notices of tlock-js (ibe.ts) and age (bech32.ts), the license of every package in the client bundle, Vite's and rolldown's runtime code, and the site's own license. check-build now fails if a notice is missing, or if a page loads noble, @scure/base or age-encryption with its first load. - The home page no longer says that the page never asks for keys. Checked in the browser on the production build: the time_only, time_and_key_portable (with its .dkk) and time_and_key_recipients (with a pasted identity) fixtures open with the SHA-256 of their records; a tampered signature fails at step 10 and a tampered STREAM chunk at step 17, with no download and no file left; an own file opens to OPFS, downloads without a CSP violation and is deleted with its lock; a left over directory goes on the next visit; no request leaves the origin. An adversarial review (four dimensions, each finding checked by a refuter) confirmed 15 findings, all fixed here. 2611 tests; coverage 100 % of the new modules, now a threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
/** 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>
{: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>
<section class="block" aria-labelledby="steps-title">
<h3 id="steps-title">Pasos 1 a 8</h3>
<StepList steps={report.steps} />
</section>
Phase 2, step 8: the open action of /inspect After steps 1 to 8, a valid capsule whose date has passed on the device clock can be opened in the page: steps 9 to 18 of spec section 63 with open, loaded on demand with a dynamic import (opener.ts), so noble and age-encryption stay out of the first load of every page. - The release is supplied directly by the person (spec 63, step 10): drand's JSON answer or the bare signature, pasted after opening the drand URL the page links to, or the release in the record of an official fixture. The page never fetches it and reads only its round and signature (spec 11, 13). The CSP is unchanged. - time_and_key credentials: a .dkk (readAccessKey reads at most 12 bytes + 16 MiB + 1) or age identities, one per line. - The plaintext of the person's own file goes to a temporary OPFS file (tempfile.ts), committed only after step 18 (spec 56), offered for download and deleted on request, with another capsule, on pagehide and, if left over, on the next visit. One directory and one Web Lock per tab keep other tabs' clean-up away from files in use. Without OPFS, or when the browser refuses it, capsules up to 64 MiB open in memory. An opening in progress stops when another capsule is loaded. - opening.ts builds the page model of steps 9 to 18 as the reference records them; fixtures show their plaintext and compare its SHA-256 with their record. - licenses.txt: the notices of tlock-js (ibe.ts) and age (bech32.ts), the license of every package in the client bundle, Vite's and rolldown's runtime code, and the site's own license. check-build now fails if a notice is missing, or if a page loads noble, @scure/base or age-encryption with its first load. - The home page no longer says that the page never asks for keys. Checked in the browser on the production build: the time_only, time_and_key_portable (with its .dkk) and time_and_key_recipients (with a pasted identity) fixtures open with the SHA-256 of their records; a tampered signature fails at step 10 and a tampered STREAM chunk at step 17, with no download and no file left; an own file opens to OPFS, downloads without a CSP violation and is deleted with its lock; a left over directory goes on the next visit; no request leaves the origin. An adversarial review (four dimensions, each finding checked by a refuter) confirmed 15 findings, all fixed here. 2611 tests; coverage 100 % of the new modules, now a threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
{@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 versión</dt>
<dd><span class="mono">{p.magic}</span>, versión {p.version}</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);
}
.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.