oficina / meet Public

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>
Paul Campbell pushed by paul@keymail.dev aed8caea617655f95d075ea6abb723b30bc8ff9f parent f8a35ac
13 files changed, +518 −83
  • Makefile +25 −4
  • cmd/meet/main.go +25 −17
  • go.mod +7 −3
  • go.sum +14 −10
  • internal/meet/app.go +25 −32
  • internal/meet/config.go +134 −0
  • internal/meet/handlers.go +1 −13
  • internal/meet/seal/fingerprint.go +20 −0
  • internal/meet/seal/seal.go +113 −0
  • internal/meet/seal/seal_test.go +47 −0
  • internal/meet/standing.go +43 −0
  • internal/meettest/config_test.go +55 −0
  • internal/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 }