User-facing docs: inline "how it works" + /personvern detail page
Two layers of explanation, addressing the gap between "I just want to
use it" and "I want to understand the crypto."
Inline:
- Signup page: extends the existing one-liner with a "Les hvordan
det virker" link. Opens in a new tab so the user doesn't lose the
form (and any invite token) mid-read.
- Public landing: gains a second muted paragraph that names the
three visibility levels in plain language and links to the docs.
Detail page (/personvern):
- New Svelte component Personvern.svelte with sections for the
three visibility levels, E2E key model, password vs recovery
code, what the server stores vs doesn't, sessions, and explicit
limits ("what crypto does NOT protect against").
- Written for non-technical users; still names the primitives
(Argon2id, XChaCha20-Poly1305) and gestures at SECURITY.md for
anyone who wants the engineering depth.
App.svelte routing:
- New /personvern path handled in parsePath
- applyRoute branch sets view='personvern'
- leavePersonvern() routes back to /home or / depending on auth
- Persistent footer link on every view so the docs are always one
click away
Norwegian Bokmål throughout. No new dependencies. Bundle 34.2 KB →
36.8 KB gzipped (mostly the markdown-ish prose content).
This commit is contained in:
parent
e66d50737a
commit
79ce7059c1
4 changed files with 197 additions and 7 deletions
|
|
@ -13,6 +13,7 @@
|
|||
import PublicList from './components/PublicList.svelte';
|
||||
import Admin from './components/Admin.svelte';
|
||||
import ActivityPermalink from './components/ActivityPermalink.svelte';
|
||||
import Personvern from './components/Personvern.svelte';
|
||||
|
||||
/**
|
||||
* URL contract:
|
||||
|
|
@ -20,6 +21,7 @@
|
|||
* /home — authenticated dashboard (private+semi+public)
|
||||
* /a/:id — permalink to a single activity (any visibility)
|
||||
* /<username>/list — opt-in public list for that user
|
||||
* /personvern — privacy + how-it-works long-form page
|
||||
*
|
||||
* Anything else (signup, login, recovery, profile, feedback, admin) is an
|
||||
* in-app view state, not a URL. We update window.history on view changes
|
||||
|
|
@ -32,7 +34,8 @@
|
|||
| 'home' // "/home" — full authenticated dashboard
|
||||
| 'profile' | 'feedback' | 'admin'
|
||||
| 'public-list' // "/<username>/list"
|
||||
| 'permalink'; // "/a/:id"
|
||||
| 'permalink' // "/a/:id"
|
||||
| 'personvern'; // "/personvern"
|
||||
|
||||
let view: View = $state('loading');
|
||||
let publicListUsername = $state('');
|
||||
|
|
@ -42,7 +45,7 @@
|
|||
let selfRegistryEnabled = $state(true);
|
||||
|
||||
interface Route {
|
||||
view: 'public-home' | 'home' | 'public-list' | 'permalink' | 'invite';
|
||||
view: 'public-home' | 'home' | 'public-list' | 'permalink' | 'invite' | 'personvern';
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
|
|
@ -50,6 +53,7 @@
|
|||
const path = window.location.pathname;
|
||||
if (path === '/' || path === '') return { view: 'public-home' };
|
||||
if (path === '/home' || path === '/home/') return { view: 'home' };
|
||||
if (path === '/personvern' || path === '/personvern/') return { view: 'personvern' };
|
||||
const userList = path.match(/^\/([a-z0-9_-]{2,31})\/list\/?$/);
|
||||
if (userList) return { view: 'public-list', payload: userList[1] };
|
||||
const perma = path.match(/^\/a\/([A-Za-z0-9-]+)\/?$/);
|
||||
|
|
@ -122,9 +126,23 @@
|
|||
// the token prefilled. If they're already logged in we still show the
|
||||
// landing — they can't claim an invite on top of an existing account.
|
||||
view = session.user ? 'public-home' : 'signup';
|
||||
} else if (route.view === 'personvern') {
|
||||
view = 'personvern';
|
||||
}
|
||||
}
|
||||
|
||||
function goPersonvern() {
|
||||
pushUrl('/personvern');
|
||||
view = 'personvern';
|
||||
}
|
||||
|
||||
function leavePersonvern() {
|
||||
// Send the visitor wherever they "would have been" — landing if logged out,
|
||||
// dashboard if logged in. Either is more useful than staying on the doc page.
|
||||
if (session.user) goHome();
|
||||
else goPublicHome();
|
||||
}
|
||||
|
||||
function onAuthed() {
|
||||
pushUrl('/home');
|
||||
view = 'home';
|
||||
|
|
@ -210,7 +228,17 @@
|
|||
<Feedback onDone={goHome} />
|
||||
{:else if view === 'admin'}
|
||||
<Admin onDone={goHome} />
|
||||
{:else if view === 'personvern'}
|
||||
<Personvern onBack={leavePersonvern} />
|
||||
{:else}
|
||||
<Home publicOnly={false} />
|
||||
{/if}
|
||||
|
||||
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border); text-align: center;">
|
||||
<a href="/personvern"
|
||||
onclick={(e) => { e.preventDefault(); goPersonvern(); }}
|
||||
class="muted" style="font-size: 0.9rem;">
|
||||
Personvern og hvordan det virker
|
||||
</a>
|
||||
</footer>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -121,11 +121,18 @@
|
|||
<section aria-label="Aktiviteter">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
{#if publicOnly}
|
||||
<p class="muted" style="margin: 0;">
|
||||
Her kan du lage og dele lister over ting å gjøre om vinteren for å
|
||||
holde vinterdepresjonen unna. Og selvfølgelig kan du også lage lister
|
||||
for andre formål — noen liker jo ikke sommeren heller.
|
||||
</p>
|
||||
<div style="margin: 0;">
|
||||
<p class="muted" style="margin: 0 0 0.5rem;">
|
||||
Her kan du lage og dele lister over ting å gjøre om vinteren for å
|
||||
holde vinterdepresjonen unna. Og selvfølgelig kan du også lage
|
||||
lister for andre formål — noen liker jo ikke sommeren heller.
|
||||
</p>
|
||||
<p class="muted" style="margin: 0; font-size: 0.9rem;">
|
||||
Du velger selv om hver oppføring er privat (kryptert i nettleseren
|
||||
din), anonym (synlig uten navn), eller offentlig.
|
||||
<a href="/personvern">Mer om personvern og hvordan det virker.</a>
|
||||
</p>
|
||||
</div>
|
||||
{:else if session.user}
|
||||
<p class="muted" style="margin: 0;">
|
||||
{#if session.user.display_name?.trim()}
|
||||
|
|
|
|||
154
frontend/src/components/Personvern.svelte
Normal file
154
frontend/src/components/Personvern.svelte
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
onBack?: () => void;
|
||||
}
|
||||
let { onBack }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section aria-label="Personvern og hvordan det virker">
|
||||
{#if onBack}
|
||||
<div class="row" style="margin-bottom: 1rem;">
|
||||
<button type="button" onclick={onBack}>← Tilbake</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2 style="font-size: 1.75rem; margin-top: 0;">Personvern og hvordan det virker</h2>
|
||||
|
||||
<p>
|
||||
Vinterliste lar deg lage og dele lister over ting å gjøre om vinteren —
|
||||
eller hva enn du vil bruke det til. Du velger selv om hver oppføring er
|
||||
privat, anonym eller offentlig. Her er hva det betyr i praksis, og hva
|
||||
serveren faktisk ser av det du skriver.
|
||||
</p>
|
||||
|
||||
<h3>De tre synlighetsnivåene</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Privat:</strong> oppføringen krypteres i nettleseren din før den
|
||||
sendes. Verken vi som driver tjenesten, eller noen som måtte få tak i
|
||||
databasen, kan lese innholdet. Bare du kan låse den opp — med passordet
|
||||
ditt eller gjenopprettingskoden.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anonym (halv-offentlig):</strong> innholdet er synlig for alle,
|
||||
men navnet ditt vises ikke ved siden av. Vi lagrer en intern referanse
|
||||
til hvem som eier oppføringen (slik at du kan redigere eller slette den),
|
||||
men API-et returnerer aldri den referansen til andre brukere.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Offentlig:</strong> innholdet er synlig for alle <em>og</em>
|
||||
kreditert med visningsnavnet ditt (eller brukernavnet, hvis du har
|
||||
satt det). Setter du ingen av delene, vises ingen attribusjon.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Ende-til-ende-kryptering, kort forklart</h3>
|
||||
<p>
|
||||
For private oppføringer skjer all kryptering i nettleseren din. Når du
|
||||
oppretter en konto, lager nettleseren en tilfeldig nøkkel som vi kaller
|
||||
DEK (Data Encryption Key). Denne nøkkelen pakkes inn — krypteres — på
|
||||
to måter:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Én pakke åpnes med passordet ditt: nettleseren utleder en
|
||||
passordavledet nøkkel (KEK_pw) ved hjelp av <strong>Argon2id</strong>,
|
||||
en moderne nøkkelutledningsfunksjon som er treg med vilje for å gjøre
|
||||
brute-force-angrep upraktiske.
|
||||
</li>
|
||||
<li>
|
||||
Én pakke åpnes med gjenopprettingskoden: en høyentropi-kode som vises
|
||||
én gang ved registrering og som du må ta vare på selv. Vi lagrer den
|
||||
<strong>aldri</strong> på serveren.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Selve innholdet krypteres med <strong>XChaCha20-Poly1305</strong>, en
|
||||
autentisert krypteringsalgoritme — som betyr at ciphertekst som er tuklet
|
||||
med, ikke kan dekrypteres uten at det oppdages.
|
||||
</p>
|
||||
|
||||
<h3>Passordet ditt forlater aldri nettleseren</h3>
|
||||
<p>
|
||||
Når du logger inn, sender vi <strong>ikke</strong> passordet til serveren.
|
||||
Nettleseren utleder en separat verifikator fra passordet (Argon2id med
|
||||
et eget salt, distinkt fra det som brukes til å pakke DEK-en) og sender
|
||||
bare denne. Serveren kjører <code>Bun.password.verify</code> mot en hash
|
||||
vi har lagret. Om databasen lekker, må en angriper både brute-force-knekke
|
||||
verifikatoren <em>og</em> så gjøre samme treghet om igjen for å utlede
|
||||
nøkkelen som åpner pakka — to separate angrep, ikke ett.
|
||||
</p>
|
||||
|
||||
<h3>Å bytte passord eller bruke gjenopprettingskoden</h3>
|
||||
<p>
|
||||
Bytter du passord, blir innholdet i den private listen din <em>aldri</em>
|
||||
kryptert på nytt. Nettleseren låser bare opp DEK-en med det gamle
|
||||
passordet og pakker den inn igjen med det nye. Det går raskt og er
|
||||
sikkert: selve dataene rører vi aldri. Gjenopprettingskoden virker etter
|
||||
samme prinsipp; den åpner sin egen pakke og lar deg sette et nytt
|
||||
passord uten å miste noe.
|
||||
</p>
|
||||
<p>
|
||||
Når noen fullfører gjenoppretting, sjekker serveren først at avsenderen
|
||||
faktisk kan gjenopprettingskoden (via en egen verifikator). Det betyr at
|
||||
noen som bare kjenner eposten din, ikke kan låse deg ute fra kontoen.
|
||||
</p>
|
||||
|
||||
<h3>Hva serveren ser, og ikke ser</h3>
|
||||
<p>Vi lagrer per bruker:</p>
|
||||
<ul>
|
||||
<li>Eposten din (brukt som innloggings-id).</li>
|
||||
<li>
|
||||
Salter og innpakkede nøkler — disse er ikke hemmelige i seg selv; uten
|
||||
passordet eller gjenopprettingskoden låser de ingenting opp.
|
||||
</li>
|
||||
<li>Hash av autentiseringsverifikatoren (ikke passordet — verifikatoren).</li>
|
||||
<li>Visningsnavn, brukernavn og rolleflagg, om du har satt dem.</li>
|
||||
</ul>
|
||||
<p>For private oppføringer lagrer vi <strong>kun</strong>:</p>
|
||||
<ul>
|
||||
<li>Ciphertekst (ulesbar uten DEK-en).</li>
|
||||
<li>En tilfeldig nonce per oppføring.</li>
|
||||
<li>Tidspunkt for opprettelse og siste endring.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Vi lagrer aldri: råpassordet, gjenopprettingskoden, DEK-en i lesbar form,
|
||||
eller klartekst-innholdet i private oppføringer.
|
||||
</p>
|
||||
|
||||
<h3>Sesjoner og innlogging</h3>
|
||||
<p>
|
||||
Innlogging gir deg en httpOnly-cookie med en tilfeldig sesjonsverdi som
|
||||
lagres i databasen vår. Vi bruker bevisst <em>ikke</em> JWT — ingen
|
||||
kompliserte tokens som vi ikke kan trekke tilbake. Logger du ut, eller
|
||||
fullfører gjenoppretting, slettes sesjonene umiddelbart.
|
||||
</p>
|
||||
|
||||
<h3>Hva som <em>ikke</em> er beskyttet</h3>
|
||||
<p>
|
||||
Krypteringen beskytter <strong>innholdet</strong> i de private
|
||||
oppføringene dine. Den beskytter <strong>ikke</strong> mot:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
At noen får tilgang til nettleseren din mens du er innlogget. Da har de
|
||||
DEK-en i minnet og kan lese alt. Logg ut når du forlater enheten.
|
||||
</li>
|
||||
<li>
|
||||
Trafikkanalyse — antallet og størrelsen på dine private oppføringer er
|
||||
synlige for noen som overvåker forbindelsen, selv om innholdet ikke er
|
||||
det.
|
||||
</li>
|
||||
<li>
|
||||
Skadelige nettleserutvidelser eller sårbarheter i nettleseren. Ingen
|
||||
ende-til-ende-app kan beskytte mot at klienten kompromitteres.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Vil du grave dypere?</h3>
|
||||
<p>
|
||||
Den fulle tekniske beskrivelsen ligger i prosjektets <code>SECURITY.md</code>.
|
||||
Den dekker eksakt hvilke biter som lagres hvor, hvilke Argon2id-parametre
|
||||
vi bruker, og hvordan vi tenker rundt avveiningene.
|
||||
</p>
|
||||
</section>
|
||||
|
|
@ -81,6 +81,7 @@
|
|||
<p class="muted">
|
||||
Vi krypterer alt du markerer som <em>privat</em> i nettleseren din. Serveren
|
||||
ser aldri passordet ditt eller innholdet i private oppføringer.
|
||||
<a href="/personvern" target="_blank" rel="noopener noreferrer">Les hvordan det virker.</a>
|
||||
</p>
|
||||
|
||||
<label for="signup-email">Epost</label>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue