diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go index 4e47b92..e24d4d7 100644 --- a/cmd/server/boot_test.go +++ b/cmd/server/boot_test.go @@ -19,6 +19,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) @@ -59,6 +60,17 @@ func loadConfig(t *testing.T, keyFile string) config.Config { t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin") t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com") t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath) + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + vapidKeyPath := filepath.Join(dir, "vapid_private_key") + if err := os.WriteFile(vapidKeyPath, []byte(pair.PrivateKey+"\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(config.EnvVAPIDPublicKey, pair.PublicKey) + t.Setenv(config.EnvVAPIDPrivateKeyFile, vapidKeyPath) + t.Setenv(config.EnvVAPIDSubject, "mailto:owner@example.com") // MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value. t.Setenv(config.EnvDefaultTZ, "") if err := os.Unsetenv(config.EnvDefaultTZ); err != nil { diff --git a/cmd/server/main.go b/cmd/server/main.go index f1307ce..ed3f863 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -25,6 +25,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" @@ -138,6 +139,11 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } + pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err @@ -154,7 +160,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets()) if err != nil { return err } diff --git a/cmd/vapidgen/main.go b/cmd/vapidgen/main.go new file mode 100644 index 0000000..26b1e56 --- /dev/null +++ b/cmd/vapidgen/main.go @@ -0,0 +1,39 @@ +// Command vapidgen prints a new VAPID key pair for Web Push. The public key +// line goes into deploy/.env as is; the private key line goes, alone, into +// the secret file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. Both keys are +// unpadded base64url P-256 keys, the encoding config.Load validates. +// +// go run ./cmd/vapidgen +package main + +import ( + "fmt" + "io" + "os" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +func main() { + if len(os.Args) > 1 { + fmt.Fprintln(os.Stderr, "usage: vapidgen (takes no arguments)") + os.Exit(2) + } + if err := run(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// run writes a new key pair to w in four lines: a comment and the public +// key's environment line, then a comment and the private key alone. +func run(w io.Writer) error { + pair, err := vapid.Generate() + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "# Environment (deploy/.env):\n%s=%s\n# Private key: this next line alone is the file %s names.\n%s\n", + config.EnvVAPIDPublicKey, pair.PublicKey, config.EnvVAPIDPrivateKeyFile, pair.PrivateKey) + return err +} diff --git a/cmd/vapidgen/main_test.go b/cmd/vapidgen/main_test.go new file mode 100644 index 0000000..b2ab37f --- /dev/null +++ b/cmd/vapidgen/main_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +// TestRunPrintsAMatchingPair checks the output shape and that the printed +// private key line is a key config.Load accepts for the printed public key. +func TestRunPrintsAMatchingPair(t *testing.T) { + t.Parallel() + var out strings.Builder + if err := run(&out); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n") + if len(lines) != 4 || !strings.HasPrefix(lines[0], "# ") || !strings.HasPrefix(lines[2], "# ") { + t.Fatalf("output = %q, want comment, public key, comment, private key", out.String()) + } + public, ok := strings.CutPrefix(lines[1], config.EnvVAPIDPublicKey+"=") + if !ok { + t.Fatalf("public key line = %q, want %s=", lines[1], config.EnvVAPIDPublicKey) + } + pub, err := vapid.ParsePublicKey(public) + if err != nil { + t.Fatalf("printed public key: %v", err) + } + priv, err := vapid.ParsePrivateKey(lines[3]) + if err != nil { + t.Fatalf("printed private key: %v", err) + } + if err := vapid.CheckPair(pub, priv); err != nil { + t.Error(err) + } + + var second strings.Builder + if err := run(&second); err != nil { + t.Fatal(err) + } + if second.String() == out.String() { + t.Error("two runs printed the same key pair") + } +} diff --git a/deploy/.env.example b/deploy/.env.example index fc2b906..645d20f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -6,7 +6,8 @@ # # Secrets are never set here. They live in deploy/secrets/ (never committed), # one value per file, owned by the container user 10001:10001 with mode -# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's +# 0600: secret_key, oidc_client_secret, google_client_secret and +# vapid_private_key. SearXNG's # searxng.env is the exception: compose reads it itself, so it is owned by # the host account that runs compose, mode 0600. Creating them: CTXD project # mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets". @@ -57,6 +58,19 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin # URI is /api/v1/integrations/google/callback. MEALQUEST_GOOGLE_CLIENT_ID=.apps.googleusercontent.com +# --- Web Push (VAPID) ------------------------------------------------------ + +# Required: the service will not start without the public key, the subject +# and the private key file, or with a private key that does not belong to +# the public key. `go run ./cmd/vapidgen` prints a new pair: its +# MEALQUEST_VAPID_PUBLIC_KEY line goes here, its private key line alone +# into deploy/secrets/vapid_private_key. Both are unpadded base64url P-256 +# keys. Key pair: CTXD project mealquest, RUNBOOKS.MD "VAPID keys (Web +# Push)". Replacing the pair invalidates every browser's subscription. +MEALQUEST_VAPID_PUBLIC_KEY= +# The push services' contact for this sender: mailto:
or https://. +MEALQUEST_VAPID_SUBJECT=mailto: + # Optional session lifetimes. A session ends after IDLE unused and always MAX # after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h. MEALQUEST_SESSION_IDLE=720h @@ -72,18 +86,10 @@ MEALQUEST_SESSION_MAX=4320h # The Authentik provider's client secret. # MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret # The Google OAuth web client's secret. +# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key +# The VAPID private key printed by `go run ./cmd/vapidgen`. # MEALQUEST_SEARXNG_URL=http://searxng:8080 # The SearXNG recipe-search sidecar on the compose network. The Settings # screen pre-fills it as the SearXNG URL until one is saved; the saved # provider and base URL come from PATCH /api/v1/settings/recipe-search. # See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)". - -# --- Planned; not read by the service yet ------------------------------------- -# -# Added to deploy/compose.yaml and this file by the features that use them. -# -# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD -# "VAPID keys (Web Push)". -# MEALQUEST_VAPID_PUBLIC_KEY= -# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key -# MEALQUEST_VAPID_SUBJECT=mailto: diff --git a/deploy/compose.yaml b/deploy/compose.yaml index a85f106..e50ae5b 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -31,11 +31,13 @@ services: MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret + MEALQUEST_VAPID_PRIVATE_KEY_FILE: /run/secrets/vapid_private_key MEALQUEST_SEARXNG_URL: http://searxng:8080 secrets: - secret_key - oidc_client_secret - google_client_secret + - vapid_private_key volumes: - mealquest-data:/data ports: @@ -130,6 +132,8 @@ secrets: file: ./secrets/oidc_client_secret google_client_secret: file: ./secrets/google_client_secret + vapid_private_key: + file: ./secrets/vapid_private_key volumes: mealquest-data: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6522df2..5971111 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,6 +43,7 @@ headers, so sign-in works whichever peer the request arrives from. | Path | Purpose | | --- | --- | | `cmd/server` | Entry point; wiring only. | +| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. | | `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. | | `internal/server` | HTTP server, router, middleware. | | `internal/frontend` | Embedded PWA, SPA fallback. | @@ -53,6 +54,7 @@ headers, so sign-in works whichever peer the request arrives from. | `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. | | `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. | | `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. | +| `internal/vapid` | VAPID key generation and validation. | | `internal/jobs`, `internal/scheduler` | Background jobs and schedules. | | `migrations/` | SQL migrations (no goose or golang-migrate). | | `web/` | React PWA. | @@ -657,6 +659,31 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. - Delivery goes through `notification_outbox`. - Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming webhook. +- VAPID configuration (`MEALQUEST_VAPID_PUBLIC_KEY`, + `MEALQUEST_VAPID_PRIVATE_KEY_FILE`, `MEALQUEST_VAPID_SUBJECT`) is required + at boot, all three together. Both keys are unpadded base64url P-256 keys + (`internal/vapid`): the public key the 65-byte uncompressed point, the + private key the 32-byte scalar, read only from its file. Boot derives the + public point from the private key and refuses a pair that does not + match. The subject is `mailto:
` or an `https://` URL. + `go run ./cmd/vapidgen` prints a new pair. +- `internal/push` keeps the subscriptions, one row per endpoint, under the + signed-in `/api/v1`: + - `GET /api/v1/push/vapid-public-key` answers `{"public_key": "…"}`, the + browser's `applicationServerKey`. The private key is never served. + - `POST /api/v1/push/subscriptions` (CSRF-checked) takes + `{"endpoint", "keys": {"p256dh", "auth"}, "user_agent"?}` — the + endpoint an `https://` URL, the keys unpadded base64url as + `PushSubscription.toJSON()` gives them (p256dh a P-256 point, auth 16 + bytes); unknown fields such as `expirationTime`, and nulls, are + refused. It upserts by endpoint: an endpoint already stored keeps its + id and `created_at`, has its keys and user agent replaced and its + failure count cleared. It answers 200 with + `{id, endpoint, user_agent, created_at, updated_at}`, never the keys. + - `DELETE /api/v1/push/subscriptions` (CSRF-checked) takes + `{"endpoint"}` and answers 204 whether or not it was stored. + - An invalid body is 400 `invalid_request` (not a JSON object of the + known fields) or `invalid_subscription` (a field fails validation). ## Google Calendar diff --git a/internal/config/config.go b/internal/config/config.go index ff2ecce..1286b5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,9 +3,11 @@ package config import ( + "crypto/ecdh" "errors" "fmt" "net" + "net/mail" "net/url" "os" "strconv" @@ -14,6 +16,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" ) // Environment variable names. @@ -32,6 +35,10 @@ const ( EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID" EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE" + EnvVAPIDPublicKey = "MEALQUEST_VAPID_PUBLIC_KEY" + EnvVAPIDPrivateKeyFile = "MEALQUEST_VAPID_PRIVATE_KEY_FILE" + EnvVAPIDSubject = "MEALQUEST_VAPID_SUBJECT" + EnvSessionIdle = "MEALQUEST_SESSION_IDLE" EnvSessionMax = "MEALQUEST_SESSION_MAX" @@ -70,6 +77,9 @@ type Config struct { GoogleClientID string GoogleClientSecret Secret + // VAPID is the key pair and contact subject Web Push signs with. + VAPID VAPID + // SessionIdle ends a session left unused this long. SessionIdle time.Duration // SessionMax ends every session this long after sign-in, used or not. @@ -93,6 +103,20 @@ type Config struct { SearXNGURL string } +// VAPID is the Web Push signing identity (RFC 8292). All three variables +// are required together, and the private key must belong to the public key. +type VAPID struct { + // PublicKey is the unpadded base64url uncompressed P-256 point, as + // browsers take it for applicationServerKey. + PublicKey string + // PrivateKey is the unpadded base64url 32-byte P-256 scalar, read from + // the file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. + PrivateKey Secret + // Subject is the push services' contact for this sender: a mailto: URI + // or an https:// URL. + Subject string +} + // PublicOrigin returns the scheme and host of PublicURL, the only parts of it // that are safe to log. func (c Config) PublicOrigin() string { @@ -122,6 +146,8 @@ func load(lookup func(string) (string, bool)) (Config, error) { GoogleClientID: r.str(EnvGoogleClientID), GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile), + VAPID: r.vapid(), + SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle), SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax), @@ -387,6 +413,61 @@ func (r *reader) secretFile(name string) Secret { return Secret{value: value} } +// vapid reads the VAPID public key, private key file and subject, each +// required, and checks the private key against the public key by deriving +// its public point. A missing variable is reported by name; the private key +// is never echoed. +func (r *reader) vapid() VAPID { + cfg := VAPID{ + PublicKey: r.str(EnvVAPIDPublicKey), + PrivateKey: r.secretFile(EnvVAPIDPrivateKeyFile), + Subject: r.vapidSubject(EnvVAPIDSubject), + } + var public *ecdh.PublicKey + if cfg.PublicKey != "" { + k, err := vapid.ParsePublicKey(cfg.PublicKey) + if err != nil { + r.fail(EnvVAPIDPublicKey, "invalid VAPID public key: %v", err) + } + public = k + } + var private *ecdh.PrivateKey + if cfg.PrivateKey.Reveal() != "" { + k, err := vapid.ParsePrivateKey(cfg.PrivateKey.Reveal()) + if err != nil { + r.fail(EnvVAPIDPrivateKeyFile, "invalid VAPID private key in secret file: %v", err) + } + private = k + } + if public != nil && private != nil && vapid.CheckPair(public, private) != nil { + r.fail(EnvVAPIDPrivateKeyFile, "VAPID private key does not match %s", EnvVAPIDPublicKey) + } + return cfg +} + +// vapidSubject reads a VAPID subject: mailto: followed by one email +// address, or an absolute https:// URL. +func (r *reader) vapidSubject(name string) string { + v, ok := r.required(name) + if !ok { + return "" + } + if addr, found := strings.CutPrefix(v, "mailto:"); found { + parsed, err := mail.ParseAddress(addr) + if err != nil || parsed.Address != addr { + r.fail(name, "invalid mailto: subject (want mailto:)") + return "" + } + return v + } + u, err := url.Parse(v) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + r.fail(name, "invalid subject (want mailto: or an https:// URL)") + return "" + } + return v +} + func unwrapPathError(err error) error { var pe *os.PathError if errors.As(err, &pe) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 297bac6..1aac073 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -14,8 +14,24 @@ import ( "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" ) +// writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as +// cmd/vapidgen tells the operator to, and returns the pair. +func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) { + t.Helper() + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil { + t.Fatal(err) + } + return pair, path +} + func TestLoad(t *testing.T) { t.Parallel() @@ -37,6 +53,16 @@ func TestLoad(t *testing.T) { if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil { t.Fatal(err) } + vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key") + otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key") + pemKeyPath := filepath.Join(dir, "vapid_private_key.pem") + if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil { + t.Fatal(err) + } + paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key") + if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil { + t.Fatal(err) + } valid := func() map[string]string { return map[string]string{ @@ -53,6 +79,10 @@ func TestLoad(t *testing.T) { EnvGoogleClientID: "mealquest.apps.googleusercontent.com", EnvGoogleClientSecretFile: googleSecretPath, + + EnvVAPIDPublicKey: vapidPair.PublicKey, + EnvVAPIDPrivateKeyFile: vapidKeyPath, + EnvVAPIDSubject: "mailto:owner@example.com", } } with := func(changes map[string]string, unset ...string) map[string]string { @@ -102,8 +132,108 @@ func TestLoad(t *testing.T) { EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile, EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups, EnvGoogleClientID, EnvGoogleClientSecretFile, + EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject, }, }, + { + name: "VAPID public key missing", + env: with(nil, EnvVAPIDPublicKey), + badVars: []string{EnvVAPIDPublicKey}, + }, + { + name: "VAPID private key file missing", + env: with(nil, EnvVAPIDPrivateKeyFile), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID subject missing", + env: with(nil, EnvVAPIDSubject), + badVars: []string{EnvVAPIDSubject}, + }, + { + name: "only the VAPID public key set", + env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject), + badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject}, + }, + { + name: "no VAPID variable set", + env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject), + badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject}, + }, + { + name: "VAPID private key file unreadable", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID private key file empty", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "inline VAPID private key rejected", + env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}), + badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"}, + }, + { + name: "VAPID private key as PEM", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "padded VAPID private key", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID private key from another pair", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID public key from another pair", + env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID public key malformed", + env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}), + badVars: []string{EnvVAPIDPublicKey}, + }, + { + name: "VAPID public key padded", + env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}), + badVars: []string{EnvVAPIDPublicKey}, + }, + { + name: "VAPID private key given as the public key", + env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}), + badVars: []string{EnvVAPIDPublicKey}, + }, + { + name: "VAPID https subject", + env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}), + }, + { + name: "VAPID subject without mailto:", + env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}), + badVars: []string{EnvVAPIDSubject}, + }, + { + name: "VAPID mailto: subject without an address", + env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}), + badVars: []string{EnvVAPIDSubject}, + }, + { + name: "VAPID mailto: subject with a display name", + env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner "}), + badVars: []string{EnvVAPIDSubject}, + }, + { + name: "VAPID plain http subject", + env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}), + badVars: []string{EnvVAPIDSubject}, + }, { name: "invalid duration", env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}), @@ -446,6 +576,7 @@ func TestLoadValues(t *testing.T) { if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil { t.Fatal(err) } + vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key") env := map[string]string{ EnvHTTPAddr: "127.0.0.1:9000", EnvPublicURL: "http://localhost:9000", @@ -461,6 +592,10 @@ func TestLoadValues(t *testing.T) { EnvGoogleClientID: "mealquest.apps.googleusercontent.com", EnvGoogleClientSecretFile: googleSecretPath, + EnvVAPIDPublicKey: vapidPair.PublicKey, + EnvVAPIDPrivateKeyFile: vapidKeyPath, + EnvVAPIDSubject: "mailto:owner@example.com", + EnvDefaultTZ: "Europe/London", EnvSearXNGURL: "http://searxng:8080", @@ -500,6 +635,9 @@ func TestLoadValues(t *testing.T) { if cfg.GoogleClientSecret.Reveal() != "google-s3cret" { t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal()) } + if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want { + t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID) + } if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) { t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want) } @@ -538,7 +676,7 @@ func TestSecretRedacted(t *testing.T) { s := Secret{value: "s3cret-value"} for _, format := range []string{"%v", "%s", "%+v", "%#v"} { - cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s} + cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}} for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} { if strings.Contains(out, "s3cret-value") { t.Errorf("format %s leaked secret: %s", format, out) @@ -547,6 +685,38 @@ func TestSecretRedacted(t *testing.T) { } } +// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key +// problem names the variable without echoing the file's contents. +func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + pair, _ := writeVAPIDKey(t, dir, "unused") + other, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + for name, contents := range map[string]string{ + "mismatched": other.PrivateKey, + "padded": pair.PrivateKey + "=", + "truncated": pair.PrivateKey[:40], + } { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil { + t.Fatal(err) + } + env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"} + _, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) + var cerr *Error + if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) { + t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile) + } + if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) { + t.Errorf("%s: error quotes the private key: %v", name, err) + } + } +} + func TestPublicURLWithCredentialsRejected(t *testing.T) { t.Parallel() diff --git a/internal/db/push_subscriptions.sql.go b/internal/db/push_subscriptions.sql.go new file mode 100644 index 0000000..5b8ac62 --- /dev/null +++ b/internal/db/push_subscriptions.sql.go @@ -0,0 +1,77 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: push_subscriptions.sql + +package db + +import ( + "context" + "database/sql" +) + +const deletePushSubscription = `-- name: DeletePushSubscription :execrows +DELETE FROM push_subscriptions +WHERE endpoint = ? +` + +func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) { + result, err := q.db.ExecContext(ctx, deletePushSubscription, endpoint) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const upsertPushSubscription = `-- name: UpsertPushSubscription :one +INSERT INTO push_subscriptions ( + id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, 0, ?, ? +) +ON CONFLICT (endpoint) DO UPDATE SET + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + failure_count = 0, + updated_at = excluded.updated_at +RETURNING id, endpoint, p256dh, auth, user_agent, failure_count, last_success_at, created_at, updated_at +` + +type UpsertPushSubscriptionParams struct { + ID string + Endpoint string + P256dh string + Auth string + UserAgent sql.NullString + CreatedAt string + UpdatedAt string +} + +// Stores a browser's push subscription, keyed by its endpoint. Subscribing +// an endpoint already stored replaces its keys and user agent and clears +// its failure count, keeping its id, created_at and last_success_at. +func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error) { + row := q.db.QueryRowContext(ctx, upsertPushSubscription, + arg.ID, + arg.Endpoint, + arg.P256dh, + arg.Auth, + arg.UserAgent, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i PushSubscription + err := row.Scan( + &i.ID, + &i.Endpoint, + &i.P256dh, + &i.Auth, + &i.UserAgent, + &i.FailureCount, + &i.LastSuccessAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/querier.go b/internal/db/querier.go index 00e2e22..5c2ff44 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -62,6 +62,7 @@ type Querier interface { DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error) DeleteIntegrationsGoogle(ctx context.Context) error + DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) DeleteRecipeIngredients(ctx context.Context, recipeID string) error DeleteRecipeSteps(ctx context.Context, recipeID string) error DeleteShoppingGroup(ctx context.Context, id string) error @@ -276,6 +277,10 @@ type Querier interface { // Creates the single row (id 1) or replaces every column except id and // created_at. UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) + // Stores a browser's push subscription, keyed by its endpoint. Subscribing + // an endpoint already stored replaces its keys and user agent and clears + // its failure count, keeping its id, created_at and last_success_at. + UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error) // A recipe is keyed by source_url: re-fetching the same URL updates the // existing row and keeps its id and created_at, which the returned row // carries. Replace its ingredients and steps in the same transaction with diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..1daaed1 --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,203 @@ +// Package push keeps the household's Web Push subscriptions, one per +// browser, keyed by the push service endpoint the browser was given, and +// the VAPID public key browsers subscribe with. +package push + +import ( + "context" + "crypto/ecdh" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +// Limits on a subscription's fields. +const ( + MaxEndpointBytes = 2048 + MaxUserAgentBytes = 512 + p256dhSize = 65 // uncompressed P-256 point + authSize = 16 +) + +// keyEncoding is the unpadded base64url PushSubscription.toJSON() gives +// the keys in. +var keyEncoding = base64.RawURLEncoding + +// Subscription is a stored push subscription. Its keys are never returned: +// only the sender needs them. +type Subscription struct { + ID string `json:"id"` + Endpoint string `json:"endpoint"` + UserAgent *string `json:"user_agent"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// SubscribeRequest is a browser's push subscription: the endpoint and keys +// of its PushSubscription, and optionally a user agent to tell the +// household's devices apart. +type SubscribeRequest struct { + Endpoint string + P256dh string + Auth string + UserAgent *string +} + +// Problem is one invalid field of a request, named by its JSON key. +type Problem struct { + Field string + Reason string +} + +// ValidationError lists every invalid field of a request. +type ValidationError struct { + Problems []Problem +} + +func (e *ValidationError) Error() string { + parts := make([]string, len(e.Problems)) + for i, p := range e.Problems { + parts[i] = p.Field + ": " + p.Reason + } + return "invalid push subscription: " + strings.Join(parts, "; ") +} + +// Service stores push subscriptions. +type Service struct { + st *store.Store + publicKey string +} + +// NewService returns a Service over st handing out publicKey, the VAPID +// public key in vapid's encoding. +func NewService(st *store.Store, publicKey string) (*Service, error) { + if st == nil { + return nil, errors.New("push: nil store") + } + if _, err := vapid.ParsePublicKey(publicKey); err != nil { + return nil, fmt.Errorf("push: VAPID public key: %w", err) + } + return &Service{st: st, publicKey: publicKey}, nil +} + +// PublicKey returns the VAPID public key, for applicationServerKey. +func (s *Service) PublicKey() string { return s.publicKey } + +// Subscribe stores req by its endpoint. An endpoint already stored keeps +// its id and created_at and has its keys and user agent replaced, so +// subscribing twice leaves one subscription. An invalid request is a +// *ValidationError. +func (s *Service) Subscribe(ctx context.Context, req SubscribeRequest) (Subscription, error) { + var problems []Problem + if reason := checkEndpoint(req.Endpoint); reason != "" { + problems = append(problems, Problem{"endpoint", reason}) + } + if reason := checkP256dh(req.P256dh); reason != "" { + problems = append(problems, Problem{"keys.p256dh", reason}) + } + if reason := checkAuth(req.Auth); reason != "" { + problems = append(problems, Problem{"keys.auth", reason}) + } + var ua sql.NullString + if req.UserAgent != nil { + if reason := checkUserAgent(*req.UserAgent); reason != "" { + problems = append(problems, Problem{"user_agent", reason}) + } + ua = sql.NullString{String: *req.UserAgent, Valid: true} + } + if len(problems) > 0 { + return Subscription{}, &ValidationError{Problems: problems} + } + + now := store.Now().String() + row, err := db.New(s.st.Writer).UpsertPushSubscription(ctx, db.UpsertPushSubscriptionParams{ + ID: store.NewID(), + Endpoint: req.Endpoint, + P256dh: req.P256dh, + Auth: req.Auth, + UserAgent: ua, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return Subscription{}, fmt.Errorf("push: store subscription: %w", err) + } + sub := Subscription{ID: row.ID, Endpoint: row.Endpoint, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} + if row.UserAgent.Valid { + sub.UserAgent = &row.UserAgent.String + } + return sub, nil +} + +// Unsubscribe removes the subscription for endpoint and reports whether +// one was stored. An endpoint that is not stored is not an error: the +// browser may unsubscribe after a failed delivery already removed it. An +// invalid endpoint is a *ValidationError. +func (s *Service) Unsubscribe(ctx context.Context, endpoint string) (bool, error) { + if reason := checkEndpoint(endpoint); reason != "" { + return false, &ValidationError{Problems: []Problem{{"endpoint", reason}}} + } + n, err := db.New(s.st.Writer).DeletePushSubscription(ctx, endpoint) + if err != nil { + return false, fmt.Errorf("push: delete subscription: %w", err) + } + return n > 0, nil +} + +// checkEndpoint returns why endpoint is not a push service URL, or "". +// Push services are always reached over https. +func checkEndpoint(endpoint string) string { + if endpoint == "" { + return "required" + } + if len(endpoint) > MaxEndpointBytes { + return fmt.Sprintf("longer than %d bytes", MaxEndpointBytes) + } + u, err := url.Parse(endpoint) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" { + return "want an absolute https:// URL without credentials or fragment" + } + return "" +} + +func checkP256dh(s string) string { + if s == "" { + return "required" + } + b, err := keyEncoding.DecodeString(s) + if err != nil || len(b) != p256dhSize { + return fmt.Sprintf("want the %d-byte uncompressed P-256 point in unpadded base64url", p256dhSize) + } + if _, err := ecdh.P256().NewPublicKey(b); err != nil { + return "not a point on P-256" + } + return "" +} + +func checkAuth(s string) string { + if s == "" { + return "required" + } + b, err := keyEncoding.DecodeString(s) + if err != nil || len(b) != authSize { + return fmt.Sprintf("want %d bytes in unpadded base64url", authSize) + } + return "" +} + +func checkUserAgent(s string) string { + switch { + case strings.TrimSpace(s) == "": + return "must not be blank; omit it instead" + case len(s) > MaxUserAgentBytes: + return fmt.Sprintf("longer than %d bytes", MaxUserAgentBytes) + } + return "" +} diff --git a/internal/push/push_test.go b/internal/push/push_test.go new file mode 100644 index 0000000..a07eb05 --- /dev/null +++ b/internal/push/push_test.go @@ -0,0 +1,224 @@ +package push + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "errors" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const endpoint = "https://fcm.googleapis.com/fcm/send/abc123" + +func newTestService(t *testing.T) (*Service, *store.Store) { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + svc, err := NewService(st, pair.PublicKey) + if err != nil { + t.Fatal(err) + } + return svc, st +} + +// browserKeys returns a p256dh and auth as a browser's +// PushSubscription.toJSON() gives them. +func browserKeys(t *testing.T) (string, string) { + t.Helper() + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + auth := make([]byte, 16) + rand.Read(auth) + return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth) +} + +type storedRow struct { + id, p256dh, auth, createdAt, updatedAt string + ua *string + failures int +} + +func rows(t *testing.T, st *store.Store) map[string]storedRow { + t.Helper() + rs, err := st.Reader.Query(`SELECT endpoint, id, p256dh, auth, user_agent, failure_count, created_at, updated_at FROM push_subscriptions`) + if err != nil { + t.Fatal(err) + } + defer rs.Close() + got := map[string]storedRow{} + for rs.Next() { + var ep string + var r storedRow + if err := rs.Scan(&ep, &r.id, &r.p256dh, &r.auth, &r.ua, &r.failures, &r.createdAt, &r.updatedAt); err != nil { + t.Fatal(err) + } + got[ep] = r + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return got +} + +func TestNewServiceRefusesBadPublicKey(t *testing.T) { + t.Parallel() + _, st := newTestService(t) + if _, err := NewService(st, "not-a-key"); err == nil { + t.Error("NewService accepted a malformed public key") + } + if _, err := NewService(nil, ""); err == nil { + t.Error("NewService accepted a nil store") + } +} + +func TestSubscribeUpsertsByEndpoint(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + ctx := context.Background() + p1, a1 := browserKeys(t) + ua := "Mozilla/5.0 (iPad)" + + first, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua}) + if err != nil { + t.Fatal(err) + } + if first.ID == "" || first.Endpoint != endpoint || first.UserAgent == nil || *first.UserAgent != ua || first.CreatedAt != first.UpdatedAt { + t.Errorf("first Subscribe = %+v", first) + } + if _, err := st.Writer.Exec(`UPDATE push_subscriptions SET failure_count = 3`); err != nil { + t.Fatal(err) + } + + // The same endpoint again, with the same keys and then rotated ones, + // updates the one row. + again, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua}) + if err != nil { + t.Fatal(err) + } + p2, a2 := browserKeys(t) + rotated, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p2, Auth: a2}) + if err != nil { + t.Fatal(err) + } + for _, s := range []Subscription{again, rotated} { + if s.ID != first.ID || s.CreatedAt != first.CreatedAt { + t.Errorf("resubscribe = %+v, want id %s and created_at %s kept", s, first.ID, first.CreatedAt) + } + } + if rotated.UserAgent != nil { + t.Errorf("resubscribe without user_agent kept %q", *rotated.UserAgent) + } + got := rows(t, st) + if len(got) != 1 { + t.Fatalf("rows = %d, want 1: %+v", len(got), got) + } + r := got[endpoint] + if r.p256dh != p2 || r.auth != a2 || r.ua != nil || r.failures != 0 || r.updatedAt < r.createdAt { + t.Errorf("stored row = %+v, want rotated keys, no user agent, failures cleared", r) + } + + // A second browser is a second row. + other := endpoint + "-other" + if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: other, P256dh: p1, Auth: a1}); err != nil { + t.Fatal(err) + } + if got := rows(t, st); len(got) != 2 { + t.Errorf("rows after a second endpoint = %d, want 2", len(got)) + } +} + +func TestUnsubscribe(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + ctx := context.Background() + p, a := browserKeys(t) + for _, ep := range []string{endpoint, endpoint + "-keep"} { + if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: ep, P256dh: p, Auth: a}); err != nil { + t.Fatal(err) + } + } + + removed, err := svc.Unsubscribe(ctx, endpoint) + if err != nil || !removed { + t.Errorf("Unsubscribe = %v, %v; want true, nil", removed, err) + } + got := rows(t, st) + if _, ok := got[endpoint]; ok || len(got) != 1 { + t.Errorf("rows after Unsubscribe = %+v, want only the other endpoint", got) + } + removed, err = svc.Unsubscribe(ctx, endpoint) + if err != nil || removed { + t.Errorf("Unsubscribe of an endpoint not stored = %v, %v; want false, nil", removed, err) + } + var invalid *ValidationError + if _, err := svc.Unsubscribe(ctx, "http://push.example.com/x"); !errors.As(err, &invalid) { + t.Errorf("Unsubscribe of an http endpoint = %v, want *ValidationError", err) + } +} + +func TestSubscribeValidates(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + p, a := browserKeys(t) + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + blank, long := " ", strings.Repeat("x", MaxUserAgentBytes+1) + offCurve := make([]byte, 65) + offCurve[0] = 0x04 + raw := base64.RawURLEncoding.EncodeToString + + for _, tc := range []struct { + name string + req SubscribeRequest + field string + }{ + {"missing endpoint", SubscribeRequest{P256dh: p, Auth: a}, "endpoint"}, + {"http endpoint", SubscribeRequest{Endpoint: "http://push.example.com/x", P256dh: p, Auth: a}, "endpoint"}, + {"relative endpoint", SubscribeRequest{Endpoint: "/fcm/send/x", P256dh: p, Auth: a}, "endpoint"}, + {"endpoint with credentials", SubscribeRequest{Endpoint: "https://u:p@push.example.com/x", P256dh: p, Auth: a}, "endpoint"}, + {"endpoint too long", SubscribeRequest{Endpoint: "https://push.example.com/" + strings.Repeat("x", MaxEndpointBytes), P256dh: p, Auth: a}, "endpoint"}, + {"missing p256dh", SubscribeRequest{Endpoint: endpoint, Auth: a}, "keys.p256dh"}, + {"padded p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: p + "=", Auth: a}, "keys.p256dh"}, + {"short p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: a, Auth: a}, "keys.p256dh"}, + {"p256dh off the curve", SubscribeRequest{Endpoint: endpoint, P256dh: raw(offCurve), Auth: a}, "keys.p256dh"}, + {"private key as p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: pair.PrivateKey, Auth: a}, "keys.p256dh"}, + {"missing auth", SubscribeRequest{Endpoint: endpoint, P256dh: p}, "keys.auth"}, + {"long auth", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: raw(make([]byte, 32))}, "keys.auth"}, + {"blank user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &blank}, "user_agent"}, + {"long user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &long}, "user_agent"}, + } { + _, err := svc.Subscribe(context.Background(), tc.req) + var invalid *ValidationError + if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != tc.field { + t.Errorf("%s: Subscribe error = %v, want one problem for %s", tc.name, err, tc.field) + } + } + if got := rows(t, st); len(got) != 0 { + t.Errorf("invalid requests stored %d rows", len(got)) + } +} diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index a4d9ed7..18a3a2a 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), - newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 229b355..eb61c2f 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/google_test.go b/internal/server/google_test.go index bb9f7e4..5a87fd4 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv { } logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t), + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 9fa481a..8d326b4 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index c95c55a..eda9ff0 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index a141938..eac10b2 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 5d6ed74..88053e9 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -94,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/push.go b/internal/server/push.go new file mode 100644 index 0000000..c78f413 --- /dev/null +++ b/internal/server/push.go @@ -0,0 +1,98 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push" +) + +// maxPushBody bounds a POST or DELETE /push/subscriptions body: an endpoint +// of push.MaxEndpointBytes, the keys and a user agent of +// push.MaxUserAgentBytes, with room for JSON escaping. +const maxPushBody = 8 << 10 + +type vapidPublicKeyResponse struct { + PublicKey string `json:"public_key"` +} + +// pushSubscriptionBody is the POST /push/subscriptions body: the +// endpoint and keys of PushSubscription.toJSON(), without its +// expirationTime, and an optional user_agent. +type pushSubscriptionBody struct { + Endpoint string `json:"endpoint"` + Keys pushSubscriptionKeys `json:"keys"` + UserAgent *string `json:"user_agent"` +} + +type pushSubscriptionKeys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` +} + +// pushUnsubscribeBody is the DELETE /push/subscriptions body. +type pushUnsubscribeBody struct { + Endpoint string `json:"endpoint"` +} + +// getVAPIDPublicKey answers GET /push/vapid-public-key with the key +// browsers pass as applicationServerKey. The private key is never served. +func getVAPIDPublicKey(logger *slog.Logger, svc *push.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, r, logger, http.StatusOK, vapidPublicKeyResponse{PublicKey: svc.PublicKey()}) + } +} + +// postPushSubscription stores the browser's subscription by its endpoint +// and answers 200 with it, without its keys. Posting an endpoint already +// stored updates it. +func postPushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[pushSubscriptionBody](http.MaxBytesReader(w, r.Body, maxPushBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + sub, err := svc.Subscribe(r.Context(), push.SubscribeRequest{ + Endpoint: body.Endpoint, + P256dh: body.Keys.P256dh, + Auth: body.Keys.Auth, + UserAgent: body.UserAgent, + }) + if err != nil { + writePushError(w, r, logger, "store push subscription", err) + return + } + writeJSON(w, r, logger, http.StatusOK, sub) + } +} + +// deletePushSubscription removes the subscription for the body's endpoint +// and answers 204, whether or not it was stored. +func deletePushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[pushUnsubscribeBody](http.MaxBytesReader(w, r.Body, maxPushBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if _, err := svc.Unsubscribe(r.Context(), body.Endpoint); err != nil { + writePushError(w, r, logger, "delete push subscription", err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// writePushError maps a push.Service error to its API error: an invalid +// subscription is 400 invalid_subscription, anything else 500. +func writePushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + var invalid *push.ValidationError + if errors.As(err, &invalid) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_subscription", invalid.Error()) + return + } + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") +} diff --git a/internal/server/push_test.go b/internal/server/push_test.go new file mode 100644 index 0000000..961e199 --- /dev/null +++ b/internal/server/push_test.go @@ -0,0 +1,284 @@ +package server + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +const ( + vapidPublicKeyPath = "/api/v1/push/vapid-public-key" + pushSubscriptionsPath = "/api/v1/push/subscriptions" + testPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABk" +) + +// newTestPush returns a push service over a migrated temp database with a +// new VAPID key pair. +func newTestPush(t *testing.T) *push.Service { + t.Helper() + svc, _, _ := newTestPushWith(t) + return svc +} + +// newTestPushWith is newTestPush also returning its store and key pair. +func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) { + t.Helper() + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + st := newTestStore(t) + svc, err := push.NewService(st, pair.PublicKey) + if err != nil { + t.Fatal(err) + } + return svc, st, pair +} + +// newPushRouter returns a router serving svc. +func newPushRouter(t *testing.T, svc *push.Service) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestRunner(t), + newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return r +} + +// subscriptionJSON returns a POST /push/subscriptions body for endpoint +// with new browser keys. +func subscriptionJSON(t *testing.T, endpoint, userAgent string) string { + t.Helper() + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + auth := make([]byte, 16) + rand.Read(auth) + body := map[string]any{ + "endpoint": endpoint, + "keys": map[string]string{ + "p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), + "auth": base64.RawURLEncoding.EncodeToString(auth), + }, + } + if userAgent != "" { + body["user_agent"] = userAgent + } + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func countPushSubscriptions(t *testing.T, st *store.Store) int { + t.Helper() + var n int + if err := st.Reader.QueryRow(`SELECT count(*) FROM push_subscriptions`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func TestNewRouterRequiresPush(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil push service") + } +} + +func TestPushAPI(t *testing.T) { + t.Parallel() + svc, st, pair := newTestPushWith(t) + router := newPushRouter(t, svc) + var bodies []string + call := func(method, target, body string) *httptest.ResponseRecorder { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, signedIn(method, target, r)) + bodies = append(bodies, rec.Body.String()) + return rec + } + decode := func(rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + return m + } + + rec := call(http.MethodGet, vapidPublicKeyPath, "") + if body := decode(rec); rec.Code != http.StatusOK || len(body) != 1 || body["public_key"] != pair.PublicKey { + t.Errorf("GET public key = %d %s, want only the public key", rec.Code, rec.Body.String()) + } + + // Posting the same endpoint twice keeps one subscription, updated. + rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox on the kitchen tablet")) + first := decode(rec) + if rec.Code != http.StatusOK || first["endpoint"] != testPushEndpoint || first["user_agent"] != "Firefox on the kitchen tablet" || first["id"] == "" { + t.Errorf("POST subscription = %d %s", rec.Code, rec.Body.String()) + } + if _, ok := first["keys"]; ok { + t.Errorf("POST response holds the keys: %s", rec.Body.String()) + } + rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox 140")) + again := decode(rec) + if rec.Code != http.StatusOK || again["id"] != first["id"] || again["created_at"] != first["created_at"] || again["user_agent"] != "Firefox 140" { + t.Errorf("POST same endpoint = %d %s, want the first subscription updated", rec.Code, rec.Body.String()) + } + if n := countPushSubscriptions(t, st); n != 1 { + t.Errorf("subscriptions after posting one endpoint twice = %d, want 1", n) + } + rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint+"-phone", "")) + if body := decode(rec); rec.Code != http.StatusOK || body["user_agent"] != nil { + t.Errorf("POST second endpoint = %d %s", rec.Code, rec.Body.String()) + } + if n := countPushSubscriptions(t, st); n != 2 { + t.Errorf("subscriptions after a second endpoint = %d, want 2", n) + } + + for _, bad := range []struct{ body, code string }{ + {"", "invalid_request"}, + {`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"expirationTime":null}`, "invalid_request"}, + {`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"extra":1}`, "invalid_request"}, + {`{"endpoint":"` + testPushEndpoint + `"}`, "invalid_subscription"}, + {`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"}}`, "invalid_subscription"}, + {subscriptionJSON(t, "http://push.example.com/x", ""), "invalid_subscription"}, + {`{"endpoint":"https://push.example.com/` + strings.Repeat("x", maxPushBody) + `"}`, "invalid_request"}, + } { + rec := call(http.MethodPost, pushSubscriptionsPath, bad.body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"code":"`+bad.code+`"`) { + t.Errorf("POST %.80s = %d %s, want 400 %s", bad.body, rec.Code, rec.Body.String(), bad.code) + } + } + if n := countPushSubscriptions(t, st); n != 2 { + t.Errorf("subscriptions after invalid posts = %d, want 2", n) + } + + // DELETE removes by endpoint and is 204 whether or not it was stored. + for i := range 2 { + rec = call(http.MethodDelete, pushSubscriptionsPath, `{"endpoint":"`+testPushEndpoint+`"}`) + if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { + t.Errorf("DELETE #%d = %d %s, want 204", i+1, rec.Code, rec.Body.String()) + } + } + if n := countPushSubscriptions(t, st); n != 1 { + t.Errorf("subscriptions after DELETE = %d, want 1", n) + } + for _, bad := range []string{"", `{}`, `{"endpoint":"not a url"}`} { + rec := call(http.MethodDelete, pushSubscriptionsPath, bad) + if rec.Code != http.StatusBadRequest { + t.Errorf("DELETE %q = %d %s, want 400", bad, rec.Code, rec.Body.String()) + } + } + if n := countPushSubscriptions(t, st); n != 1 { + t.Errorf("subscriptions after invalid deletes = %d, want 1", n) + } + + // The private key never leaves the process, in any encoding. + priv, err := base64.RawURLEncoding.DecodeString(pair.PrivateKey) + if err != nil { + t.Fatal(err) + } + leaks := []string{ + pair.PrivateKey, + pair.PrivateKey[:16], + base64.StdEncoding.EncodeToString(priv), + base64.RawStdEncoding.EncodeToString(priv)[:16], + hex.EncodeToString(priv), + } + for _, b := range bodies { + for _, leak := range leaks { + if strings.Contains(b, leak) { + t.Errorf("response holds the VAPID private key: %s", b) + } + } + } +} + +func TestPushSubscriptionsNeedCSRF(t *testing.T) { + t.Parallel() + svc, st, _ := newTestPushWith(t) + router := newPushRouter(t, svc) + if _, err := svc.Subscribe(t.Context(), push.SubscribeRequest{Endpoint: testPushEndpoint + "-stored", P256dh: mustP256dh(t), Auth: "AAAAAAAAAAAAAAAAAAAAAA"}); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + method, body string + }{ + {http.MethodPost, subscriptionJSON(t, testPushEndpoint, "")}, + {http.MethodDelete, `{"endpoint":"` + testPushEndpoint + `-stored"}`}, + } { + for _, header := range []*string{nil, ptr(""), ptr("wrong-token")} { + req := httptest.NewRequest(tc.method, pushSubscriptionsPath, strings.NewReader(tc.body)) + req.AddCookie(validSessionCookie) + if header != nil { + req.Header.Set(CSRFHeader, *header) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON { + t.Errorf("%s without a valid CSRF token (%v) = %d %s, want 403 csrf", tc.method, header, rec.Code, rec.Body.String()) + } + } + } + if n := countPushSubscriptions(t, st); n != 1 { + t.Errorf("subscriptions after refused requests = %d, want the 1 stored before", n) + } + // GET needs no token. + req := httptest.NewRequest(http.MethodGet, vapidPublicKeyPath, nil) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("GET public key without CSRF token = %d, want 200", rec.Code) + } +} + +func TestPushNeedsSession(t *testing.T) { + t.Parallel() + router := newPushRouter(t, newTestPush(t)) + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { + target := pushSubscriptionsPath + if method == http.MethodGet { + target = vapidPublicKeyPath + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(method, target, nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s %s without a session = %d, want 401", method, target, rec.Code) + } + } +} + +func mustP256dh(t *testing.T) string { + t.Helper() + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()) +} diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 126a67e..9869c1e 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index 931c1e3..e0f4125 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,6 +18,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" @@ -55,12 +56,14 @@ type RouteMounter interface { // /api/v1/shopping/items/{id}; googleSvc serves GET // /api/v1/integrations/google and its /connect, /callback and /calendars, // PUT /api/v1/integrations/google/calendar and DELETE -// /api/v1/integrations/google; runner serves +// /api/v1/integrations/google; pushSvc serves GET +// /api/v1/push/vapid-public-key and POST and DELETE +// /api/v1/push/subscriptions; runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -85,6 +88,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if googleSvc == nil { return nil, errors.New("server: nil Google Calendar service") } + if pushSvc == nil { + return nil, errors.New("server: nil push service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -158,6 +164,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc)) apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc)) apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc)) + apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc)) + apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc)) + apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c735e54..70fee30 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -54,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -287,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -296,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -305,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -314,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -323,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } @@ -332,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil suggestions service") } } @@ -341,7 +341,7 @@ func TestNewRouterRequiresPlan(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil plan service") } } @@ -350,7 +350,7 @@ func TestNewRouterRequiresShopping(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil shopping service") } } @@ -359,7 +359,7 @@ func TestNewRouterRequiresGoogle(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Calendar service") } } @@ -368,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 45b6d7c..0498264 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index 7e4bb5a..78106d0 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/vapid/vapid.go b/internal/vapid/vapid.go new file mode 100644 index 0000000..2980cea --- /dev/null +++ b/internal/vapid/vapid.go @@ -0,0 +1,87 @@ +// Package vapid generates and validates the VAPID key pair Web Push signs +// with (RFC 8292): a P-256 (ES256) key pair in the encoding webpush-go +// reads. Both keys are unpadded base64url: the public key is the 65-byte +// uncompressed point and the private key the 32-byte scalar. +package vapid + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" +) + +// encoding is unpadded base64url. Strict refuses non-zero trailing bits, so +// every key has exactly one accepted spelling. +var encoding = base64.RawURLEncoding.Strict() + +const ( + publicKeySize = 65 // 0x04 || X || Y + privateKeySize = 32 +) + +// KeyPair is a VAPID key pair in its stored encoding. +type KeyPair struct { + PublicKey string + PrivateKey string +} + +// Generate returns a new random key pair. +func Generate() (KeyPair, error) { + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return KeyPair{}, fmt.Errorf("vapid: generate key: %w", err) + } + return KeyPair{ + PublicKey: encoding.EncodeToString(k.PublicKey().Bytes()), + PrivateKey: encoding.EncodeToString(k.Bytes()), + }, nil +} + +// ParsePublicKey parses an encoded public key, which must be a point on +// P-256. +func ParsePublicKey(s string) (*ecdh.PublicKey, error) { + b, err := encoding.DecodeString(s) + if err != nil { + return nil, errors.New("not unpadded base64url") + } + if len(b) != publicKeySize || b[0] != 0x04 { + return nil, fmt.Errorf("want the %d-byte uncompressed P-256 point, got %d bytes", publicKeySize, len(b)) + } + k, err := ecdh.P256().NewPublicKey(b) + if err != nil { + return nil, errors.New("not a point on P-256") + } + return k, nil +} + +// ParsePrivateKey parses an encoded private key. Its errors never quote the +// key. +func ParsePrivateKey(s string) (*ecdh.PrivateKey, error) { + b, err := encoding.DecodeString(s) + if err != nil { + return nil, errors.New("not unpadded base64url") + } + if len(b) != privateKeySize { + return nil, fmt.Errorf("want a %d-byte P-256 scalar, got %d bytes", privateKeySize, len(b)) + } + k, err := ecdh.P256().NewPrivateKey(b) + if err != nil { + return nil, errors.New("not a valid P-256 scalar") + } + return k, nil +} + +// ErrMismatch is returned by CheckPair when the private key's public key is +// not the given public key. +var ErrMismatch = errors.New("private key does not belong to the public key") + +// CheckPair reports whether private is the private key of public, by +// deriving the public point from private and comparing. +func CheckPair(public *ecdh.PublicKey, private *ecdh.PrivateKey) error { + if !private.PublicKey().Equal(public) { + return ErrMismatch + } + return nil +} diff --git a/internal/vapid/vapid_test.go b/internal/vapid/vapid_test.go new file mode 100644 index 0000000..770d199 --- /dev/null +++ b/internal/vapid/vapid_test.go @@ -0,0 +1,92 @@ +package vapid + +import ( + "encoding/base64" + "strings" + "testing" +) + +func TestGenerateRoundTrips(t *testing.T) { + t.Parallel() + a, err := Generate() + if err != nil { + t.Fatal(err) + } + b, err := Generate() + if err != nil { + t.Fatal(err) + } + if a == b { + t.Fatal("two generated key pairs are equal") + } + // 65 and 32 bytes in unpadded base64url. + if len(a.PublicKey) != 87 || len(a.PrivateKey) != 43 { + t.Errorf("key lengths = %d, %d; want 87, 43", len(a.PublicKey), len(a.PrivateKey)) + } + pub, err := ParsePublicKey(a.PublicKey) + if err != nil { + t.Fatal(err) + } + priv, err := ParsePrivateKey(a.PrivateKey) + if err != nil { + t.Fatal(err) + } + if err := CheckPair(pub, priv); err != nil { + t.Errorf("CheckPair of a generated pair: %v", err) + } + other, err := ParsePrivateKey(b.PrivateKey) + if err != nil { + t.Fatal(err) + } + if err := CheckPair(pub, other); err != ErrMismatch { + t.Errorf("CheckPair of mismatched keys = %v, want ErrMismatch", err) + } +} + +func TestParseRejectsMalformedKeys(t *testing.T) { + t.Parallel() + pair, err := Generate() + if err != nil { + t.Fatal(err) + } + raw := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + compressed, _ := base64.RawURLEncoding.DecodeString(pair.PublicKey) + compressed = append([]byte{0x02}, compressed[1:33]...) + offCurve := make([]byte, 65) + offCurve[0] = 0x04 + offCurve[64] = 1 + order := []byte{ // the P-256 group order n, not a valid scalar + 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51, + } + + for name, s := range map[string]string{ + "empty": "", + "padded": pair.PublicKey + "=", + "standard alpha": strings.NewReplacer("-", "+", "_", "/").Replace(pair.PublicKey) + "+/", + "compressed": raw(compressed), + "off curve": raw(offCurve), + "private key": pair.PrivateKey, + } { + if _, err := ParsePublicKey(s); err == nil { + t.Errorf("ParsePublicKey(%s) succeeded", name) + } + } + for name, s := range map[string]string{ + "empty": "", + "padded": pair.PrivateKey + "=", + "PEM": "-----BEGIN EC PRIVATE KEY-----", + "zero": raw(make([]byte, 32)), + "group size": raw(order), + "public key": pair.PublicKey, + } { + _, err := ParsePrivateKey(s) + if err == nil { + t.Errorf("ParsePrivateKey(%s) succeeded", name) + continue + } + if s != "" && strings.Contains(err.Error(), s) { + t.Errorf("ParsePrivateKey(%s) error quotes the key: %v", name, err) + } + } +} diff --git a/migrations/queries/push_subscriptions.sql b/migrations/queries/push_subscriptions.sql new file mode 100644 index 0000000..5042e46 --- /dev/null +++ b/migrations/queries/push_subscriptions.sql @@ -0,0 +1,20 @@ +-- name: UpsertPushSubscription :one +-- Stores a browser's push subscription, keyed by its endpoint. Subscribing +-- an endpoint already stored replaces its keys and user agent and clears +-- its failure count, keeping its id, created_at and last_success_at. +INSERT INTO push_subscriptions ( + id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, 0, ?, ? +) +ON CONFLICT (endpoint) DO UPDATE SET + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + failure_count = 0, + updated_at = excluded.updated_at +RETURNING *; + +-- name: DeletePushSubscription :execrows +DELETE FROM push_subscriptions +WHERE endpoint = ?;