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 <[email protected]>
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
+75
View File
@@ -38,6 +38,81 @@ type DB struct {
// misplaced key look like a corrupt disk.
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
// SealNotes lukker de noter, der stadig ligger åbne.
//
// Kaldes én gang ved opstart, efter nøglen er sat. Uden den ville kroppene blive
// forseglet efterhånden som de bliver *skrevet* — sådan som instansens
// indstillinger gør det — og for et arkiv på tolv hundrede importerede noter, som
// ingen redigerer igen, betyder det aldrig. En bagudfyldning er den eneste måde,
// den her ændring rent faktisk beskytter noget på.
//
// Idempotent: en note, der allerede er forseglet, kendes på sit præfiks og røres
// ikke. Så den kan køre ved hver opstart uden at koste andet end et gennemløb af
// id'er og en længde.
//
// Skriver med SQL frem for gennem SaveNote. SaveNote udleder titlen af kroppen og
// bygger links om, og begge dele er allerede rigtige her — det, der skal ske, er
// alene, at de to søjler skal skiftes ud med sig selv, lukket.
func (db *DB) SealNotes(ctx context.Context) (int, error) {
if db.box == nil {
return 0, nil
}
rows, err := db.QueryContext(ctx, `SELECT id, title, body FROM notes`)
if err != nil {
return 0, err
}
type open struct{ id, title, body string }
var pending []open
for rows.Next() {
var o open
if err := rows.Scan(&o.id, &o.title, &o.body); err != nil {
rows.Close()
return 0, err
}
if secret.IsSealed(o.body) && (o.title == "" || secret.IsSealed(o.title)) {
continue
}
pending = append(pending, o)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
if len(pending) == 0 {
return 0, nil
}
// Hele bunken i én transaktion. En halvt forseglet tabel er ikke i stykker —
// præfikset gør, at begge slags kan læses — men en afbrudt kørsel, der skal
// tages om, er lettere at ræsonnere om, når den enten skete eller ikke gjorde.
err = db.Tx(ctx, func(tx *sql.Tx) error {
for _, o := range pending {
title, err := db.sealValue(o.title)
if err != nil {
return err
}
body, err := db.sealValue(o.body)
if err != nil {
return err
}
// `updated_at` røres ikke. Det betyder "hvornår skrev dette program noten
// sidst", og at lukke en konvolut om den er ikke at skrive den — en
// bagudfyldning, der stemplede tolv hundrede noter med i aften, ville
// kaste den rækkefølge væk, importen brugte en dag på at hente hjem.
if _, err := tx.ExecContext(ctx,
`UPDATE notes SET title = ?, body = ? WHERE id = ?`, title, body, o.id); err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, err
}
return len(pending), nil
}
// dsn is the connection string.
//
// SQLite is a single file, but it is being driven by an HTTP server with concurrent