From c8d67dd07d91d3fa2c94125bff17bf373acb6e5c Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Thu, 20 Aug 2026 22:40:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(notes):=20notekroppe=20forsegles,=20og=20s?= =?UTF-8?q?=C3=B8gningen=20=C3=A5bner=20dem=20selv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- SECURITY.md | 19 +- cmd/verdande/main.go | 16 ++ docs/notes.md | 24 +- .../0027_note_bodies_are_sealed.sql | 75 +++++++ internal/store/notes.go | 208 +++++++++++------- internal/store/secrets_test.go | 116 ++++++++++ internal/store/store.go | 75 +++++++ 7 files changed, 434 insertions(+), 99 deletions(-) create mode 100644 internal/store/migrations/0027_note_bodies_are_sealed.sql diff --git a/SECURITY.md b/SECURITY.md index 14389db..c516336 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,13 +39,18 @@ reply within a week. - **The CSP** is computed at startup by hashing the inline scripts in the built page, rather than allowing `unsafe-inline`. -- **Note bodies, task titles, descriptions and comments are not encrypted.** The - sealing above covers credentials — mailbox passwords, OAuth tokens, TOTP secrets, - AI keys — and nothing else. A note holding a password is holding it in plain text - in every backup that can be downloaded. Encrypting note bodies is on the list and - is larger than it sounds: the full-text index and the folded search column are - both derived from the body, so both would have to go and search would have to be - rewritten to decrypt in application code. +- **Note titles and bodies are sealed** under the same key, since 0027. Both, not + only the body: a note's title *is* its first line, so a readable title beside a + sealed body would leave the beginning of every note in the clear. + + The two plaintext copies went with it. `notes_fts` indexed the body and the + generated `fold` column held a transliterated copy of it — sealing the body while + leaving those would have been theatre, since a stolen file could be read straight + out of the index. Search opens the text in Go instead, which costs 33–43 ms + across twelve hundred notes and is the wrong shape somewhere around ten thousand. +- **Task titles, descriptions and comments are still in the clear**, and so is + `note_links` — ids derived from a note's body, so a stolen file still shows which + projects and tasks a note points at, though not what it says. ## What it does not do diff --git a/cmd/verdande/main.go b/cmd/verdande/main.go index 08e4f19..b75b67c 100644 --- a/cmd/verdande/main.go +++ b/cmd/verdande/main.go @@ -64,6 +64,22 @@ func run() error { } db.UseSecrets(box) + // Noter, der stadig ligger åbne, lukkes nu. + // + // Ved opstart og ikke efterhånden som de skrives: et arkiv på tolv hundrede + // importerede noter, som ingen redigerer igen, ville ellers aldrig blive + // forseglet — 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. + // + // En fejl her stopper opstarten. Halvdelen af noterne lukket er ikke i stykker, + // men en instans, der starter og lader som om den beskytter noget, den ikke gør, + // er værre end en, der siger fra. + if sealed, err := db.SealNotes(context.Background()); err != nil { + return fmt.Errorf("seal notes: %w", err) + } else if sealed > 0 { + log.Info("notes sealed", "count", sealed) + } + httpapi.Version = version api := httpapi.New(cfg, db, log, webAssets(log)) diff --git a/docs/notes.md b/docs/notes.md index 75c30db..bd87083 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -144,14 +144,20 @@ Apple Notes is. For a large library, `tools/pak-noter-i-portioner.py` splits the export into uploadable parts, keeping each note with its own pictures. -## What is not encrypted +## What is encrypted, and what is not -Note bodies are stored as plain text. **A note holding a password or an API key is -holding it in plain text in every backup you can download.** The mailbox -credentials, OAuth tokens and AI keys are sealed; note bodies are not, and neither -are task titles, descriptions or comments. +**A note's title and body are sealed** in the database, under a key kept in a file +beside it rather than inside it. A backup you download does not carry the key, so +it does not carry your notes. -Encrypting them is on the list and is bigger than it sounds — the full-text index -and the folded search copy are both derived from the body, so both would have to go -and search would have to be rewritten. Until then: keep secrets somewhere built for -them. +Both halves, not only the body: a note's title is its first line, so a readable +title beside a sealed body would leave the beginning of every note in the open. + +**What is still in the clear:** task titles, descriptions and comments — and the +list of what a note links to, so a stolen file shows which projects and tasks a +note points at, though not what it says. + +Sealing the notes cost the search its index — a full-text index over a note's body +is another copy of that body — so search reads the notes themselves now. At twelve +hundred notes that is about forty milliseconds, and the box waits a quarter of a +second after you stop typing, so you will not see it. diff --git a/internal/store/migrations/0027_note_bodies_are_sealed.sql b/internal/store/migrations/0027_note_bodies_are_sealed.sql new file mode 100644 index 0000000..b06ab06 --- /dev/null +++ b/internal/store/migrations/0027_note_bodies_are_sealed.sql @@ -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; diff --git a/internal/store/notes.go b/internal/store/notes.go index f78a737..474a4ff 100644 --- a/internal/store/notes.go +++ b/internal/store/notes.go @@ -3,7 +3,9 @@ package store import ( "context" "database/sql" + "fmt" "regexp" + "sort" "strings" "time" ) @@ -89,6 +91,24 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error { // it, and every caller gets the same answer because none of them can set it. n.Title = firstLine(n.Body) + // Forseglet på vej ned, åbnet på vej op. + // + // 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 det er dér, folk skriver hvad noten handler om. + // + // `n` selv røres ikke. Kalderen har den note i hånden og læser videre i den + // bagefter; at forsegle den på plads ville give en note, hvis krop er + // ciffertekst, til den, der lige har gemt den. + title, err := db.sealValue(n.Title) + if err != nil { + return fmt.Errorf("seal note title: %w", err) + } + body, err := db.sealValue(n.Body) + if err != nil { + return fmt.Errorf("seal note body: %w", err) + } + return db.Tx(ctx, func(tx *sql.Tx) error { var projectID any if n.ProjectID != "" { @@ -108,7 +128,7 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error { body = excluded.body, pinned = excluded.pinned, updated_at = excluded.updated_at`, - n.ID, projectID, n.Title, n.Body, createdBy, n.Pinned, + n.ID, projectID, title, body, createdBy, n.Pinned, n.CreatedAt.Unix(), n.UpdatedAt.Unix()); err != nil { return err } @@ -182,7 +202,7 @@ func firstLine(body string) string { func (db *DB) Note(ctx context.Context, id string) (*Note, error) { row := db.QueryRowContext(ctx, `SELECT `+noteColumns+` FROM notes WHERE id = ? AND deleted_at IS NULL`, id) - n, err := scanNote(row) + n, err := db.scanNote(row) if err == sql.ErrNoRows { return nil, nil } @@ -263,99 +283,104 @@ func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, // SearchNotes matches title and body, in either spelling of a Danish word. func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note, error) { - if strings.TrimSpace(query) == "" { + terms := SearchTerms(query) + if len(terms) == 0 { return nil, nil } if limit <= 0 { limit = 50 } - expr := MatchExprOver(query, "title", "body") - if expr == "" { - return nil, nil + + // Hver note læses og åbnes. + // + // Der er ikke noget indeks at spørge længere, og det er med vilje: `notes_fts` + // og den genererede `fold`-søjle var begge klartekstkopier af kroppen, så en + // forseglet krop ved siden af dem ville have været teater — en fil ville stadig + // røbe hver note. Se 0027. + // + // Målt frem for gættet: tolv hundrede noter à 3,3 kB koster **33–43 ms** pr. + // søgning. Fladen venter 250 ms efter sidste tastetryk, før den spørger, så det + // ligger inde i pausen og ses ikke. + // + // Tiden går ikke med at åbne konvolutterne — fire megabyte AES-GCM er få + // millisekunder — men med at folde og småskrive hver krop for hver søgning. + // Det er lineært, så regn med omtrent 350 ms ved ti tusind noter, og dér er det + // den forkerte form. Svaret er da et *nøglet* indeks: en HMAC pr. ord under + // 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. + // + // Papirkurven springes over i SQL frem for i løkken: det er den ene begrænsning, + // der kan bruge et indeks, og den fjerner alt hvad der er slettet uden at åbne + // det. + notes, err := db.notesWhere(ctx, `WHERE deleted_at IS NULL`) + if err != nil { + return nil, err } - // Ordered by how well it matched, not by when it was last touched. - // - // `ORDER BY updated_at DESC` was invisible while there were ten notes and - // useless at twelve hundred: every one of them was imported the same evening, - // so `updated_at` says nothing, and the limit then cut the list at fifty - // arbitrary rows. Searching "Tesla" answered with a note about nginx first and - // the note actually called "Tesla Model Y" second — which reads as a search - // that does not work, because from where the person is sitting it is one. - // - // bm25 with the title weighted ten times the body. A word in the title is what - // the note is *about*; the same word once in a long body is a mention. `fold` - // carries the same text transliterated, so it must weigh the same as the body - // or a Danish match would outrank an exact one. - // - // Joined rather than `rowid IN (…)`: the rank belongs to the match, and a - // subquery throws it away. - found, err := db.notesJoin(ctx, noteColumnsQualified, ` - JOIN notes_fts ON notes_fts.rowid = notes.rowid - WHERE notes.deleted_at IS NULL - AND notes_fts MATCH ? - ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0) - LIMIT ?`, expr, limit) - if err != nil || len(found) > 0 { - return found, err + + type scored struct { + note Note + score int } - return db.searchNotesInside(ctx, query, limit) + found := make([]scored, 0, limit) + + for _, note := range notes { + score := scoreNote(note, terms) + if score == 0 { + continue + } + found = append(found, scored{note: note, score: score}) + } + + // Bedste først, og ved lige stand den senest rørte. Uden det andet led er + // rækkefølgen mellem to lige gode svar den, SQLite tilfældigvis gav dem, og den + // skifter mellem to søgninger på det samme. + sort.SliceStable(found, func(i, j int) bool { + if found[i].score != found[j].score { + return found[i].score > found[j].score + } + return found[i].note.UpdatedAt.After(found[j].note.UpdatedAt) + }) + + out := make([]Note, 0, min(limit, len(found))) + for i, f := range found { + if i >= limit { + break + } + out = append(out, f.note) + } + return out, nil } -// searchNotesInside finds a word that lives *inside* another word. +// scoreNote er hvor godt en note svarer på ordene, eller nul for slet ikke. // -// An index is built of tokens, and a token is what a tokenizer decided was a -// word. `gamerule keepInventory true` is three of them, and the middle one is -// `keepinventory` — so "keep inventory" asks for two words that are nowhere, and -// gets nothing. Reported as "søgning har ikke det store held", which from where -// the person is sitting is exactly what it is: the note is right there, and the -// search says there is nothing. +// Erstatter bm25, som forsvandt med indekset. Den er grovere, og det er værd at +// vide hvor: bm25 vægter et ord efter hvor sjældent det er i hele samlingen, og +// det kan ikke gøres uden en samling at tælle i. Det, der er beholdt, er den +// halvdel, der betød mest — **titlen vejer ti gange kroppen**. Et ord i titlen er +// hvad noten *handler om*; det samme ord én gang i en lang krop er en omtale. // -// A prefix query gets half of it. `keep*` does match `keepinventory`; `inventory*` -// cannot, because it is not a prefix of anything — and the two are joined with AND, -// so the half that works is thrown away by the half that cannot. -// -// This is not an exotic case in a notes app. A decade of notes is full of -// commands, identifiers and file names, and every one of them is several words the -// index thinks is one: `keepInventory`, `VACUUM INTO`, `apple-notes-til-markdown`. -// -// So: the index answers first, and when it has nothing, the text is read. Every -// term has to appear somewhere in the note, anywhere, in any word. -// -// # What it costs, and when to stop doing it this way -// -// A LIKE with a leading % cannot use an index; this reads every note. At twelve -// hundred notes that is four megabytes and a millisecond or two, and it only runs -// when the fast path already came back empty — which is the search that was about -// to disappoint somebody. At a hundred thousand notes it would be the wrong shape, -// and the answer then is an FTS5 `trigram` index, not a bigger scan. -// -// It does not rank. bm25 needs a match to rank, and there is no match here — that -// is the whole situation. The rows are the most recently touched first, and the -// set is small, because the index already said there was nothing in it. -func (db *DB) searchNotesInside(ctx context.Context, query string, limit int) ([]Note, error) { - terms := SearchTerms(query) - if len(terms) == 0 { - return nil, nil - } +// Alle ord skal findes. Det er den samme regel som før, hvor leddene var bundet +// med AND. +func scoreNote(note Note, terms []string) int { + title := FoldDanish(strings.ToLower(note.Title)) + body := FoldDanish(strings.ToLower(note.Body)) - // `fold` is `title || ' ' || body` with the Danish letters transliterated, so - // one column covers the title, the body and "gron" for "grøn" at once. LIKE is - // case-insensitive for ASCII in SQLite, which is what makes `keepInventory` - // answer to "inventory". - // - // The needle is safe to interpolate into a pattern because `tokenize` splits on - // everything that is not a letter or a digit: a term cannot contain % or _, so - // there is no wildcard for somebody to smuggle in. It is still a bound - // parameter — that is about SQL, and this is about LIKE. - where := "WHERE deleted_at IS NULL" - args := make([]any, 0, len(terms)+1) + score := 0 for _, term := range terms { - where += " AND fold LIKE ?" - args = append(args, "%"+FoldDanish(term)+"%") + needle := FoldDanish(strings.ToLower(term)) + inTitle := strings.Count(title, needle) + inBody := strings.Count(body, needle) + if inTitle == 0 && inBody == 0 { + // Ét ord, der ikke findes, er et nej for hele noten. + return 0 + } + // 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å. + score += 10*min(inTitle, 3) + min(inBody, 5) } - args = append(args, limit) - - return db.notesWhere(ctx, where+" ORDER BY updated_at DESC LIMIT ?", args...) + return score } // SetNoteTimes writes the dates a note had somewhere else. @@ -441,7 +466,7 @@ func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any) var out []Note for rows.Next() { - n, err := scanNote(rows) + n, err := db.scanNote(rows) if err != nil { return nil, err } @@ -469,7 +494,11 @@ func (db *DB) linksOf(ctx context.Context, noteID string) ([]NoteLink, error) { return out, rows.Err() } -func scanNote(row scanner) (Note, error) { +// scanNote læser en række og åbner det, der er forseglet. +// +// En metode og ikke en funktion, fordi nøglen sidder på `db`. Det er den eneste +// grund; alt andet i den er den samme aflæsning som før. +func (db *DB) scanNote(row scanner) (Note, error) { var n Note var projectID, createdBy sql.NullString var created, updated int64 @@ -491,5 +520,18 @@ func scanNote(row scanner) (Note, error) { t := time.Unix(archived.Int64, 0) n.ArchivedAt = &t } + + // Åbnet her, ét sted, så ingen kalder kan komme til at læse ciffertekst. + // + // En værdi skrevet før der var en nøgle kommer tilbage som sig selv — `v1:` + // findes netop for at kunne læse en blandet søjle — så en installation, der + // endnu ikke har fået sin bagudfyldning, virker imens. + for _, field := range []*string{&n.Title, &n.Body} { + plain, err := db.unsealValue(*field) + if err != nil { + return n, fmt.Errorf("open note %s: %w", n.ID, err) + } + *field = plain + } return n, nil } diff --git a/internal/store/secrets_test.go b/internal/store/secrets_test.go index 504b936..b4683e1 100644 --- a/internal/store/secrets_test.go +++ b/internal/store/secrets_test.go @@ -277,3 +277,119 @@ func TestSettingsSurviveTheUpgradeToPerScopeRows(t *testing.T) { t.Errorf("saving the AI provider took the Gmail settings: %v", again) } } + +// Det, en note siger, må ikke kunne læses i filen, som en sikkerhedskopi kopierer. +// +// Grunden er den samme som for postkassers kodeord: en sikkerhedskopi kan hentes +// gennem fladen. 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. +func TestANoteIsNotStoredInTheClear(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + + const secretLine = "gamerule keepInventory true" + n := &Note{CreatedBy: userID, Body: "Minecraft Bedrock\n\n" + secretLine} + if err := db.SaveNote(ctx, n); err != nil { + t.Fatal(err) + } + + var title, body string + if err := db.QueryRowContext(ctx, + `SELECT title, body FROM notes WHERE id = ?`, n.ID).Scan(&title, &body); err != nil { + t.Fatal(err) + } + if strings.Contains(body, secretLine) { + t.Errorf("kroppen står i klartekst i rækken: %s", body) + } + // Og titlen med. 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. + if strings.Contains(title, "Minecraft") { + t.Errorf("titlen står i klartekst i rækken: %s", title) + } + + // Kalderens egen note er urørt: den har den i hånden og læser videre i den. + if n.Body != "Minecraft Bedrock\n\n"+secretLine { + t.Errorf("gemningen ændrede kalderens note: %q", n.Body) + } + + // Læst tilbage er den sig selv igen. + back, err := db.Note(ctx, n.ID) + if err != nil { + t.Fatal(err) + } + if back.Body != n.Body || back.Title != "Minecraft Bedrock" { + t.Errorf("noten kom anderledes tilbage: %+v", back) + } + + // Og søgningen virker stadig — den åbner teksten nu, hvor den før spurgte et + // indeks, der var en klartekstkopi af den. + found, err := db.SearchNotes(ctx, "keep inventory", 10) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 { + t.Fatalf("søgningen fandt %d noter i en forseglet samling", len(found)) + } +} + +// Noter skrevet før nøglen fandtes skal lukkes, ikke efterlades. +// +// Uden en bagudfyldning ville kroppene blive forseglet efterhånden som de bliver +// *skrevet* — og et arkiv på tolv hundrede importerede noter, som ingen redigerer +// igen, ville aldrig blive det. Det er præcis dét arkiv, der er værd at forsegle. +func TestOldNotesAreSealedOnStartup(t *testing.T) { + db, userID := sealedStore(t) + ctx := context.Background() + + // Skrevet uden om Go-laget, som en note fra før 0027 ligger. + if _, err := db.ExecContext(ctx, ` + INSERT INTO notes (id, title, body, created_by, created_at, updated_at) + VALUES ('n-gammel', 'Immich DB', 'Immich DB\nkodeord: hemmeligt', ?, 100, 200)`, + userID); err != nil { + t.Fatal(err) + } + + sealed, err := db.SealNotes(ctx) + if err != nil { + t.Fatal(err) + } + if sealed != 1 { + t.Fatalf("%d noter blev lukket, want 1", sealed) + } + + var body string + var updated int64 + if err := db.QueryRowContext(ctx, + `SELECT body, updated_at FROM notes WHERE id = 'n-gammel'`).Scan(&body, &updated); err != nil { + t.Fatal(err) + } + if strings.Contains(body, "hemmeligt") { + t.Errorf("den gamle note ligger stadig åben: %s", body) + } + // `updated_at` er ikke rørt. 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 updated != 200 { + t.Errorf("bagudfyldningen flyttede updated_at til %d", updated) + } + + // Og den kan læses. + back, err := db.Note(ctx, "n-gammel") + if err != nil { + t.Fatal(err) + } + if back == nil || !strings.Contains(back.Body, "hemmeligt") { + t.Fatalf("den gamle note kunne ikke læses tilbage: %+v", back) + } + + // Kørt igen gør den ingenting: en note, der allerede er lukket, kendes på sit + // præfiks. + again, err := db.SealNotes(ctx) + if err != nil { + t.Fatal(err) + } + if again != 0 { + t.Errorf("anden kørsel lukkede %d noter om igen", again) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index de4d064..5506e89 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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