config: refusals, sealer, release stamping
Config with every startup refusal from spec 7.1, development selected only by the -env flag; the sealer lifted from calendar with Meet's label; make release stamps BuildVersion and refuses a dirty tree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 files changed,
+518
−83
Makefile+25 −4cmd/meet/main.go+25 −17go.mod+7 −3go.sum+14 −10internal/meet/app.go+25 −32internal/meet/config.go+134 −0internal/meet/handlers.go+1 −13internal/meet/seal/fingerprint.go+20 −0internal/meet/seal/seal.go+113 −0internal/meet/seal/seal_test.go+47 −0internal/meet/standing.go+43 −0internal/meettest/config_test.go+55 −0internal/meettest/harness_test.go+9 −4
diff --git a/Makefile b/Makefile| index ce317c6..8181a98 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -9,7 +9,14 @@ RELEASE_GOOS ?= linux |
| RELEASE_GOARCH ?= arm64 |
| RELEASE_BIN := releases/$(APP)-$(RELEASE_GOOS)-$(RELEASE_GOARCH) |
| -.PHONY: build release test vet fmt-check migration-check ci |
| +# What the running binary reports from GET /api/version. An unstamped |
| +# binary answers "dev" from every build, so the platform's deploy |
| +# verification can never disagree with itself. Stamping is what gives |
| +# that check the ability to fail. Outside a repository, or before the |
| +# first commit, name it yourself: make release VERSION=v0.1.0 |
| +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null) |
| + |
| +.PHONY: build release version-check test vet fmt-check migration-check ci |
| # build is the compile check: with more than one package matched, go |
| # build discards the output, so this catches a broken package without |
| @@ -27,15 +34,29 @@ build: |
| # and go version -m still reports module metadata. What you lose is |
| # source-level debugging with delve or gdb — build without the flags for |
| # that. rastrillo dev never strips, for the same reason. |
| -release: |
| +release: version-check |
| @mkdir -p releases |
| CGO_ENABLED=0 GOOS=$(RELEASE_GOOS) GOARCH=$(RELEASE_GOARCH) \ |
| - go build -ldflags="-s -w" -o $(RELEASE_BIN) ./cmd/$(APP) |
| + go build -ldflags="-s -w -X 'amadan.net/rastrillo/rastrillo.BuildVersion=$(VERSION)'" \ |
| + -o $(RELEASE_BIN) ./cmd/$(APP) |
| @echo |
| - @echo "built $(RELEASE_BIN)" |
| + @echo "built $(RELEASE_BIN) reporting version $(VERSION)" |
| @echo "ship it with:" |
| @echo " carlos ship -app $(APP) -target $(RELEASE_GOOS)-$(RELEASE_GOARCH) $(RELEASE_BIN)" |
| +version-check: |
| + @if [ -z "$(VERSION)" ]; then \ |
| + echo "make release: no version to stamp."; \ |
| + echo " git describe found nothing here: no repository, or no commit yet."; \ |
| + echo " Commit, or name it: make release VERSION=v0.1.0"; \ |
| + exit 1; \ |
| + fi |
| + @case "$(VERSION)" in *-dirty) \ |
| + echo "make release: the tree is dirty, so $(VERSION) does not name what you are about to ship."; \ |
| + echo " Commit first, or name it: make release VERSION=v0.1.0-wip"; \ |
| + exit 1;; \ |
| + esac |
| + |
| test: |
| go test ./... |
diff --git a/cmd/meet/main.go b/cmd/meet/main.go| index 9bc2c13..8e94ce7 100644 |
| --- a/cmd/meet/main.go |
| +++ b/cmd/meet/main.go |
| @@ -1,25 +1,26 @@ |
| -// Command meet wires the app: resolve the platform's activation |
| -// argv, open the database, build the app, serve. |
| package main |
| import ( |
| + "flag" |
| "log/slog" |
| "os" |
| + "strings" |
| "amadan.net/rastrillo/rastrillo" |
| "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/mail" |
| - meet "amadan.net/oficina/meet/internal/meet" |
| + "amadan.net/oficina/meet/internal/meet" |
| ) |
| func main() { |
| logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) |
| - // Resolve, not Run: this app opens its own database handle via |
| - // db.Open — a *gorm.DB with a split reader/writer pool, not the |
| - // bare *sql.DB Options.Router would hand back — so Options.DBPath |
| - // must be empty again by the Serve call below, or Serve would open |
| - // a second, unused connection to the same file. |
| + // Only the flag can select development: an env var that could would |
| + // let a misconfigured box relax every refusal (config.go). |
| + env := flag.String("env", "", "set to development to relax startup refusals") |
| + flag.Parse() |
| + |
| opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "meet.db", Logger: logger}) |
| if err != nil { |
| logger.Error("resolve activation", "err", err) |
| @@ -33,17 +34,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("MEET_ORIGIN") |
| - if origin == "" { |
| - origin = "http://localhost:8080" |
| - logger.Warn("MEET_ORIGIN not set; defaulting", "origin", origin) |
| + var mailer mail.Sender |
| + if os.Getenv("MEET_SMTP_HOST") != "" && os.Getenv("MEET_SMTP_FROM") != "" { |
| + mailer = mail.FromEnv("MEET", logger) |
| + } |
| + |
| + cfg := meet.Config{ |
| + Env: *env, |
| + Origin: os.Getenv("MEET_ORIGIN"), |
| + InstanceKey: os.Getenv("MEET_INSTANCE_KEY"), |
| + DEK: os.Getenv("MEET_DEK"), |
| + AllowedAddresses: strings.Split(os.Getenv("MEET_ALLOWED_ADDRESSES"), ","), |
| + Mailer: mailer, |
| + RelayConfigured: os.Getenv("CARLOS_RELAY") != "", |
| + Zone: os.Getenv("MEET_ZONE"), |
| + Logger: logger, |
| } |
| - mux, err := meet.App(d, origin, logger) |
| + mux, err := meet.App(d, cfg) |
| if err != nil { |
| logger.Error("build app", "err", err) |
| os.Exit(1) |
diff --git a/go.mod b/go.mod| index 1897a7e..55fdfa9 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -1,13 +1,15 @@ |
| module amadan.net/oficina/meet |
| -go 1.25.0 |
| +go 1.26.0 |
| require ( |
| amadan.net/rastrillo/rastrillo v0.26.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| + golang.org/x/crypto v0.56.0 |
| ) |
| require ( |
| + github.com/BurntSushi/toml v1.6.0 // indirect |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| @@ -15,8 +17,8 @@ require ( |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| - golang.org/x/sys v0.46.0 // indirect |
| - golang.org/x/text v0.20.0 // indirect |
| + golang.org/x/sys v0.47.0 // indirect |
| + golang.org/x/text v0.41.0 // indirect |
| gorm.io/gorm v1.31.2 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| @@ -24,3 +26,5 @@ require ( |
| modernc.org/memory v1.11.0 // indirect |
| modernc.org/sqlite v1.55.0 // indirect |
| ) |
| + |
| +tool amadan.net/rastrillo/rastrillo/cmd/rastrillo |
diff --git a/go.sum b/go.sum| index 4908081..c8aae48 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,7 @@ |
| amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo= |
| amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= |
| github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= |
| @@ -40,17 +42,19 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF |
| github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= |
| +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= |
| +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= |
| +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= |
| +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= |
| +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= |
| -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= |
| -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= |
| +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= |
| +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= |
| +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= |
| gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index f1b1239..cdbad22 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -10,51 +10,44 @@ import ( |
| "amadan.net/rastrillo/rastrillo/csrf" |
| "amadan.net/rastrillo/rastrillo/db" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| + |
| + "amadan.net/oficina/meet/internal/meet/seal" |
| ) |
| -// App wires the whole app: schema, router, static files. It returns a |
| -// *http.ServeMux because rastrillo.Options.Mux is typed that way — the |
| -// chi router mounts inside it. origin is the app's external origin |
| -// ("https://app.example.com") — the CSRF check's yardstick, and, once |
| -// the app grows accounts, sessions.Config.Origin too. |
| -// |
| -// Growing the app is SKILL.md's five-file shape: models in models.go, |
| -// handlers in handlers.go, and — for a multi-user app — the sessions |
| -// core plus an identity plugin wired right here (examples/notes in the |
| -// 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) { |
| - // 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 |
| - // generator and its check never see a subsystem's tables and try |
| - // to drop them. |
| +// app holds what every handler needs. |
| +type app struct { |
| + db *db.DB |
| + cfg resolved |
| + sealer *seal.Sealer |
| + logger *slog.Logger |
| +} |
| + |
| +// App wires the whole app: refusals, schema, router, static files. It |
| +// returns a *http.ServeMux because rastrillo.Options.Mux is typed that |
| +// way; the chi router mounts inside it. |
| +func App(d *db.DB, cfg Config) (*http.ServeMux, error) { |
| + rc, err := resolve(cfg) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sealer, err := seal.New(rc.dek) |
| + if err != nil { |
| + return nil, err |
| + } |
| migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| if err != nil { |
| return nil, err |
| } |
| - // One line per boot. Result exists so this can be logged, and |
| - // until something does, a fleet-wide adoption — every database |
| - // stamped, no DDL run, exactly once, never again — produces no |
| - // output at all, which is the last event you want to be silent. |
| - logger.Info("migrate", "applied", migrated.Applied, |
| + rc.Logger.Info("migrate", "applied", migrated.Applied, |
| "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| - a := &app{db: d, logger: logger} |
| + a := &app{db: d, cfg: rc, sealer: sealer, logger: rc.Logger} |
| r := chi.NewRouter() |
| - // App-wide, from day one: every state-changing route added below |
| - // is born covered. Origin-checking, not tokens — nothing to mint |
| - // or forget in a form. |
| - r.Use(csrf.Protect(origin)) |
| + r.Use(csrf.Protect(rc.Origin)) |
| r.Get("/", a.index) |
| mux := http.NewServeMux() |
| - // The app serves its own static files — the framework never does. |
| - // They are embedded (render.go) and fingerprinted: templates link |
| - // them via {{asset ...}}, which returns a content-hashed URL the |
| - // handler serves cacheable-forever. |
| mux.Handle("GET /static/", assets.Handler()) |
| mux.Handle("/", r) |
| return mux, nil |
diff --git a/internal/meet/config.go b/internal/meet/config.go| new file mode 100644 |
| index 0000000..7ae39f7 |
| --- /dev/null |
| +++ b/internal/meet/config.go |
| @@ -0,0 +1,134 @@ |
| +package meet |
| + |
| +import ( |
| + "encoding/base64" |
| + "fmt" |
| + "log/slog" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/mail" |
| +) |
| + |
| +// EnvDevelopment is the one Env value that relaxes startup refusals. |
| +// Only the -env flag can select it (cmd/meet/main.go); an environment |
| +// variable cannot, so a misconfigured production box fails loudly |
| +// rather than quietly running with a zero key. |
| +const EnvDevelopment = "development" |
| + |
| +// Config is everything the app needs to start. Every field that is a |
| +// secret or a boundary is a startup refusal when it is missing outside |
| +// development, per spec §7.1. |
| +type Config struct { |
| + // Env is "development" or anything else (treated as production). |
| + Env string |
| + // Origin is the app's external origin, scheme included |
| + // ("https://meet.oficina.works"). MEET_ORIGIN. |
| + Origin string |
| + // InstanceKey seals rastrillo/auth's pending blob and signs the |
| + // pending-join cookie. MEET_INSTANCE_KEY. |
| + InstanceKey string |
| + // DEK is the base64 data encryption key, exactly 32 bytes decoded. |
| + // MEET_DEK, set with `carlos secrets set`. |
| + DEK string |
| + // AllowedAddresses is the team: the addresses that are members. |
| + // MEET_ALLOWED_ADDRESSES, comma-separated. |
| + AllowedAddresses []string |
| + // Mailer delivers magic links. Nil outside development is refused; |
| + // nil in development logs the link instead of sending it. |
| + Mailer mail.Sender |
| + // RelayConfigured is whether CARLOS_RELAY is present. Slice 1 only |
| + // reads its presence for the holding page's floor sentence; slice 3 |
| + // parses it. |
| + RelayConfigured bool |
| + // Zone is the IANA zone start times are entered and shown in. |
| + // MEET_ZONE, default UTC. Per-person zones are deliberately not in |
| + // version one. |
| + Zone string |
| + Logger *slog.Logger |
| +} |
| + |
| +func (cfg Config) development() bool { return cfg.Env == EnvDevelopment } |
| + |
| +const devInstanceKey = "meet-development-instance-key-not-a-secret" |
| + |
| +var devDEK = make([]byte, 32) |
| + |
| +// resolved is Config after every refusal has been checked and every |
| +// default applied. |
| +type resolved struct { |
| + Config |
| + instanceKey string |
| + dek []byte |
| + mailer mail.Sender |
| + allow allowlist |
| + zone *time.Location |
| +} |
| + |
| +func resolve(cfg Config) (resolved, error) { |
| + if cfg.Logger == nil { |
| + cfg.Logger = slog.Default() |
| + } |
| + out := resolved{Config: cfg} |
| + |
| + if strings.TrimSpace(cfg.Origin) == "" { |
| + if !cfg.development() { |
| + return out, fmt.Errorf("meet: Config.Origin is empty and Env is %q, not %q; set $MEET_ORIGIN", cfg.Env, EnvDevelopment) |
| + } |
| + out.Origin = "http://localhost:8080" |
| + } |
| + |
| + key := strings.TrimSpace(cfg.InstanceKey) |
| + switch { |
| + case key != "": |
| + out.instanceKey = key |
| + case cfg.development(): |
| + out.instanceKey = devInstanceKey |
| + default: |
| + return out, fmt.Errorf("meet: Config.InstanceKey is empty and Env is %q, not %q; refusing to start with no seal for the sign-in link and the join cookie; set $MEET_INSTANCE_KEY", cfg.Env, EnvDevelopment) |
| + } |
| + |
| + raw := strings.TrimSpace(cfg.DEK) |
| + switch { |
| + case raw == "" && cfg.development(): |
| + out.dek = devDEK |
| + case raw == "": |
| + return out, fmt.Errorf("meet: Config.DEK is empty and Env is %q, not %q; a meeting's title is sealed at rest and a boot without a key would write plaintext into a replicated file; set $MEET_DEK to 32 base64 bytes with `carlos secrets set`", cfg.Env, EnvDevelopment) |
| + default: |
| + b, err := base64.StdEncoding.DecodeString(raw) |
| + if err != nil { |
| + return out, fmt.Errorf("meet: $MEET_DEK is not valid base64: %w", err) |
| + } |
| + if len(b) != 32 { |
| + return out, fmt.Errorf("meet: $MEET_DEK decodes to %d bytes, want exactly 32", len(b)) |
| + } |
| + out.dek = b |
| + } |
| + |
| + switch { |
| + case cfg.Mailer != nil: |
| + out.mailer = cfg.Mailer |
| + case cfg.development(): |
| + out.mailer = mail.Logged(cfg.Logger) |
| + default: |
| + return out, fmt.Errorf("meet: mail is not configured (set $MEET_SMTP_HOST and $MEET_SMTP_FROM) and Env is %q, not %q; a sign-in that cannot send its link is not sign-in", cfg.Env, EnvDevelopment) |
| + } |
| + |
| + list, err := resolveAllowlist(cfg) |
| + if err != nil { |
| + return out, err |
| + } |
| + out.allow = list |
| + |
| + zone := strings.TrimSpace(cfg.Zone) |
| + if zone == "" { |
| + zone = "UTC" |
| + } |
| + loc, err := time.LoadLocation(zone) |
| + if err != nil { |
| + return out, fmt.Errorf("meet: $MEET_ZONE %q is not an IANA zone: %w", zone, err) |
| + } |
| + out.zone = loc |
| + out.Zone = zone |
| + return out, nil |
| +} |
diff --git a/internal/meet/handlers.go b/internal/meet/handlers.go| index 4fec997..e44e7ad 100644 |
| --- a/internal/meet/handlers.go |
| +++ b/internal/meet/handlers.go |
| @@ -1,18 +1,6 @@ |
| package meet |
| -import ( |
| - "log/slog" |
| - "net/http" |
| - |
| - "amadan.net/rastrillo/rastrillo/db" |
| -) |
| - |
| -// 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 |
| - logger *slog.Logger |
| -} |
| +import "net/http" |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| render(w, "index", nil) |
diff --git a/internal/meet/seal/fingerprint.go b/internal/meet/seal/fingerprint.go| new file mode 100644 |
| index 0000000..2305168 |
| --- /dev/null |
| +++ b/internal/meet/seal/fingerprint.go |
| @@ -0,0 +1,20 @@ |
| +package seal |
| + |
| +import ( |
| + "crypto/hmac" |
| + "encoding/hex" |
| +) |
| + |
| +// Fingerprint is a keyed, stable digest: equal values give equal |
| +// fingerprints and nothing else can be learned from one. An address is |
| +// enumerable, so a plain hash of it is the address in another alphabet; |
| +// HMAC under a purpose key is not. |
| +func (s *Sealer) Fingerprint(purpose, value string) (string, error) { |
| + key, err := s.purposeKey(purpose) |
| + if err != nil { |
| + return "", err |
| + } |
| + mac := hmac.New(sha256New, key) |
| + mac.Write([]byte(value)) |
| + return hex.EncodeToString(mac.Sum(nil)), nil |
| +} |
diff --git a/internal/meet/seal/seal.go b/internal/meet/seal/seal.go| new file mode 100644 |
| index 0000000..bc0a9a3 |
| --- /dev/null |
| +++ b/internal/meet/seal/seal.go |
| @@ -0,0 +1,113 @@ |
| +// Package seal is the app's only encryption surface. |
| +// |
| +// The one rule (spec §0.1): a meeting's title is sealed at rest, and |
| +// the database never holds a plaintext title or an outsider's address. |
| +// The DEK arrives as platform config and is never written to the |
| +// database it decrypts. |
| +// |
| +// Lifted from calendar's seal package, itself lifted from sheets', with |
| +// only the label changed. Calendar names the third app as the trigger |
| +// to extract this into estilo or rastrillo; Meet is that app, and the |
| +// extraction is a task on the plan, not a change here. |
| +package seal |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "errors" |
| + "hash" |
| + "io" |
| + |
| + "golang.org/x/crypto/hkdf" |
| +) |
| + |
| +// label is mixed into every derived key. Changing it makes every |
| +// existing sealed value unreadable, so it is a constant, not config. |
| +const label = "oficina/meet:" |
| + |
| +const ( |
| + PurposeMeetingTitle = "meeting.title" |
| + PurposePendingAddress = "pending.address" |
| +) |
| + |
| +func sha256New() hash.Hash { return sha256.New() } |
| + |
| +type Sealer struct{ dek []byte } |
| + |
| +// New refuses anything but a 32-byte key and keeps its own copy. |
| +func New(dek []byte) (*Sealer, error) { |
| + if len(dek) != 32 { |
| + return nil, errors.New("seal: the DEK must be exactly 32 bytes") |
| + } |
| + k := make([]byte, 32) |
| + copy(k, dek) |
| + return &Sealer{dek: k}, nil |
| +} |
| + |
| +func (s *Sealer) purposeKey(purpose string) ([]byte, error) { |
| + key := make([]byte, 32) |
| + r := hkdf.New(sha256New, s.dek, nil, []byte(label+purpose)) |
| + if _, err := io.ReadFull(r, key); err != nil { |
| + return nil, err |
| + } |
| + return key, nil |
| +} |
| + |
| +func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) { |
| + key, err := s.purposeKey(purpose) |
| + if err != nil { |
| + return nil, err |
| + } |
| + block, err := aes.NewCipher(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return cipher.NewGCM(block) |
| +} |
| + |
| +// Seal returns nonce || ciphertext. |
| +func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) { |
| + aead, err := s.aeadFor(purpose) |
| + if err != nil { |
| + return nil, err |
| + } |
| + nonce := make([]byte, aead.NonceSize()) |
| + if _, err := rand.Read(nonce); err != nil { |
| + return nil, err |
| + } |
| + return aead.Seal(nonce, nonce, plaintext, nil), nil |
| +} |
| + |
| +func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) { |
| + aead, err := s.aeadFor(purpose) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(ct) < aead.NonceSize() { |
| + return nil, errors.New("seal: ciphertext too short") |
| + } |
| + nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():] |
| + return aead.Open(nil, nonce, body, nil) |
| +} |
| + |
| +// SealString seals a string; an empty string seals to nil so an |
| +// optional column stays NULL. |
| +func (s *Sealer) SealString(purpose, plaintext string) ([]byte, error) { |
| + if plaintext == "" { |
| + return nil, nil |
| + } |
| + return s.Seal(purpose, []byte(plaintext)) |
| +} |
| + |
| +func (s *Sealer) OpenString(purpose string, ct []byte) (string, error) { |
| + if len(ct) == 0 { |
| + return "", nil |
| + } |
| + b, err := s.Open(purpose, ct) |
| + if err != nil { |
| + return "", err |
| + } |
| + return string(b), nil |
| +} |
diff --git a/internal/meet/seal/seal_test.go b/internal/meet/seal/seal_test.go| new file mode 100644 |
| index 0000000..616b3b9 |
| --- /dev/null |
| +++ b/internal/meet/seal/seal_test.go |
| @@ -0,0 +1,47 @@ |
| +package seal |
| + |
| +import ( |
| + "bytes" |
| + "testing" |
| +) |
| + |
| +func TestSealRoundTripsAndDiffersPerPurpose(t *testing.T) { |
| + s, err := New(bytes.Repeat([]byte{7}, 32)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ct, err := s.SealString(PurposeMeetingTitle, "Board meeting") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bytes.Contains(ct, []byte("Board")) { |
| + t.Fatal("ciphertext contains plaintext") |
| + } |
| + got, err := s.OpenString(PurposeMeetingTitle, ct) |
| + if err != nil || got != "Board meeting" { |
| + t.Fatalf("open: %q, %v", got, err) |
| + } |
| + if _, err := s.OpenString(PurposePendingAddress, ct); err == nil { |
| + t.Fatal("a ciphertext opened under the wrong purpose") |
| + } |
| +} |
| + |
| +func TestFingerprintIsStableAndKeyed(t *testing.T) { |
| + a, _ := New(bytes.Repeat([]byte{1}, 32)) |
| + b, _ := New(bytes.Repeat([]byte{2}, 32)) |
| + x1, _ := a.Fingerprint(PurposePendingAddress, "x@example.com") |
| + x2, _ := a.Fingerprint(PurposePendingAddress, "x@example.com") |
| + y, _ := b.Fingerprint(PurposePendingAddress, "x@example.com") |
| + if x1 != x2 { |
| + t.Fatal("fingerprint is not stable") |
| + } |
| + if x1 == y { |
| + t.Fatal("fingerprint does not depend on the key") |
| + } |
| +} |
| + |
| +func TestNewRefusesWrongKeyLength(t *testing.T) { |
| + if _, err := New([]byte("short")); err == nil { |
| + t.Fatal("a 5-byte key was accepted") |
| + } |
| +} |
diff --git a/internal/meet/standing.go b/internal/meet/standing.go| new file mode 100644 |
| index 0000000..d060486 |
| --- /dev/null |
| +++ b/internal/meet/standing.go |
| @@ -0,0 +1,43 @@ |
| +package meet |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| + |
| +func normalizeAddress(address string) string { |
| + return strings.ToLower(strings.TrimSpace(address)) |
| +} |
| + |
| +// allowlist is the team in lone shape (spec §7.1): the addresses that |
| +// are members. A nil map admits everyone, which is allowed only in |
| +// development. |
| +type allowlist struct { |
| + addrs map[string]struct{} |
| +} |
| + |
| +func (a allowlist) allows(address string) bool { |
| + if a.addrs == nil { |
| + return true |
| + } |
| + _, ok := a.addrs[normalizeAddress(address)] |
| + return ok |
| +} |
| + |
| +func resolveAllowlist(cfg Config) (allowlist, error) { |
| + addrs := make(map[string]struct{}) |
| + for _, addr := range cfg.AllowedAddresses { |
| + addr = normalizeAddress(addr) |
| + if addr == "" { |
| + continue |
| + } |
| + addrs[addr] = struct{}{} |
| + } |
| + if len(addrs) > 0 { |
| + return allowlist{addrs: addrs}, nil |
| + } |
| + if cfg.development() { |
| + return allowlist{}, nil |
| + } |
| + return allowlist{}, fmt.Errorf("meet: $MEET_ALLOWED_ADDRESSES is empty and Env is %q, not %q; refusing to start with no team", cfg.Env, EnvDevelopment) |
| +} |
diff --git a/internal/meettest/config_test.go b/internal/meettest/config_test.go| new file mode 100644 |
| index 0000000..06eb888 |
| --- /dev/null |
| +++ b/internal/meettest/config_test.go |
| @@ -0,0 +1,55 @@ |
| +package meettest |
| + |
| +import ( |
| + "io" |
| + "log/slog" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| + |
| + "amadan.net/oficina/meet/internal/meet" |
| +) |
| + |
| +func openDB(t *testing.T) *db.DB { |
| + t.Helper() |
| + d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), slog.New(slog.NewTextHandler(io.Discard, nil))) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + return d |
| +} |
| + |
| +func TestProductionRefusesToStartWithoutSecrets(t *testing.T) { |
| + // validDEK is a valid 32-byte base64 DEK, used in the "no mailer" |
| + // and "no allowlist" cases so each refuses for its own named |
| + // reason rather than for a bad DEK. |
| + const validDEK = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" |
| + cases := map[string]meet.Config{ |
| + "no origin": {Env: "production"}, |
| + "no key": {Env: "production", Origin: "https://x.test"}, |
| + "no dek": {Env: "production", Origin: "https://x.test", InstanceKey: "k"}, |
| + "no mailer": {Env: "production", Origin: "https://x.test", InstanceKey: "k", DEK: validDEK}, |
| + "no allowlist": {Env: "production", Origin: "https://x.test", InstanceKey: "k", DEK: validDEK, Mailer: nopMailer{}}, |
| + } |
| + for name, cfg := range cases { |
| + if _, err := meet.App(openDB(t), cfg); err == nil { |
| + t.Errorf("%s: production started without it", name) |
| + } |
| + } |
| +} |
| + |
| +func TestDevelopmentStartsWithNothing(t *testing.T) { |
| + if _, err := meet.App(openDB(t), meet.Config{Env: meet.EnvDevelopment}); err != nil { |
| + t.Fatalf("development refused: %v", err) |
| + } |
| +} |
| + |
| +func TestBadDEKIsRefusedEvenInDevelopment(t *testing.T) { |
| + _, err := meet.App(openDB(t), meet.Config{Env: meet.EnvDevelopment, DEK: "not base64!"}) |
| + if err == nil || !strings.Contains(err.Error(), "base64") { |
| + t.Fatalf("want a base64 refusal, got %v", err) |
| + } |
| +} |
diff --git a/internal/meettest/harness_test.go b/internal/meettest/harness_test.go| index c45e9a4..e6a161b 100644 |
| --- a/internal/meettest/harness_test.go |
| +++ b/internal/meettest/harness_test.go |
| @@ -7,6 +7,7 @@ |
| package meettest |
| import ( |
| + "context" |
| "io" |
| "log/slog" |
| "net/http" |
| @@ -32,14 +33,14 @@ func newApp(t *testing.T) http.Handler { |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| if err != nil { |
| - t.Fatalf("db.Open: %v", err) |
| + t.Fatal(err) |
| } |
| t.Cleanup(func() { d.Close() }) |
| - mux, err := meet.App(d, testOrigin, logger) |
| + h, err := meet.App(d, meet.Config{Env: meet.EnvDevelopment, Origin: testOrigin, Logger: logger}) |
| if err != nil { |
| - t.Fatalf("App: %v", err) |
| + t.Fatal(err) |
| } |
| - return mux |
| + return h |
| } |
| func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { |
| @@ -60,3 +61,7 @@ func post(t *testing.T, h http.Handler, target string, form url.Values) *httptes |
| h.ServeHTTP(rec, req) |
| return rec |
| } |
| + |
| +type nopMailer struct{} |
| + |
| +func (nopMailer) Send(ctx context.Context, to, subject, body string) error { return nil } |