Files
Shade/docs/shade-overview.html
Sterister 7d214dc614 feat: persistent storage — SQLite backends for crash resilience
Shade sessions and keys now survive server crashes, container restarts,
and power outages via SQLite with WAL mode.

New packages:
- @shade/storage-sqlite: SQLiteStorage (StorageProvider) + SqlitePrekeyStore
  (PrekeyStore), both using bun:sqlite with auto-created tables and WAL mode
- Serialization layer in shade-core for SessionState/keys ↔ JSON/base64

Docker usage: mount volume at /data, set SHADE_DB_PATH=/data/shade-client.db
Prekey server auto-detects SHADE_PREKEY_DB_PATH for SQLite persistence

Includes crash recovery integration test: encrypt → close DB → reopen →
conversation continues seamlessly.

129 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:19:54 +02:00

619 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shade — ende-til-ende kryptering som modul</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,400&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap" rel="stylesheet" />
<style>
:root {
--bg: #0f1210;
--bg-elevated: #181d1a;
--surface: #1f2622;
--border: #2d3832;
--text: #e8ebe6;
--muted: #8a9a8f;
--accent: #6ee7a8;
--accent-dim: #3d8f63;
--warning: #f0c674;
--font-display: "Fraunces", Georgia, serif;
--font-body: "Source Serif 4", Georgia, serif;
--radius: 10px;
--shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--font-body);
font-size: 1.05rem;
line-height: 1.65;
color: var(--text);
background:
radial-gradient(ellipse 120% 80% at 50% -20%, rgba(110, 231, 168, 0.08), transparent 50%),
var(--bg);
}
.wrap {
max-width: 720px;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
}
header {
margin-bottom: 2.5rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border);
}
h1 {
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 2.75rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.75rem;
line-height: 1.15;
}
.lede {
font-size: 1.15rem;
color: var(--muted);
margin: 0;
max-width: 38ch;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.25rem;
}
.badge {
font-family: var(--font-display);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--accent);
}
h2 {
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 600;
margin: 2.25rem 0 0.75rem;
color: var(--text);
}
h3 {
font-family: var(--font-display);
font-size: 1.05rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
}
p {
margin: 0 0 1rem;
}
a {
color: var(--accent);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
a:hover {
color: #9af0c4;
}
.callout {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.15rem;
margin: 1.25rem 0;
box-shadow: var(--shadow);
}
.callout strong {
color: var(--accent);
font-weight: 600;
}
/* Tabs */
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 1.5rem 0 0;
border-bottom: 1px solid var(--border);
padding-bottom: 0;
}
.tab-btn {
font-family: var(--font-display);
font-size: 0.9rem;
padding: 0.6rem 1rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.tab-btn:hover {
color: var(--text);
background: rgba(110, 231, 168, 0.06);
}
.tab-btn[aria-selected="true"] {
color: var(--text);
background: var(--surface);
border-color: var(--border);
border-bottom-color: var(--surface);
margin-bottom: -1px;
position: relative;
z-index: 1;
}
.tab-panel {
display: none;
background: var(--surface);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
padding: 1.25rem 1.25rem 1.1rem;
margin-bottom: 1.5rem;
}
.tab-panel.active {
display: block;
}
.tab-panel ul {
margin: 0.5rem 0 0;
padding-left: 1.2rem;
}
.tab-panel li {
margin-bottom: 0.35rem;
}
code, .mono {
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", monospace;
font-size: 0.88em;
background: rgba(0, 0, 0, 0.35);
padding: 0.12em 0.35em;
border-radius: 4px;
}
/* Accordion */
.accordion {
margin: 1.5rem 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--bg-elevated);
}
.acc-item + .acc-item {
border-top: 1px solid var(--border);
}
.acc-trigger {
width: 100%;
text-align: left;
font-family: var(--font-display);
font-size: 1rem;
font-weight: 600;
padding: 1rem 1.15rem;
background: transparent;
color: var(--text);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.acc-trigger:hover {
background: rgba(110, 231, 168, 0.05);
}
.acc-trigger::after {
content: "▼";
font-size: 0.55rem;
color: var(--accent);
transition: transform 0.2s;
}
.acc-trigger[aria-expanded="false"]::after {
transform: rotate(-90deg);
}
.acc-panel {
padding: 0 1.15rem 1rem;
color: var(--muted);
}
.acc-panel[hidden] {
display: none;
}
/* Flow simulator */
.flow {
margin: 2rem 0;
padding: 1.25rem;
background: var(--surface);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.flow h3 {
margin-top: 0;
font-family: var(--font-display);
}
.flow-steps {
display: grid;
gap: 0.65rem;
margin: 1rem 0;
}
.flow-step {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
align-items: start;
padding: 0.65rem 0.75rem;
border-radius: 8px;
background: var(--bg);
border: 1px solid transparent;
opacity: 0.35;
transition: opacity 0.25s, border-color 0.25s, box-shadow 0.25s;
}
.flow-step.active {
opacity: 1;
border-color: var(--accent-dim);
box-shadow: 0 0 0 1px rgba(110, 231, 168, 0.15);
}
.flow-num {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.85rem;
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--accent-dim);
color: var(--bg);
}
.flow-step.active .flow-num {
background: var(--accent);
color: var(--bg);
}
.flow-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.btn {
font-family: var(--font-display);
font-size: 0.9rem;
font-weight: 600;
padding: 0.55rem 1.1rem;
border-radius: 8px;
border: 1px solid var(--accent-dim);
background: rgba(110, 231, 168, 0.12);
color: var(--accent);
cursor: pointer;
}
.btn:hover {
background: rgba(110, 231, 168, 0.22);
}
.btn-secondary {
border-color: var(--border);
background: transparent;
color: var(--muted);
}
.btn-secondary:hover {
color: var(--text);
border-color: var(--muted);
}
footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.9rem;
color: var(--muted);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Shade</h1>
<p class="lede">
En gjenbrukbar modul for <strong style="color: var(--text); font-weight: 600;">ende-til-ende-kryptert</strong> kommunikasjon i egne apper — med samme type protokoll som brukes i Signal.
</p>
<div class="badge-row">
<span class="badge">X3DH</span>
<span class="badge">Double Ratchet</span>
<span class="badge">TypeScript</span>
<span class="badge">Plattformagnostisk crypto</span>
</div>
</header>
<section id="hva">
<h2>Hva gjør prosjektet?</h2>
<p>
<strong>Shade</strong> er et monorepo som implementerer sikker meldingskryptering mellom to parter (for eksempel nettleser og server, eller to klienter). Meldingene er kryptert slik at transportlaget (HTTP, WebSocket, e.l.) bare ser uleselige bytes — ikke innholdet.
</p>
<div class="callout">
<strong>Kjerneideen:</strong> Du bygger inn <code>ShadeSessionManager</code> (fra <code>@shade/core</code>) sammen med en <code>CryptoProvider</code> (f.eks. Web Crypto i nettleser/Bun) og lagring. Deretter kan du kalle <code>encrypt</code> / <code>decrypt</code> per motpart, akkurat som i demo-koden <code>demo.ts</code>.
</div>
<p>
Første melding til noen ny inneholder nøkkelavtale (X3DH). Etterpå bruker hver melding <em>Double Ratchet</em>: nye meldingsnøkler og periodiske DH-steg gir <strong>forward secrecy</strong> (gamle meldinger overlever ikke nøkkellekkasje) og <strong>post-compromise security</strong> (systemet «helbreder» seg over tid etter kompromittering).
</p>
</section>
<section id="pakker">
<h2>Pakkene (hvordan det henger sammen)</h2>
<div class="tabs" role="tablist" aria-label="Pakkeoversikt">
<button type="button" class="tab-btn" role="tab" id="tab-core" aria-selected="true" aria-controls="panel-core">shade-core</button>
<button type="button" class="tab-btn" role="tab" id="tab-crypto" aria-selected="false" aria-controls="panel-crypto">shade-crypto-web</button>
<button type="button" class="tab-btn" role="tab" id="tab-proto" aria-selected="false" aria-controls="panel-proto">shade-proto</button>
<button type="button" class="tab-btn" role="tab" id="tab-transport" aria-selected="false" aria-controls="panel-transport">shade-transport</button>
<button type="button" class="tab-btn" role="tab" id="tab-server" aria-selected="false" aria-controls="panel-server">shade-server</button>
</div>
<div id="panel-core" class="tab-panel active" role="tabpanel" aria-labelledby="tab-core">
<p style="margin-top:0"><strong>Protokollen.</strong> X3DH, Double Ratchet, sesjonstyper, feiltyper. Ingen plattformkrypto her — bare grensesnittet <code>CryptoProvider</code>.</p>
<ul>
<li><code>ShadeSessionManager</code> — høynivå-API: <code>initialize</code>, <code>createPreKeyBundle</code>, <code>initSessionFromBundle</code>, <code>encrypt</code>, <code>decrypt</code></li>
<li>Symmetrisk kryptering: <strong>AES-256-GCM</strong> med AAD fra ratchet-header</li>
</ul>
</div>
<div id="panel-crypto" class="tab-panel" role="tabpanel" aria-labelledby="tab-crypto" hidden>
<p style="margin-top:0"><strong>Implementasjon av crypto for web/Bun/Node</strong> via SubtleCrypto — X25519, Ed25519, HKDF, HMAC, tilfeldige bytes.</p>
<ul>
<li>Gjør det mulig å bruke <code>shade-core</code> i nettleser og i servere som støtter Web Crypto</li>
<li>Kommentarer i koden peker på fremtidig Android (f.eks. Tink) som egen provider</li>
</ul>
</div>
<div id="panel-proto" class="tab-panel" role="tabpanel" aria-labelledby="tab-proto" hidden>
<p style="margin-top:0"><strong>Binært wire-format</strong> for meldinger: versjon + type + lengdeprefiksede felt (big-endian).</p>
<ul>
<li>Type <code>0x01</code> = PreKeyMessage, <code>0x02</code> = RatchetMessage</li>
<li>Brukes når du vil serialisere <code>ShadeEnvelope</code> effektivt over nettet</li>
</ul>
</div>
<div id="panel-transport" class="tab-panel" role="tabpanel" aria-labelledby="tab-transport" hidden>
<p style="margin-top:0"><strong>Transportadaptere</strong> — ikke selve krypteringen, men hvordan du sender bytes (f.eks. fetch eller WebSocket).</p>
<ul>
<li>Kobler applikasjonen din til den kanalen du allerede bruker</li>
</ul>
</div>
<div id="panel-server" class="tab-panel" role="tabpanel" aria-labelledby="tab-server" hidden>
<p style="margin-top:0"><strong>Prekey-server (Hono)</strong> — lagrer offentlige nøkler slik at Alice kan starte samtale mens Bob er «offline».</p>
<ul>
<li><code>POST /v1/keys/register</code> — registrer identitet + bundle</li>
<li><code>GET /v1/keys/bundle/:address</code> — hent bundle (forbruker én engangsnøkkel om tilgjengelig)</li>
<li><code>POST /v1/keys/replenish</code> — etterfyll engangsnøkler</li>
</ul>
</div>
</section>
<section id="nokler">
<h2>Nøkler i korthet</h2>
<div class="accordion" id="key-acc">
<div class="acc-item">
<button type="button" class="acc-trigger" aria-expanded="true" aria-controls="acc-identity" id="btn-identity">
Identitetsnøkkel (langvarig)
</button>
<div class="acc-panel" id="acc-identity" role="region" aria-labelledby="btn-identity">
<strong>Ed25519</strong> brukes til å signere den «signerte prekeyen». <strong>X25519</strong> brukes i Diffie-Hellman i X3DH og i ratchet. Én identitet per enhet/bruker i typisk oppsett.
</div>
</div>
<div class="acc-item">
<button type="button" class="acc-trigger" aria-expanded="false" aria-controls="acc-spk" id="btn-spk">
Signert prekey (mediumvarig, roteres)
</button>
<div class="acc-panel" id="acc-spk" role="region" aria-labelledby="btn-spk" hidden>
X25519-nøkkel som publiseres og signeres med identiteten. Mottaker verifiserer signaturen før DH. I koden anbefales rotasjon omtrent hver 17 dag.
</div>
</div>
<div class="acc-item">
<button type="button" class="acc-trigger" aria-expanded="false" aria-controls="acc-otpk" id="btn-otpk">
Engangsnøkler (one-time prekeys)
</button>
<div class="acc-panel" id="acc-otpk" role="region" aria-labelledby="btn-otpk" hidden>
Valgfrie, men viktige for ekstra sikkerhet: hver hentet bundle kan inkludere én engangsnøkkel som slettes etter bruk (<code>processPreKeyMessage</code> fjerner den fra lager). Gir bedre beskyttelse mot visse angrep når mange klienter kobler til samme mottaker.
</div>
</div>
</div>
</section>
<section id="flyt">
<h2>Interaktiv flyt: fra null til kryptert melding</h2>
<p>
Klikk «Neste» for å gå gjennom rekkefølgen slik Shade er bygget. Dette speiler <code>ShadeSessionManager</code> og demoen i repoet.
</p>
<div class="flow">
<h3>Sesjon og meldinger</h3>
<div class="flow-steps" id="flow-steps"></div>
<div class="flow-actions">
<button type="button" class="btn" id="flow-next">Neste steg</button>
<button type="button" class="btn btn-secondary" id="flow-reset">Start på nytt</button>
</div>
</div>
</section>
<section id="x3dh-ratchet">
<h2>X3DH og Double Ratchet (kort forklart)</h2>
<p>
<strong>X3DH</strong> løser problemet «jeg vil snakke med Bob nå, men Bob svarer ikke før senere». Bob legger ut en <em>prekey bundle</em> på serveren. Alice henter den, gjør 3 eller 4 DH-operasjoner (avhengig av om engangsnøkkel brukes), og deriverer en felles rot-nøkkel som begge kan rekonstruere uten at serveren kjenner hemmeligheten.
</p>
<p>
<strong>Double Ratchet</strong> bruker den roten som startpunkt. For hver melding (eller ved nye DH-nøkler) avledes nye nøkler; meldinger på ledningen er AES-GCM med autentisering (AAD binder kryptoteksten til ratchet-header). Protokollen håndterer også meldinger i feil rekkefølge innenfor grenser (<code>MAX_SKIP</code>).
</p>
<p>
Spesifikasjoner fra Signal (engelsk): <a href="https://signal.org/docs/specifications/x3dh/" target="_blank" rel="noopener">X3DH</a> · <a href="https://signal.org/docs/specifications/doubleratchet/" target="_blank" rel="noopener">Double Ratchet</a>.
</p>
</section>
<section id="gjenbruk">
<h2>Bruke Shade i flere prosjekter</h2>
<p>
Tenk på Shade som tre lag du kan kombinere etter behov:
</p>
<ol>
<li><strong>Core + crypto-provider + storage</strong> — selve E2EE-motoren (kan kjøre i klient eller serverprosess som skal dekryptere).</li>
<li><strong>Proto</strong> — når du vil ha kompakt binær serialisering.</li>
<li><strong>Transport + prekey-server</strong> — når du vil standardisere nøkkelutveksling og kanaler.</li>
</ol>
<p>
Referansekjøring: <code class="mono">bun demo.ts</code> i rotmappen viser frontend/backend-flyt med minnelager og ekte kryptoprimitiver.
</p>
</section>
<footer>
<p>Shade — oversikt generert som statisk HTML i <code class="mono">docs/shade-overview.html</code>. Åpne filen direkte i nettleseren eller server den statisk.</p>
</footer>
</div>
<script>
(function () {
// Tabs
var tabBtns = document.querySelectorAll(".tab-btn");
var panels = document.querySelectorAll(".tab-panel");
tabBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
var id = btn.getAttribute("aria-controls");
tabBtns.forEach(function (b) {
b.setAttribute("aria-selected", b === btn ? "true" : "false");
});
panels.forEach(function (p) {
var on = p.id === id;
p.classList.toggle("active", on);
p.hidden = !on;
});
});
});
// Accordion (single-open for clarity)
var triggers = document.querySelectorAll(".acc-trigger");
triggers.forEach(function (t) {
t.addEventListener("click", function () {
var exp = t.getAttribute("aria-expanded") === "true";
var panelId = t.getAttribute("aria-controls");
var panel = document.getElementById(panelId);
triggers.forEach(function (other) {
var oid = other.getAttribute("aria-controls");
var op = document.getElementById(oid);
other.setAttribute("aria-expanded", "false");
if (op) {
op.hidden = true;
}
});
if (!exp) {
t.setAttribute("aria-expanded", "true");
if (panel) panel.hidden = false;
} else {
t.setAttribute("aria-expanded", "false");
if (panel) panel.hidden = true;
}
});
});
// Flow steps
var steps = [
{
title: "Initialiser klient",
body: "Kall initialize(): last eller generer identitetsnøkkel (Ed25519 + X25519), registrationId og signert prekey.",
},
{
title: "Publiser prekey bundle",
body: "Bygg bundle med createPreKeyBundle() / generateOneTimePreKeys() og registrer på prekey-server (eller del ut av band for demo).",
},
{
title: "Start sesjon mot peer",
body: "Hent motpartens bundle, kjør initSessionFromBundle(address, bundle). X3DH kjører og ratchet-sesjon lagres i StorageProvider.",
},
{
title: "Første encrypt",
body: "encrypt() returnerer ShadeEnvelope type 'prekey': inneholder ephemeral nøkkel, prekey-ID-er og første RatchetMessage (AES-GCM).",
},
{
title: "Motpart decrypt",
body: "decrypt() på PreKeyMessage: gjenskaper samme root key, initReceiverSession, ratchetDecrypt — plaintext ut.",
},
{
title: "Videre meldinger",
body: "Neste kall til encrypt() gir type 'ratchet'. DH-ratchet steg gir nye kjeder og forbedret sikkerhet over tid.",
},
];
var container = document.getElementById("flow-steps");
var idx = 0;
function renderFlow() {
container.innerHTML = "";
steps.forEach(function (s, i) {
var row = document.createElement("div");
row.className = "flow-step" + (i <= idx ? " active" : "");
row.innerHTML =
'<span class="flow-num">' + (i + 1) + "</span>" +
"<div><strong style=\"color:var(--text)\">" + s.title + "</strong><br />" + s.body + "</div>";
container.appendChild(row);
});
}
document.getElementById("flow-next").addEventListener("click", function () {
idx = Math.min(idx + 1, steps.length - 1);
renderFlow();
});
document.getElementById("flow-reset").addEventListener("click", function () {
idx = 0;
renderFlow();
});
renderFlow();
})();
</script>
</body>
</html>