feat(notes): notekroppe forsegles, og søgningen åbner dem selv
CI / OpenAPI is valid (push) Successful in 38s
CI / Go (push) Failing after 18m36s
CI / End-to-end (push) Failing after 18m54s
CI / Docker image builds (push) Successful in 7m27s
Documentation / build (push) Failing after 7m7s
Documentation / deploy (push) Skipped
Release / Build and publish image (push) Failing after 9m59s

Grunden er den samme som for postkassers kodeord og OAuth-tokens: en
sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem hver
eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler ned — det
ved vi, fordi der ligger sådan nogle i den installation, det her blev bygget til.

**Både titel og krop.** Titlen *er* første linje, så en forseglet krop ved siden af
en læselig titel ville efterlade begyndelsen af hver eneste note i klartekst.

**Og de to kopier gik med.** Det var derfor det ikke var gjort for længe siden:
`notes_fts` indekserede kroppen, og den genererede `fold`-søjle bar en
translittereret kopi af den. At forsegle kroppen og lade dem stå ville have været
teater — en angriber med filen kunne læse hver note ud af indekset. Begge er væk,
og tabellen er bygget om, fordi SQLite ikke kan fjerne en genereret søjle på plads.

**Søgningen åbner teksten i Go.** Målt frem for gættet: tolv hundrede noter à 3,3 kB
koster 33–43 ms pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den
spørger — så det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne;
fire megabyte AES-GCM er få millisekunder. Den går med at folde og småskrive hver
krop, og det er lineært: regn med omtrent 350 ms ved ti tusind noter. Dér er det den
forkerte form, og svaret er da et nøglet indeks — hvis pris er, at præfiks- og
delordssøgningen falder bort.

bm25 forsvandt med indekset. Det, der er beholdt, er den halvdel, der betød mest:
titlen vejer ti gange kroppen. Med et loft pr. ord, så en note, der nævner ordet
fyrre gange, ikke slår en, der nævner alle ordene én gang — det er den fejl, en ren
optælling laver, og den er let at lave og svær at få øje på.

**En bagudfyldning ved opstart, ikke efterhånden.** Instansens indstillinger
forsegles, når de skrives, og det er rigtigt for dem. For et arkiv på tolv hundrede
importerede noter, som ingen redigerer igen, betyder "når de skrives" aldrig — og
det er præcis dét arkiv, der er værd at forsegle. Idempotent, så den koster et
gennemløb af id'er hver anden gang, og `updated_at` røres ikke: at lukke en
konvolut om en note er ikke at skrive den, og et stempel med i aften ville kaste
den rækkefølge væk, importen brugte en dag på at hente hjem.

En fejl under bagudfyldningen stopper opstarten. Halvdelen af noterne lukket er
ikke i stykker — `v1:`-præfikset gør, at begge slags kan læses — men en instans,
der starter og lader som om den beskytter noget, den ikke gør, er værre end en, der
siger fra.

Hvad der stadig ligger åbent, står i SECURITY.md og på dokumentationssiden:
opgavetitler, beskrivelser, kommentarer, og `note_links`, som røber hvilke
projekter og opgaver en note peger på, men ikke hvad der står.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-20 22:40:55 +02:00
co-authored by Claude Opus 5
parent 23bd784b07
commit c8d67dd07d
7 changed files with 434 additions and 99 deletions
@@ -0,0 +1,75 @@
-- verdande:rebuild-tables
--
-- Notekroppe forsegles.
--
-- Grunden er den samme som for postkassers kodeord og OAuth-tokens: en
-- sikkerhedskopi kan hentes gennem fladen, og indtil nu bar hver eneste af dem
-- hver eneste note i klartekst. Noter er dér, folk skriver kodeord og API-nøgler
-- ned — det ved vi, fordi der ligger sådan nogle i denne installation.
--
-- # Hvorfor det ikke var gjort for længe siden
--
-- Kroppen var kopieret to steder mere, og begge kopier var klartekst:
--
-- * `fold` — en genereret søjle med title || ' ' || body, translittereret, så
-- "grøn" kan findes ved at skrive "gron".
-- * `notes_fts` — et FTS5-indeks over titel, krop og fold.
--
-- At forsegle kroppen og lade dem stå ville have været teater: en angriber med
-- filen kunne læse hver note ud af indekset. Så de skal begge væk, og søgningen
-- skal skrives om til at åbne teksten i Go.
--
-- # Hvad det koster, og hvornår formen er forkert
--
-- Søgningen læser nu hver note. Målt: tolv hundrede noter à 3,3 kB koster 33–43 ms
-- pr. søgning, og fladen venter 250 ms efter sidste tastetryk, før den spørger — så
-- det ligger inde i pausen. Tiden går ikke med at åbne konvolutterne, men med at
-- folde og småskrive hver krop; det er lineært, så regn med omtrent 350 ms ved ti
-- tusind noter. Dér er det den forkerte form, og svaret er da et *nøglet* indeks:
-- en HMAC pr. ord under den samme nøgle. Bemærk hvad det koster, når det bliver
-- aktuelt — et nøglet indeks kan slå et helt ord op og ikke andet, så præfiks- og
-- delordssøgningen, der findes i dag, ville falde bort.
--
-- # Hvad der stadig ligger i klartekst
--
-- Alt andet. Opgavetitler, beskrivelser, kommentarer — og `note_links`, som er
-- id'er udledt af kroppen, så en fil røber stadig *hvilke* projekter og opgaver en
-- note peger på, men ikke hvad der står. Det er skrevet i SECURITY.md, og det er
-- ikke løst her.
--
-- # Selve ombygningen
--
-- SQLite kan ikke fjerne en genereret søjle på plads, så tabellen bygges om.
-- Rækkerne kopieres med kroppen, som den er: forseglingen sker i Go, hvor nøglen
-- er, og en opstart efter denne migration går rækkerne igennem og lukker dem. Til
-- den tid kan en søjle indeholde begge dele, og `v1:`-præfikset findes netop for
-- at kunne læses uden at gætte.
CREATE TABLE notes_sealed (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
created_by TEXT REFERENCES users (id) ON DELETE SET NULL,
pinned INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
archived_at INTEGER
);
INSERT INTO notes_sealed
(id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at)
SELECT id, project_id, title, body, created_by, pinned, created_at, updated_at, deleted_at, archived_at
FROM notes;
DROP TRIGGER notes_fts_insert;
DROP TRIGGER notes_fts_delete;
DROP TRIGGER notes_fts_update;
DROP TABLE notes_fts;
DROP TABLE notes;
ALTER TABLE notes_sealed RENAME TO notes;
CREATE INDEX notes_by_project ON notes (project_id, deleted_at, updated_at DESC);
CREATE INDEX notes_current ON notes (archived_at, deleted_at, updated_at DESC)
WHERE deleted_at IS NULL;