feat: the DEK arrives as platform config, or the instance refuses to boot
Nothing in the app built a seal.Sealer — plan 1's tests each made their own — so there was no way to store a cell. SHEETS_DEK now supplies it, validated at boot alongside sign-in. A missing key is a refusal, not a generated one: an instance that invents a DEK seals this run's cells with a key the next run will not have, which reads as data loss and cannot be undone. No refusal echoes the value, because it is a live key and carlos logs is storage we cannot grep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
8 files changed,
+159
−13
cmd/sheets/main.go+12 −1internal/sheets/app.go+10 −5internal/sheets/app_test.go+1 −1internal/sheets/begin_test.go+1 −1internal/sheets/keys.go+60 −0internal/sheets/keys_test.go+70 −0internal/sheets/page_test.go+1 −1internal/sheets/signin_test.go+4 −4
diff --git a/cmd/sheets/main.go b/cmd/sheets/main.go| index 29ce1c9..095fb87 100644 |
| --- a/cmd/sheets/main.go |
| +++ b/cmd/sheets/main.go |
| @@ -80,7 +80,18 @@ func main() { |
| os.Exit(1) |
| } |
| - app := sheets.New(d.G, signin, sheets.Admits(authEnv.Owners)) |
| + // Same shape as the sign-in refusals above, and for the same |
| + // reason: an instance with no DEK answers /healthz perfectly well |
| + // and then fails on the first cell anybody types. The key arrives |
| + // as platform config and is never written to the database — see |
| + // internal/sheets/keys.go and spec §2.1. |
| + sealer, err := sheets.LoadSealer() |
| + if err != nil { |
| + logger.Error("sealing", "err", err) |
| + os.Exit(1) |
| + } |
| + |
| + app := sheets.New(d.G, signin, sealer, sheets.Admits(authEnv.Owners)) |
| // rastrillo.Options.Mux wants a *http.ServeMux; the app's own |
| // router is a chi.Router (an http.Handler), so it mounts under one. |
diff --git a/internal/sheets/app.go b/internal/sheets/app.go| index 3820d23..ef73d87 100644 |
| --- a/internal/sheets/app.go |
| +++ b/internal/sheets/app.go |
| @@ -8,12 +8,15 @@ import ( |
| "github.com/carlosframework/rastrillo/auth" |
| "github.com/go-chi/chi/v5" |
| "gorm.io/gorm" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/seal" |
| ) |
| // App holds the wiring. One per process. |
| type App struct { |
| - db *gorm.DB |
| - auth *auth.Auth |
| + db *gorm.DB |
| + auth *auth.Auth |
| + sealer *seal.Sealer |
| // admit is the allowlist, consulted before a link is SENT. |
| // auth.Config.Authorize covers the same list at redemption; this |
| @@ -21,15 +24,17 @@ type App struct { |
| admit func(string) bool |
| } |
| -// New builds an App. db may be nil in tests that do not touch storage. |
| +// New builds an App. db and sealer may be nil in tests that do not |
| +// touch storage; every route that reads or writes a cell checks and |
| +// answers an error rather than panicking. |
| // |
| // a may be nil ONLY in tests: Router then serves the sign-in page but |
| // mounts none of auth's handlers, so a nil-auth App can render pages |
| // without a database or a mailer. Production always passes one — main |
| // refuses to boot without the configuration auth needs, so there is no |
| // path there that reaches this with nil. |
| -func New(db *gorm.DB, a *auth.Auth, admit func(string) bool) *App { |
| - return &App{db: db, auth: a, admit: admit} |
| +func New(db *gorm.DB, a *auth.Auth, sealer *seal.Sealer, admit func(string) bool) *App { |
| + return &App{db: db, auth: a, sealer: sealer, admit: admit} |
| } |
| // Router returns the mounted handler. |
diff --git a/internal/sheets/app_test.go b/internal/sheets/app_test.go| index 10baba0..a2b5828 100644 |
| --- a/internal/sheets/app_test.go |
| +++ b/internal/sheets/app_test.go |
| @@ -12,7 +12,7 @@ import ( |
| // ahead of this one and wins (see the comment on Router in app.go); this |
| // test only proves the app's router itself is wired and reachable. |
| func TestHealthz(t *testing.T) { |
| - app := New(nil, nil, nil) |
| + app := New(nil, nil, nil, nil) |
| req := httptest.NewRequest(http.MethodGet, "/healthz", nil) |
| rec := httptest.NewRecorder() |
| app.Router().ServeHTTP(rec, req) |
diff --git a/internal/sheets/begin_test.go b/internal/sheets/begin_test.go| index 49d5191..db28bcb 100644 |
| --- a/internal/sheets/begin_test.go |
| +++ b/internal/sheets/begin_test.go |
| @@ -75,7 +75,7 @@ func appWithRecorder(t *testing.T) (*App, *recorder) { |
| if err != nil { |
| t.Fatalf("NewAuth: %v", err) |
| } |
| - return New(d.G, a, Admits(owners)), rec |
| + return New(d.G, a, nil, Admits(owners)), rec |
| } |
| func postSignin(t *testing.T, app *App, address string) *httptest.ResponseRecorder { |
diff --git a/internal/sheets/keys.go b/internal/sheets/keys.go| new file mode 100644 |
| index 0000000..228d7ff |
| --- /dev/null |
| +++ b/internal/sheets/keys.go |
| @@ -0,0 +1,60 @@ |
| +package sheets |
| + |
| +import ( |
| + "encoding/base64" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "strings" |
| + |
| + "amadan.net/oficina/sheets/internal/sheets/seal" |
| +) |
| + |
| +// dekBytes is the DEK's required length. seal.New enforces it too; this |
| +// check exists so the refusal names SHEETS_DEK and the length an |
| +// operator has to produce, rather than surfacing a framework message |
| +// about a byte slice. |
| +const dekBytes = 32 |
| + |
| +// LoadSealer reads the data encryption key from platform config and |
| +// builds the app's one Sealer. |
| +// |
| +// The DEK arrives as platform config (`carlos secrets`), never stored in |
| +// the database — spec §2.1. A missing one is a hard refusal rather than |
| +// a generated key: an instance that invents a DEK at boot seals this |
| +// run's cells with a key the next run will not have, which reads as data |
| +// loss and cannot be undone. Every other boot refusal in this app has |
| +// the same shape, and for the same reason: continuing would produce an |
| +// instance that answers /healthz perfectly well and then fails on the |
| +// first cell anybody types. |
| +// |
| +// No error here ever includes the value read from the environment. It is |
| +// a live key, and `carlos logs` is platform-held storage we cannot grep |
| +// or redact (AGENTS.md / Mechanical rules). |
| +func LoadSealer() (*seal.Sealer, error) { |
| + raw := strings.TrimSpace(os.Getenv("SHEETS_DEK")) |
| + if raw == "" { |
| + return nil, errors.New( |
| + "sealing is not configured: SHEETS_DEK not set. " + |
| + "Generate one with `head -c 32 /dev/urandom | base64` and set it with " + |
| + "`carlos secrets set --app sheets SHEETS_DEK=...`") |
| + } |
| + dek, err := base64.StdEncoding.DecodeString(raw) |
| + if err != nil { |
| + // The decode error is deliberately dropped rather than wrapped: |
| + // base64.CorruptInputError renders as an offset, which is a |
| + // (small) fact about the key's bytes. The variable name is the |
| + // only thing an operator needs here. |
| + return nil, errors.New("sealing is not configured: SHEETS_DEK is not valid base64") |
| + } |
| + if len(dek) != dekBytes { |
| + return nil, fmt.Errorf( |
| + "sealing is not configured: SHEETS_DEK decodes to %d bytes, want %d", |
| + len(dek), dekBytes) |
| + } |
| + s, err := seal.New(dek) |
| + if err != nil { |
| + return nil, fmt.Errorf("sealing is not configured: %w", err) |
| + } |
| + return s, nil |
| +} |
diff --git a/internal/sheets/keys_test.go b/internal/sheets/keys_test.go| new file mode 100644 |
| index 0000000..0c9baef |
| --- /dev/null |
| +++ b/internal/sheets/keys_test.go |
| @@ -0,0 +1,70 @@ |
| +package sheets |
| + |
| +import ( |
| + "encoding/base64" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestLoadSealerRefusesAMissingDEK(t *testing.T) { |
| + t.Setenv("SHEETS_DEK", "") |
| + _, err := LoadSealer() |
| + if err == nil { |
| + t.Fatal("LoadSealer with no SHEETS_DEK = nil error, want a refusal") |
| + } |
| + if !strings.Contains(err.Error(), "SHEETS_DEK") { |
| + t.Fatalf("error %q does not name the variable an operator has to set", err) |
| + } |
| +} |
| + |
| +func TestLoadSealerRefusesAShortDEK(t *testing.T) { |
| + t.Setenv("SHEETS_DEK", base64.StdEncoding.EncodeToString(make([]byte, 16))) |
| + if _, err := LoadSealer(); err == nil { |
| + t.Fatal("LoadSealer with a 16-byte DEK = nil error, want a refusal") |
| + } |
| +} |
| + |
| +func TestLoadSealerRefusesJunk(t *testing.T) { |
| + t.Setenv("SHEETS_DEK", "not base64 at all!!") |
| + if _, err := LoadSealer(); err == nil { |
| + t.Fatal("LoadSealer with unparseable base64 = nil error, want a refusal") |
| + } |
| +} |
| + |
| +func TestLoadSealerAcceptsA32ByteDEK(t *testing.T) { |
| + t.Setenv("SHEETS_DEK", base64.StdEncoding.EncodeToString(make([]byte, 32))) |
| + s, err := LoadSealer() |
| + if err != nil { |
| + t.Fatalf("LoadSealer: %v", err) |
| + } |
| + if s == nil { |
| + t.Fatal("LoadSealer returned a nil sealer and a nil error") |
| + } |
| + // A sealer that cannot seal is not a sealer. This also pins that |
| + // LoadSealer hands back something wired to the key it read, rather |
| + // than a zero value that happens to be non-nil. |
| + ct, err := s.Seal("cell.input", []byte("42")) |
| + if err != nil { |
| + t.Fatalf("Seal through a LoadSealer sealer: %v", err) |
| + } |
| + if len(ct) == 0 { |
| + t.Fatal("Seal produced no ciphertext") |
| + } |
| +} |
| + |
| +// The DEK is a live key. A refusal that echoes it puts it in |
| +// platform-held log storage we cannot grep (AGENTS.md / Mechanical |
| +// rules), which is exactly the leak sealing exists to prevent. |
| +func TestLoadSealerNeverEchoesTheKey(t *testing.T) { |
| + // Valid base64, wrong length — so it reaches the length branch, |
| + // which is the one most likely to be written with %q of the input. |
| + const secret = "c2VjcmV0LXNlY3JldC1zZWNyZXQ=" |
| + t.Setenv("SHEETS_DEK", secret) |
| + _, err := LoadSealer() |
| + if err == nil { |
| + t.Fatal("a short DEK was accepted") |
| + } |
| + if strings.Contains(err.Error(), secret) { |
| + t.Fatalf("the refusal contains the DEK itself: %q", err) |
| + } |
| +} |
diff --git a/internal/sheets/page_test.go b/internal/sheets/page_test.go| index 4a1e7cf..e0b7600 100644 |
| --- a/internal/sheets/page_test.go |
| +++ b/internal/sheets/page_test.go |
| @@ -11,7 +11,7 @@ import ( |
| func get(t *testing.T, path string) *httptest.ResponseRecorder { |
| t.Helper() |
| rec := httptest.NewRecorder() |
| - New(nil, nil, nil).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) |
| + New(nil, nil, nil, nil).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) |
| return rec |
| } |
diff --git a/internal/sheets/signin_test.go b/internal/sheets/signin_test.go| index c6dc596..a00f8ad 100644 |
| --- a/internal/sheets/signin_test.go |
| +++ b/internal/sheets/signin_test.go |
| @@ -47,7 +47,7 @@ func TestSigninMessageSentAndEmpty(t *testing.T) { |
| func TestSigninPageDoesNotEchoQuery(t *testing.T) { |
| rec := httptest.NewRecorder() |
| req := httptest.NewRequest(http.MethodGet, "/signin?err=%3Cscript%3Ealert(1)%3C/script%3E", nil) |
| - New(nil, nil, nil).Router().ServeHTTP(rec, req) |
| + New(nil, nil, nil, nil).Router().ServeHTTP(rec, req) |
| if rec.Code != http.StatusOK { |
| t.Fatalf("GET /signin = %d", rec.Code) |
| } |
| @@ -80,7 +80,7 @@ func testAuth(t *testing.T) *App { |
| if err != nil { |
| t.Fatalf("NewAuth: %v", err) |
| } |
| - return New(d.G, a, Admits([]string{"paul@example.com"})) |
| + return New(d.G, a, nil, Admits([]string{"paul@example.com"})) |
| } |
| // TestAuthRoutesAreMounted proves the four handlers are reachable. It |
| @@ -107,7 +107,7 @@ func TestAuthRoutesAreMounted(t *testing.T) { |
| // pins that those codes really do mean "not mounted", so the assertion |
| // there is measuring what it claims to. |
| func TestAuthRoutesAbsentWithoutAuth(t *testing.T) { |
| - app := New(nil, nil, nil) |
| + app := New(nil, nil, nil, nil) |
| for _, tc := range []struct{ method, path string }{ |
| {http.MethodPost, "/signin"}, |
| {http.MethodGet, "/auth/verify"}, |
| @@ -126,7 +126,7 @@ func TestAuthRoutesAbsentWithoutAuth(t *testing.T) { |
| func TestSigninCarriesForce(t *testing.T) { |
| body := func(url string) string { |
| rec := httptest.NewRecorder() |
| - New(nil, nil, nil).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) |
| + New(nil, nil, nil, nil).Router().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) |
| return rec.Body.String() |
| } |
| with := body("/signin?force=1&err=keymail") |