Config, the DEK, and seeding the catalogue at boot
LoadConfig is a tested function rather than a run of os.Getenv in main, for the reason memoria's config.go records: an inline version of the same validation was "checked" by grepping for os.Exit near each variable name, which passed even when the refusal was deleted, because a neighbouring error path supplied the os.Exit the grep looked for. A guard that cannot fail is not a guard, so every refusal here has a test. There is deliberately no generated-fallback DEK. A fallback would seal this boot's cards under a key the next boot cannot derive, losing the data quietly instead of refusing loudly. The catalogue is seeded in App rather than by a migration, and that is deliberate: a migration cannot seal, because the DEK is config and arrives after the schema does, so a migration writing labels would put the catalogue's words into the durable file in the clear — which is exactly what §4.3 forbids. The sealer now hangs off the app struct, so a handler that touches a sealed column without it fails to compile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 files changed,
+186
−13
cmd/fichas/main.go+16 −9internal/fichas/app.go+13 −2internal/fichas/config.go+67 −0internal/fichas/config_test.go+66 −0internal/fichas/handlers.go+7 −1internal/fichastest/harness_test.go+17 −1
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/fichas/cmd/fichas [no test files] ok amadan.net/oficina/fichas/internal/fichas 0.016s ? amadan.net/oficina/fichas/internal/fichas/icons [no test files] ok amadan.net/oficina/fichas/internal/fichas/seal (cached) ok amadan.net/oficina/fichas/internal/fichastest 0.023s === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/cmd/fichas/main.go b/cmd/fichas/main.go| index 7f377ff..605ab18 100644 |
| --- a/cmd/fichas/main.go |
| +++ b/cmd/fichas/main.go |
| @@ -33,17 +33,24 @@ func main() { |
| } |
| defer d.Close() |
| - // Origin decides the CSRF same-origin check (and, when the app |
| - // grows accounts, the Secure/__Host- cookie attributes) — so |
| - // defaulting it silently in production would be a real bug. Loud |
| - // on purpose, harmless in local dev. |
| - origin := os.Getenv("FICHAS_ORIGIN") |
| - if origin == "" { |
| - origin = "http://localhost:8080" |
| - logger.Warn("FICHAS_ORIGIN not set; defaulting", "origin", origin) |
| + // Every refusal in LoadConfig is a safety property, and it lives in |
| + // a tested function rather than inline here — config.go records the |
| + // false-pass guard that made the case for moving it. |
| + // |
| + // Origin decides the CSRF same-origin check (and, when the app grows |
| + // accounts, the Secure/__Host- cookie attributes), so defaulting it |
| + // silently in production would be a real bug. Loud on purpose, |
| + // harmless in local dev. |
| + cfg, err := fichas.LoadConfig(os.Getenv) |
| + if err != nil { |
| + logger.Error("configuration", "err", err) |
| + os.Exit(1) |
| + } |
| + if cfg.UsedDefaultOrigin() { |
| + logger.Warn("FICHAS_ORIGIN not set; defaulting", "origin", cfg.Origin) |
| } |
| - mux, err := fichas.App(d, origin, logger) |
| + mux, err := fichas.App(d, cfg.Origin, cfg.Sealer, logger) |
| if err != nil { |
| logger.Error("build app", "err", err) |
| os.Exit(1) |
diff --git a/internal/fichas/app.go b/internal/fichas/app.go| index 92f7349..b95c61c 100644 |
| --- a/internal/fichas/app.go |
| +++ b/internal/fichas/app.go |
| @@ -10,6 +10,8 @@ import ( |
| "github.com/carlosframework/rastrillo/csrf" |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/carlosframework/rastrillo/migrate" |
| + |
| + "amadan.net/oficina/fichas/internal/fichas/seal" |
| ) |
| // App wires the whole app: schema, router, static files. It returns a |
| @@ -24,7 +26,7 @@ import ( |
| // rastrillo repo is the worked example to copy). Adding a subsystem |
| // also means adding its Schema to migrations.go's BootSchema — see |
| // the comment there. |
| -func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) { |
| +func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*http.ServeMux, error) { |
| // Apply BootSchema, not Schema: BootSchema is everything this |
| // app needs at boot (its own migrations plus any subsystem's), |
| // while Schema (migrations.go) stays just this app's own so the |
| @@ -41,7 +43,16 @@ func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) { |
| logger.Info("migrate", "applied", migrated.Applied, |
| "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| - a := &app{db: d, logger: logger} |
| + // The seeded catalogue (§4.4) is data, not schema, so it is written |
| + // here and not by a migration. Deliberate: a migration cannot seal, |
| + // because the DEK is config and arrives after the schema does, and a |
| + // migration that wrote labels in the clear would put the catalogue's |
| + // words in the durable file — which is the whole thing §4.3 forbids. |
| + if err := Seed(d.G, sealer); err != nil { |
| + return nil, err |
| + } |
| + |
| + a := &app{db: d, sealer: sealer, logger: logger} |
| r := chi.NewRouter() |
| // App-wide, from day one: every state-changing route added below |
diff --git a/internal/fichas/config.go b/internal/fichas/config.go| new file mode 100644 |
| index 0000000..9bbca3b |
| --- /dev/null |
| +++ b/internal/fichas/config.go |
| @@ -0,0 +1,67 @@ |
| +package fichas |
| + |
| +import ( |
| + "encoding/base64" |
| + "errors" |
| + "fmt" |
| + |
| + "amadan.net/oficina/fichas/internal/fichas/seal" |
| +) |
| + |
| +// Config is everything fichas needs from platform config. |
| +// |
| +// This is a type with a constructor rather than a run of os.Getenv calls in |
| +// main, for one reason: the refusals below are a safety property, and a |
| +// safety property needs a test. memoria's config.go records why — an |
| +// earlier version of the same validation lived inline in main and was |
| +// "checked" by grepping for os.Exit near each variable name, which passed |
| +// even when the refusal was deleted, because a neighbouring error path |
| +// supplied the os.Exit the grep was looking for. |
| +type Config struct { |
| + Origin string |
| + Sealer *seal.Sealer |
| +} |
| + |
| +// ErrMissing is returned when required platform config is absent. |
| +var ErrMissing = errors.New("fichas: required configuration is missing") |
| + |
| +// defaultOrigin is what an unset FICHAS_ORIGIN falls back to. Local only: |
| +// it is the CSRF yardstick, so on a real host it must be the host the |
| +// browser is actually on. |
| +const defaultOrigin = "http://127.0.0.1:8824" |
| + |
| +// LoadConfig reads and validates configuration. getenv is a parameter so a |
| +// test can supply an environment without mutating the process's. |
| +// |
| +// **There is deliberately no generated-fallback DEK.** A fallback would |
| +// seal this boot's cards under a key the next boot cannot derive, losing |
| +// the data quietly instead of refusing loudly. Failing to start is the |
| +// correct behaviour and the only honest one. |
| +func LoadConfig(getenv func(string) string) (Config, error) { |
| + var c Config |
| + |
| + dekRaw := getenv("FICHAS_DEK") |
| + if dekRaw == "" { |
| + return c, fmt.Errorf("%w: FICHAS_DEK", ErrMissing) |
| + } |
| + dek, err := base64.StdEncoding.DecodeString(dekRaw) |
| + if err != nil { |
| + return c, fmt.Errorf("fichas: FICHAS_DEK is not valid base64: %w", err) |
| + } |
| + sealer, err := seal.New(dek) |
| + if err != nil { |
| + return c, fmt.Errorf("fichas: FICHAS_DEK rejected: %w", err) |
| + } |
| + c.Sealer = sealer |
| + |
| + c.Origin = getenv("FICHAS_ORIGIN") |
| + if c.Origin == "" { |
| + c.Origin = defaultOrigin |
| + } |
| + return c, nil |
| +} |
| + |
| +// UsedDefaultOrigin reports whether Origin fell back, so main can say so |
| +// out loud. A silently defaulted origin is a CSRF yardstick pointing at |
| +// the wrong host. |
| +func (c Config) UsedDefaultOrigin() bool { return c.Origin == defaultOrigin } |
diff --git a/internal/fichas/config_test.go b/internal/fichas/config_test.go| new file mode 100644 |
| index 0000000..ef3948b |
| --- /dev/null |
| +++ b/internal/fichas/config_test.go |
| @@ -0,0 +1,66 @@ |
| +package fichas |
| + |
| +import ( |
| + "encoding/base64" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func env(m map[string]string) func(string) string { |
| + return func(k string) string { return m[k] } |
| +} |
| + |
| +func goodDEK() string { |
| + return base64.StdEncoding.EncodeToString(make([]byte, 32)) |
| +} |
| + |
| +// The refusal that matters most: no DEK, no boot. A generated fallback |
| +// would seal this boot's cards under a key the next boot cannot derive. |
| +func TestLoadConfigRefusesAMissingDEK(t *testing.T) { |
| + _, err := LoadConfig(env(map[string]string{})) |
| + if !errors.Is(err, ErrMissing) { |
| + t.Fatalf("booting with no FICHAS_DEK returned %v, want ErrMissing", err) |
| + } |
| +} |
| + |
| +func TestLoadConfigRefusesAMalformedDEK(t *testing.T) { |
| + for name, v := range map[string]string{ |
| + "not base64": "!!!!not base64!!!!", |
| + "too short": base64.StdEncoding.EncodeToString(make([]byte, 16)), |
| + "too long": base64.StdEncoding.EncodeToString(make([]byte, 64)), |
| + "empty bytes": base64.StdEncoding.EncodeToString(nil), |
| + } { |
| + if _, err := LoadConfig(env(map[string]string{"FICHAS_DEK": v})); err == nil { |
| + t.Errorf("a DEK that is %s was accepted", name) |
| + } |
| + } |
| +} |
| + |
| +func TestLoadConfigAcceptsAGoodDEK(t *testing.T) { |
| + c, err := LoadConfig(env(map[string]string{"FICHAS_DEK": goodDEK()})) |
| + if err != nil { |
| + t.Fatalf("LoadConfig: %v", err) |
| + } |
| + if c.Sealer == nil { |
| + t.Fatal("a config with no Sealer was returned without an error") |
| + } |
| + if !c.UsedDefaultOrigin() { |
| + t.Fatalf("Origin is %q; an unset FICHAS_ORIGIN should report as defaulted", c.Origin) |
| + } |
| +} |
| + |
| +func TestLoadConfigTakesTheOriginWhenSet(t *testing.T) { |
| + c, err := LoadConfig(env(map[string]string{ |
| + "FICHAS_DEK": goodDEK(), |
| + "FICHAS_ORIGIN": "https://fichas.oficina.works", |
| + })) |
| + if err != nil { |
| + t.Fatalf("LoadConfig: %v", err) |
| + } |
| + if c.Origin != "https://fichas.oficina.works" { |
| + t.Fatalf("Origin is %q", c.Origin) |
| + } |
| + if c.UsedDefaultOrigin() { |
| + t.Fatal("an explicitly set origin reports as defaulted") |
| + } |
| +} |
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go| index 335ed49..c0b6456 100644 |
| --- a/internal/fichas/handlers.go |
| +++ b/internal/fichas/handlers.go |
| @@ -5,12 +5,18 @@ import ( |
| "net/http" |
| "github.com/carlosframework/rastrillo/db" |
| + |
| + "amadan.net/oficina/fichas/internal/fichas/seal" |
| ) |
| // app holds what every handler needs. When the app grows accounts, |
| // this is where the sessions handle lands too. |
| type app struct { |
| - db *db.DB |
| + db *db.DB |
| + // sealer is the only way anything in this app reads or writes a |
| + // value. A handler that touches a sealed column without it is a bug |
| + // the compiler will catch. |
| + sealer *seal.Sealer |
| logger *slog.Logger |
| } |
diff --git a/internal/fichastest/harness_test.go b/internal/fichastest/harness_test.go| index f748035..f8c9385 100644 |
| --- a/internal/fichastest/harness_test.go |
| +++ b/internal/fichastest/harness_test.go |
| @@ -19,12 +19,28 @@ import ( |
| "github.com/carlosframework/rastrillo/db" |
| fichas "amadan.net/oficina/fichas/internal/fichas" |
| + "amadan.net/oficina/fichas/internal/fichas/seal" |
| ) |
| // testOrigin is the origin newApp wires and post presents as its |
| // same-origin evidence — the pair a browser form submission would be. |
| const testOrigin = "http://app.test" |
| +// testSealer is a fixed DEK. Fixed rather than random so that a failure |
| +// is reproducible; it never leaves the test binary. |
| +func testSealer(t *testing.T) *seal.Sealer { |
| + t.Helper() |
| + k := make([]byte, 32) |
| + for i := range k { |
| + k[i] = 0x7f |
| + } |
| + s, err := seal.New(k) |
| + if err != nil { |
| + t.Fatalf("seal.New: %v", err) |
| + } |
| + return s |
| +} |
| + |
| // newApp builds the whole app per test over a fresh temp database, |
| // exactly as main.go does. |
| func newApp(t *testing.T) http.Handler { |
| @@ -35,7 +51,7 @@ func newApp(t *testing.T) http.Handler { |
| t.Fatalf("db.Open: %v", err) |
| } |
| t.Cleanup(func() { d.Close() }) |
| - mux, err := fichas.App(d, testOrigin, logger) |
| + mux, err := fichas.App(d, testOrigin, testSealer(t), logger) |
| if err != nil { |
| t.Fatalf("App: %v", err) |
| } |