oficina / sheets Public

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
Paul Campbell pushed by paul@keymail.dev 6c47bf9eafe62ed3f7337086e1668ba9172c8901 parent 58da80b
8 files changed, +159 −13
  • cmd/sheets/main.go +12 −1
  • internal/sheets/app.go +10 −5
  • internal/sheets/app_test.go +1 −1
  • internal/sheets/begin_test.go +1 −1
  • internal/sheets/keys.go +60 −0
  • internal/sheets/keys_test.go +70 −0
  • internal/sheets/page_test.go +1 −1
  • internal/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")