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:
co-authored by
Claude Opus 5
parent
aabbbac583
commit
a38a79e9b6
@@ -0,0 +1,141 @@
|
||||
// Package secret seals the values that must not travel with a backup.
|
||||
//
|
||||
// The reason this exists is not the database on the server — it is the copies of
|
||||
// it. Backups can be downloaded through the web interface, and every one taken so
|
||||
// far carried a live Gmail refresh token in plain text. Anyone who ends up with a
|
||||
// copy ends up with the mailbox.
|
||||
//
|
||||
// So the key must not live in the database. That rules out the pattern used for
|
||||
// the VAPID pair, which the instance generates and stores in the same file it
|
||||
// protects: a key kept beside what it locks is not a lock. It comes from the
|
||||
// environment, or from a file next to the data that the backup does not include.
|
||||
package secret
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// prefix marks a sealed value, so a mixed column can be read without guessing and
|
||||
// so the scheme can be replaced later without having to.
|
||||
const prefix = "v1:"
|
||||
|
||||
// KeyFile is where the key lands when the environment does not carry one. It sits
|
||||
// beside the database rather than inside it, and the backup routine must never
|
||||
// learn to include it.
|
||||
const KeyFile = "secret.key"
|
||||
|
||||
// Box seals and opens values.
|
||||
type Box struct {
|
||||
aead cipher.AEAD
|
||||
}
|
||||
|
||||
// Open loads the key, generating and saving one on first use.
|
||||
//
|
||||
// A key given in the environment wins: that is how a host keeps the secret out of
|
||||
// its disks altogether. Otherwise a file, created 0600, and read back with its
|
||||
// permissions checked — a key that anybody on the box can read is a key that is
|
||||
// only ceremonially secret.
|
||||
func Open(env, dataDir string) (*Box, error) {
|
||||
if env != "" {
|
||||
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(env))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, errors.New("VERDANDE_SECRET_KEY must be 32 bytes of base64 — " +
|
||||
"generate one with: openssl rand -base64 32")
|
||||
}
|
||||
return newBox(key)
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, KeyFile)
|
||||
raw, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
key, decErr := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
|
||||
if decErr != nil || len(key) != 32 {
|
||||
// Deliberately not regenerating. A new key here would not fix anything —
|
||||
// it would silently make every stored token unreadable, which looks
|
||||
// exactly like the mailbox having disconnected itself overnight.
|
||||
return nil, fmt.Errorf("%s is not a 32-byte base64 key; move it aside only "+
|
||||
"if you accept that every connected mailbox must be reconnected", path)
|
||||
}
|
||||
if info, statErr := os.Stat(path); statErr == nil && info.Mode().Perm()&0o077 != 0 {
|
||||
return nil, fmt.Errorf("%s is readable by others (%04o); chmod 600 it", path, info.Mode().Perm())
|
||||
}
|
||||
return newBox(key)
|
||||
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
key := make([]byte, 32)
|
||||
if _, randErr := rand.Read(key); randErr != nil {
|
||||
return nil, randErr
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(key)
|
||||
if writeErr := os.WriteFile(path, []byte(encoded+"\n"), 0o600); writeErr != nil {
|
||||
return nil, fmt.Errorf("write %s: %w", path, writeErr)
|
||||
}
|
||||
return newBox(key)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newBox(key []byte) (*Box, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Box{aead: aead}, nil
|
||||
}
|
||||
|
||||
// Seal returns the sealed form of v. An empty string stays empty: there is nothing
|
||||
// to hide, and a row full of ciphertext for "" only makes the column harder to read.
|
||||
func (b *Box) Seal(v string) (string, error) {
|
||||
if v == "" || IsSealed(v) {
|
||||
return v, nil
|
||||
}
|
||||
nonce := make([]byte, b.aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := b.aead.Seal(nonce, nonce, []byte(v), nil)
|
||||
return prefix + base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
// Unseal returns the plain value. Anything without the marker is returned as it
|
||||
// is — the column holds values written before there was a key, and they are read
|
||||
// as themselves until something writes them back sealed.
|
||||
func (b *Box) Unseal(v string) (string, error) {
|
||||
if !IsSealed(v) {
|
||||
return v, nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(v, prefix))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sealed value is not base64: %w", err)
|
||||
}
|
||||
if len(raw) < b.aead.NonceSize() {
|
||||
return "", errors.New("sealed value is too short to contain a nonce")
|
||||
}
|
||||
nonce, body := raw[:b.aead.NonceSize()], raw[b.aead.NonceSize():]
|
||||
out, err := b.aead.Open(nil, nonce, body, nil)
|
||||
if err != nil {
|
||||
// The overwhelmingly likely cause, and worth saying rather than "message
|
||||
// authentication failed", which sends people looking for a corrupt row.
|
||||
return "", errors.New("cannot read a stored secret: this is a different key " +
|
||||
"than the one it was written with")
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// IsSealed reports whether v has already been through Seal.
|
||||
func IsSealed(v string) bool { return strings.HasPrefix(v, prefix) }
|
||||
@@ -0,0 +1,149 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAValueSurvivesTheRoundTrip(t *testing.T) {
|
||||
b := mustBox(t)
|
||||
for _, in := range []string{"1//0abc-refresh", "kodeord med æøå", strings.Repeat("x", 5000)} {
|
||||
sealed, err := b.Seal(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(sealed, in) {
|
||||
t.Fatal("the sealed form still contains the value")
|
||||
}
|
||||
out, err := b.Unseal(sealed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != in {
|
||||
t.Errorf("got %q, want %q", out, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two seals of the same value must not look alike, or the column tells anyone
|
||||
// reading it which accounts share a password.
|
||||
func TestSealingTwiceGivesDifferentText(t *testing.T) {
|
||||
b := mustBox(t)
|
||||
first, _ := b.Seal("same")
|
||||
second, _ := b.Seal("same")
|
||||
if first == second {
|
||||
t.Error("the same value sealed to the same text twice")
|
||||
}
|
||||
}
|
||||
|
||||
// The column holds values written before there was a key. Reading must not fail
|
||||
// on them, and sealing must not wrap an already-sealed value a second time.
|
||||
func TestPlainTextIsReadAsItself(t *testing.T) {
|
||||
b := mustBox(t)
|
||||
out, err := b.Unseal("1//0abc-written-before-there-was-a-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "1//0abc-written-before-there-was-a-key" {
|
||||
t.Errorf("an unsealed value came back as %q", out)
|
||||
}
|
||||
|
||||
sealed, _ := b.Seal("x")
|
||||
again, _ := b.Seal(sealed)
|
||||
if again != sealed {
|
||||
t.Error("an already-sealed value was sealed a second time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsStillNothing(t *testing.T) {
|
||||
b := mustBox(t)
|
||||
if out, _ := b.Seal(""); out != "" {
|
||||
t.Errorf("an empty value sealed to %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The message somebody gets after restoring a database without its key file. It
|
||||
// has to point at the key, because every other symptom points at the mailbox.
|
||||
func TestTheWrongKeySaysItIsTheWrongKey(t *testing.T) {
|
||||
sealed, err := mustBox(t).Seal("hemmelig")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = mustBox(t).Unseal(sealed)
|
||||
if err == nil {
|
||||
t.Fatal("another key opened it")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "different key") {
|
||||
t.Errorf("the error does not name the cause: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKeyFileIsMadeOnceAndKeptPrivate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Open("", dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, KeyFile)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("the key file is %04o, not 0600", perm)
|
||||
}
|
||||
|
||||
// Opening again must reuse it. A second key here would quietly make every
|
||||
// stored token unreadable, and it would look like the mailboxes disconnecting
|
||||
// themselves overnight.
|
||||
first, _ := os.ReadFile(path)
|
||||
if _, err := Open("", dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _ := os.ReadFile(path)
|
||||
if string(first) != string(second) {
|
||||
t.Error("opening a second time replaced the key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyOthersCanReadIsRefused(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Open("", dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, KeyFile)
|
||||
if err := os.Chmod(path, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Open("", dir); err == nil {
|
||||
t.Error("a world-readable key was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The environment wins, so a host can keep the key off its disks altogether.
|
||||
func TestTheEnvironmentBeatsTheFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
key := base64.StdEncoding.EncodeToString([]byte(strings.Repeat("k", 32)))
|
||||
if _, err := Open(key, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, KeyFile)); !os.IsNotExist(err) {
|
||||
t.Error("a key file was written even though the environment carried one")
|
||||
}
|
||||
|
||||
if _, err := Open("too-short", dir); err == nil {
|
||||
t.Error("a key that is not 32 bytes was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func mustBox(t *testing.T) *Box {
|
||||
t.Helper()
|
||||
b, err := Open("", t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user