feat(secrets): tokens og kodeord forlader ikke længere huset med en kopi

Gmails refresh-token lå i klartekst i databasen. Det er ikke filen på serveren,
der er problemet, men kopierne af den: sikkerhedskopier kan hentes ned gennem
fladen, så hver eneste kopi, der er taget indtil nu, bar et levende token.

Derfor ligger nøglen uden for databasen — i miljøet, eller i en fil ved siden af
dataene med tilstand 0600. VAPID-parret er præcedens for en hemmelighed,
instansen selv laver og gemmer i basen, men det mønster dur ikke her: en nøgle i
samme fil som det, den låser, er ingen lås. Backup er VACUUM INTO af selve
databasen, så filen følger ikke med.

Kun de navngivne felter forsegles — refresh_token, access_token, password,
client_secret. Resten af klatten er det, man læser, når en postkasse driller:
hvilken mappe, hvilken etiket, hvilken adresse. At kryptere det med ville ikke
købe noget og ville koste muligheden for at kigge.

Ingen migration, og ingen der skal forbinde forfra: en værdi uden mærket læses
som sig selv, og næste skrivning lægger den forseglet på plads. Kolonnen
konverterer sig selv efterhånden som den bruges, i stedet for i én omgang, der
skal have ret om hver eneste række på én gang. Der er en prøve for netop det.

Fladen siger det på siden, hvor man henter kopien — for den dag nøglen betyder
noget, er den dag, man gendanner et andet sted og undrer sig over, at postkassen
er tavs. Runen er på v4; en ny variabel uden et versionsspring er usynlig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 21:18:48 +02:00
co-authored by Claude Opus 5
parent aabbbac583
commit a38a79e9b6
11 changed files with 530 additions and 3 deletions
+70 -2
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/kristianwind/verdande/internal/auth"
@@ -313,6 +314,65 @@ func (db *DB) UsersWithGmail(ctx context.Context) ([]User, error) {
// UserSettings reads one scope of a person's integration settings. A missing row is
// an empty map, not an error: nobody has settings until they set some.
// --- sealed values ----------------------------------------------------------------
// secretFields are the keys whose values never leave this process in the clear.
//
// A named set rather than "seal everything": the rest of a settings blob is what
// somebody reads when a mailbox misbehaves — which folder, which label, which
// address — and turning all of it into ciphertext would buy nothing and cost the
// ability to look.
var secretFields = map[string]bool{
"refresh_token": true,
"access_token": true,
"password": true,
"client_secret": true,
}
// seal returns a copy of values with the secret fields sealed. Without a key it
// returns them untouched, which is what the tests that never set one expect.
func (db *DB) seal(values map[string]any) (map[string]any, error) {
if db.box == nil {
return values, nil
}
out := make(map[string]any, len(values))
for k, v := range values {
s, ok := v.(string)
if !secretFields[k] || !ok {
out[k] = v
continue
}
sealed, err := db.box.Seal(s)
if err != nil {
return nil, fmt.Errorf("seal %s: %w", k, err)
}
out[k] = sealed
}
return out, nil
}
// unseal is seal's other direction. Values written before there was a key come
// back as themselves; the next write puts them away properly, so the column
// converts itself as it is used rather than in one migration that has to be right
// about every row at once.
func (db *DB) unseal(values map[string]any) (map[string]any, error) {
if db.box == nil {
return values, nil
}
for k, v := range values {
s, ok := v.(string)
if !secretFields[k] || !ok {
continue
}
plain, err := db.box.Unseal(s)
if err != nil {
return nil, fmt.Errorf("read %s: %w", k, err)
}
values[k] = plain
}
return values, nil
}
func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[string]any, error) {
var raw string
err := db.QueryRowContext(ctx,
@@ -329,10 +389,14 @@ func (db *DB) UserSettings(ctx context.Context, userID, scope string) (map[strin
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return map[string]any{}, nil
}
return values, nil
return db.unseal(values)
}
func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values map[string]any) error {
values, err := db.seal(values)
if err != nil {
return err
}
raw, err := json.Marshal(values)
if err != nil {
return err
@@ -370,10 +434,14 @@ func (db *DB) InstanceSettings(ctx context.Context, scope string) (map[string]an
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return map[string]any{}, nil
}
return values, nil
return db.unseal(values)
}
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
values, err := db.seal(values)
if err != nil {
return err
}
raw, err := json.Marshal(values)
if err != nil {
return err
+125
View File
@@ -0,0 +1,125 @@
package store
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/kristianwind/verdande/internal/secret"
)
// The whole point of the exercise: what is written must not be readable in the
// file that a backup copies.
func TestATokenIsNotStoredInTheClear(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const token = "1//0-a-real-looking-refresh-token"
if err := db.SetUserSettings(ctx, userID, "gmail", map[string]any{
"refresh_token": token,
"label": "Verdande",
}); err != nil {
t.Fatal(err)
}
var raw string
if err := db.QueryRowContext(ctx,
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
t.Fatal(err)
}
if strings.Contains(raw, token) {
t.Fatalf("the token is in the row as written: %s", raw)
}
// The rest stays readable, which is the reason for naming the secret fields
// rather than sealing the whole blob.
if !strings.Contains(raw, "Verdande") {
t.Errorf("the label was sealed too: %s", raw)
}
back, err := db.UserSettings(ctx, userID, "gmail")
if err != nil {
t.Fatal(err)
}
if back["refresh_token"] != token {
t.Errorf("got %q back", back["refresh_token"])
}
}
// The migration that matters. A row written before there was a key must still be
// readable, and must put itself away sealed the next time it is written — nobody
// should have to reconnect a mailbox because the server learned to encrypt.
func TestATokenWrittenBeforeTheKeyStillWorks(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
const token = "1//0-written-when-there-was-no-key"
plain, _ := json.Marshal(map[string]any{"refresh_token": token})
if _, err := db.ExecContext(ctx, `
INSERT INTO user_settings (user_id, scope, values_json, updated_at)
VALUES (?, 'gmail', ?, 0)`, userID, string(plain)); err != nil {
t.Fatal(err)
}
values, err := db.UserSettings(ctx, userID, "gmail")
if err != nil {
t.Fatalf("a row from before the key could not be read: %v", err)
}
if values["refresh_token"] != token {
t.Fatalf("got %q, want the plain token back", values["refresh_token"])
}
// Written back — and now sealed, without anything having asked for it.
if err := db.SetUserSettings(ctx, userID, "gmail", values); err != nil {
t.Fatal(err)
}
var raw string
if err := db.QueryRowContext(ctx,
`SELECT values_json FROM user_settings WHERE user_id = ?`, userID).Scan(&raw); err != nil {
t.Fatal(err)
}
if strings.Contains(raw, token) {
t.Error("the row is still in the clear after being written back")
}
}
// UsersWithGmail finds people by the presence of the key, not its value, so it
// must keep working now that the value is ciphertext.
func TestTheSweepStillFindsAConnectedMailbox(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
if err := db.SetUserSettings(ctx, userID, "gmail",
map[string]any{"refresh_token": "1//0-token"}); err != nil {
t.Fatal(err)
}
users, err := db.UsersWithGmail(ctx)
if err != nil {
t.Fatal(err)
}
if len(users) != 1 {
t.Fatalf("the sweep found %d connected mailboxes, want 1", len(users))
}
}
func sealedStore(t *testing.T) (*DB, string) {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
box, err := secret.Open("", t.TempDir())
if err != nil {
t.Fatal(err)
}
db.UseSecrets(box)
user := &User{Email: "kristian@example.dk", Name: "Kristian"}
if err := db.CreateUser(context.Background(), user, "Indbakke"); err != nil {
t.Fatal(err)
}
return db, user.ID
}
+13
View File
@@ -7,6 +7,7 @@ import (
"database/sql"
"embed"
"fmt"
"github.com/kristianwind/verdande/internal/secret"
"io/fs"
"path/filepath"
"runtime"
@@ -23,8 +24,20 @@ var migrationFS embed.FS
type DB struct {
*sql.DB
path string
// box seals the values that must not travel with a backup. Nil in the tests
// that do not care, where a secret is stored as itself — see Seal.
box *secret.Box
}
// UseSecrets gives the store the key it seals with. Called once at startup, before
// anything reads or writes settings.
//
// Separate from Open because the key comes from the environment or from a file
// beside the data, and a database that cannot be opened without it would make a
// misplaced key look like a corrupt disk.
func (db *DB) UseSecrets(box *secret.Box) { db.box = box }
// dsn is the connection string.
//
// SQLite is a single file, but it is being driven by an HTTP server with concurrent