oficina / fichas Public

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>
Paul Campbell pushed by paul@keymail.dev f8d95131d3d155b42b348b6652ee85ff7c7ec66b parent 5774623
6 files changed, +186 −13
  • cmd/fichas/main.go +16 −9
  • internal/fichas/app.go +13 −2
  • internal/fichas/config.go +67 −0
  • internal/fichas/config_test.go +66 −0
  • internal/fichas/handlers.go +7 −1
  • internal/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)
}