diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 00000000000..3d6febe1b7f --- /dev/null +++ b/.beads/issues.jsonl @@ -0,0 +1,12 @@ +{"_type":"issue","id":"wire-server-5qh","title":"Web Push support for gundeck (RFC 8030/8291/8292)","description":"# Web Push support for gundeck (RFC 8030/8291/8292)\n\n## Goal\nExtend wire-server's push infrastructure to deliver notifications to **web browsers** via the W3C Push API, alongside the existing native (GCM/APNS) push that runs through AWS SNS.\n\n## Why it's a distinct subsystem\nWeb push is **architecturally decoupled from AWS SNS** — AWS SNS has no web-push platform. Instead gundeck acts as the RFC 8030 *application server*: it stores browser-supplied subscription endpoints and POSTs to them directly, encrypting the body per RFC 8291 (ECDH P-256 + HKDF + AES-128-GCM) and authenticating per RFC 8292 (VAPID JWT).\n\n```\n ┌──── native (unchanged) ────┐\npushAllLegacy ──┼─► pushNative ─► AWS SNS │\n │\n └─► pushWeb ─► HTTPS POST to browser push-service endpoint\n (RFC 8291 encrypt + RFC 8292 VAPID)\n```\n\n## Key design decisions (confirmed)\n- **Separate `WebPushSubscription` type + route** (not a new `Transport` variant on `PushToken`), because the subscription shape (endpoint URL + p256dh + auth) is structurally different from a native token, and dispatch cannot use SNS.\n- **Static VAPID key from config** (server's P-256 private key in gundeck settings; public key advertised to web clients out-of-band). Simplest, stable identifier.\n- **No new external dependencies**: crypto from `crypton` (already transitive via `saml2-web-sso`), JWT from `jose` (already in `wire-api`).\n\n## Relevant specs\n- W3C Push API — subscription object `{endpoint, keys:{p256dh,auth}, expirationTime}`\n- RFC 8030 — Web Push protocol (POST to endpoint, TTL/Urgency/Topic headers)\n- RFC 8291 — Message Encryption (aes128gcm content coding, 86-byte header)\n- RFC 8292 — VAPID (application server identification via ES256 JWT)\n\n## Layer map (children)\n1. WP-API — API types + routes (wire-api)\n2. WP-DB — Cassandra migration V13\n3. WP-CFG — Config (WebPushOpts + VAPID keypair in Env)\n4. WP-STORE — Storage layer (Gundeck/Push/Web/Data.hs)\n5. WP-CRYPTO — RFC 8291/8292 crypto module\n6. WP-HANDLERS — API handlers (add/delete/list)\n7. WP-DISPATCH — Web dispatch (HTTPS POST)\n8. WP-ROUTES — Route wiring (Gundeck/API/Public.hs)\n9. WP-FLOW — Hook into pushAllLegacy\n10. WP-TESTS — Golden + roundtrip + crypto KAT\n11. WP-QA — Quality gate (build/format/lint/nix)\n\n## Out of scope (v1)\n- Declarative push messages (W3C §3.3) — we send the same opaque notice payload as native.\n- Client-side service-worker code.\n- Migration tooling for existing native tokens.\n","status":"closed","priority":0,"issue_type":"epic","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:02:22Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-18T09:28:20Z","closed_at":"2026-06-18T09:28:20Z","close_reason":"All 11 children complete (100%). Web push (RFC 8030/8291/8292) fully integrated across wire-api, wire-subsystems, and gundeck. Final QA gate (wire-server-5qh.11) passed with zero drift: make c clean (-Werror), all unit suites green (wire-api 467, wire-subsystems 311, gundeck 123 incl. RFC 8291 crypto KAT), ormolu/hlint clean, nix aligned, no secrets in repo.","labels":["epic","web-push"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.10","title":"WP-TESTS: Golden + roundtrip + RFC 8291 crypto KAT + unit","description":"# Tests — Golden + roundtrip + crypto KAT + unit\n\n## Objective\nCover correctness of types, crypto (the security-critical part), and dispatch logic.\n\n## Files to create / change\n- **NEW** `libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/WebPushSubscription_user.hs` (mirror `PushToken_user.hs`)\n- **EDIT** `libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs` (register group, lines 156-157, 827-830)\n- **EDIT** `libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs` (add roundtrip near line 212)\n- **NEW** `services/gundeck/test/unit/Web/CryptoSpec.hs` (or project's test layout — check existing `services/gundeck/test/unit/Native.hs`)\n- **NEW** `services/gundeck/test/unit/Web/DispatchSpec.hs` (handler/dispatch logic via `MonadPushAll` abstraction)\n\n## Crypto KAT (MUST pass) — the core correctness proof\nEncrypt `\"When I grow up, I want to be a watermelon\"` using the RFC 8291 §5 / Appendix A test vectors and assert byte-identical ciphertext:\n\n```\nPlaintext: V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24\nua_public: BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4\nua_private: q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94\nas_public: BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8\nas_private: yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw\nauth_secret: BTBZMqHH6r4Tts7J_aSIgg\nsalt: DGv6ra1nlYgDCS1FRnbzlw\nExpected body: DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI... (RFC 8291 §5)\n```\nAlso assert intermediate values (ecdh_secret, IKM, CEK, NONCE) match Appendix A if practical.\n\n## Other unit tests\n- `signVapid` JWT decodes with the expected `aud`/`sub`/`exp` and verifies against the public key.\n- Invalid-curve `p256dh` rejected.\n- Dispatch: 201→success, 410→delete+onTokenRemoved, 413→too-large counter (use the `MonadPushAll` test double like `services/gundeck/test/unit/Native.hs` does).\n\n## Acceptance criteria\n- [ ] `make c package=wire-api test=1` green (golden + roundtrip).\n- [ ] `make c package=gundeck test=1` green (crypto KAT + dispatch unit).\n- [ ] Crypto KAT assertion is present and passing — not just \"compiles\".\n\n## References\n- `services/gundeck/test/unit/Native.hs` (test-double pattern)\n- `libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/PushToken_user.hs` (golden pattern)\n- RFC 8291 §5 + Appendix A (test vectors)\n- Depends on: WP-API, WP-CRYPTO, WP-DISPATCH\n","status":"closed","priority":1,"issue_type":"task","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:51Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-18T08:06:11Z","started_at":"2026-06-18T08:04:55Z","closed_at":"2026-06-18T08:06:11Z","close_reason":"WP-TESTS complete: all required coverage shipped incrementally with the component beads (WP-API/CRYPTO/DISPATCH/CFG/STORE/HANDLERS/ROUTES).\n\nVERIFICATION (2026-06-18):\n- make c package=wire-api test=1 -\u003e All 467 tests passed (1.78s)\n- make c package=gundeck test=1 -\u003e All 118 tests passed (15.97s)\n\nwire-api coverage:\n- Golden: WebPushSubscription_user (2 objects), registered at Generated.hs:234,841-849.\n- Aeson roundtrip: 7 types (EndpointUrl, P256dhKey, AuthSecret, WebPushKeys, WebPushSubscription, WebPushSubscriptionList, DeleteWebPushRequest) at Aeson.hs:215-221.\n- PostgresMarshall roundtrip: EndpointUrl, P256dhKey, AuthSecret, ConnId, Word64 at PostgresMarshall.hs:50-52.\n\ngundeck coverage:\n- WebPushCrypto: RFC 8291 §5 KAT (byte-exact 144-byte aes128gcm body); fresh-key roundtrip; reference-receiver decrypt roundtrip; IO-wrapper (encryptPayload) roundtrip. VAPID JWT signature verifies against derived public key; aud/sub/exp claims present; ES256/JWT header; Crypto-Key carries server p256ecdsa pub. p256dh validation matrix: length (64B rejected), format (0x02 rejected), on-curve (off-curve rejected), plaintext-size (3994B rejected per RFC 8291 §4).\n- WebPushDispatch: classifyResponse matrix (201/202 success, 404/410 gone, 413 too-large, 429/500/502/503 retryable, 200/400/401/403 error); urgencyFrom (Low-\u003elow, High-\u003ehigh); ttlFrom (Nothing/0/3600/maxBound); endpointOrigin (path/port/query/fragment stripping).\n- Vapid: parseVapidKeyPair RFC 8291 App.A KAT (derives published pubkey); rejects non-base64url, wrong length, zero scalar, all-0xFF scalar. validateVapidSubject (mailto/https accept; http/ftp/bare reject). mkVapidKeyPair gates. VapidPublicKeyResponse wire format + roundtrip + end-to-end derivation KAT + QuickCheck property.\n- WebPushHandlers: SSRF validation matrix + handler roundtrip.\n- WebPushRunner: runWebPush (pure ()) == Right ().\n- WebPushSerialise: plaintext JSON shape + RFC 8291 §4 size guard.\n- WebPushSsrf: IPv4/IPv6 private-range egress filter (loopback/CGNAT/ULA/link-local/metadata/localhost).\n\nNAMING DIVERGENCE (spec anticipated via 'or project's test layout - check existing' clause): spec asked for services/gundeck/test/unit/Web/{CryptoSpec,DispatchSpec}.hs; actual files are flat-named WebPushCrypto.hs / WebPushDispatch.hs, consistent with gundeck's existing flat test layout (Native.hs, Json.hs, Push.hs, etc.; only Aws/ uses a subdir).\n\nDEFERRED (with rationale):\n1. Explicit RFC 8291 Appendix A intermediate-value assertions (ecdh_secret/IKM/CEK/NONCE): the final-body byte-exact KAT already proves all intermediates correct transitively (AES-128-GCM is deterministic in CEK+nonce; HKDF is deterministic in IKM+salt; IKM is deterministic in ecdh_secret+auth_secret). Explicit assertions would be debugging ergonomics only.\n2. MockGundeck-based push1 end-to-end dispatch test (410 -\u003e Store.delete + onTokenRemoved): the pure classifyResponse step is covered; the Gundeck-monadic push1 path depends on WP-FLOW (wire-server-5qh.9) MonadPushAll additions, not yet merged. Deferred to WP-FLOW / integration tests per WebPushDispatch.hs:21-22 comment.","labels":["epic","testing","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.10","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:50Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.10","depends_on_id":"wire-server-5qh.1","type":"blocks","created_at":"2026-06-17T16:06:03Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.10","depends_on_id":"wire-server-5qh.5","type":"blocks","created_at":"2026-06-17T16:06:03Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.10","depends_on_id":"wire-server-5qh.7","type":"blocks","created_at":"2026-06-17T16:06:04Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.11","title":"WP-QA: Quality gate (build/format/lint/nix align)","description":"# Quality gate — build, format, lint, align Nix\n\n## Objective\nSatisfy the project's quality expectations (AGENTS.md) before declaring web push done. Run after all other WP-* beads are closed.\n\n## Checklist\n1. **Type check, whole project, no warnings:**\n `make c | grep -vE 'Compiling|Linking|Preprocessing|Configuring|Building'`\n2. **Unit tests green (all changed packages):**\n `make c test=1 | grep -vE 'Compiling|Linking|Preprocessing|Configuring|Building'`\n (at minimum `wire-api` and `gundeck`; run others touched)\n3. **Format:**\n `make format` (ormolu --mode inplace on all changed .hs files)\n4. **Lint:**\n `make lint-all` (hlint)\n5. **Align Cabal/Nix deps:**\n `make regen-local-nix-derivations` — especially relevant if `crypton`/`jose` were added explicitly to `gundeck.cabal` build-depends in WP-CRYPTO.\n6. **Review diff:**\n `git diff` — confirm no stray debug logs, no committed secrets (the VAPID private key MUST come from config/env, never the repo).\n7. **Do NOT run integration tests** (`make ci` or `*-integration` suites) per AGENTS.md — they depend on specific environments.\n\n## Pre-commit validation\n- No compiler warnings introduced.\n- `crypton`/`jose` deps resolved correctly in nix after regen.\n\n## Acceptance criteria\n- [ ] `make c` clean across whole repo.\n- [ ] `make c test=1` green for changed packages.\n- [ ] `make format` produces no further diff.\n- [ ] `make lint-all` clean.\n- [ ] `make regen-local-nix-derivations` shows no Cabal/Nix drift.\n- [ ] `git diff` reviewed; no secrets in repo.\n\n## References\n- `AGENTS.md` (Quality expectations; Agent commandments)\n- Depends on: ALL other WP-* beads\n","status":"closed","priority":1,"issue_type":"chore","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:51Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-18T09:27:05Z","closed_at":"2026-06-18T09:27:05Z","close_reason":"QA gate passed with zero drift. make c clean (-Werror, no warnings); wire-api-tests 467 PASS; wire-subsystems-tests 311 PASS; gundeck-tests 123 PASS (RFC 8291 crypto KAT, VAPID JWT, SSRF allowlist, dispatch/handler/runner/serialise). ormolu + hlint clean on all 31 changed .hs. make regen-local-nix-derivations produced no nix drift (checksums identical). git diff reviewed: VAPID private key loaded from GUNDECK_WEBPUSH_VAPID_PRIVATE_KEY env/YAML, redacted in Show instance (Options.hs:175), deploy yaml ships webpush block commented with placeholder; no debug code, no TODOs, no secrets. Cabal/Nix deps (crypton, hasql-pool, polysemy, uri-bytestring, memory, base64-bytestring, wire-subsystems) in sync. No QA commit needed (no drift). Epic wire-server-5qh now 11/11.","labels":["epic","qa","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.11","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:51Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.11","depends_on_id":"wire-server-5qh.10","type":"blocks","created_at":"2026-06-17T16:06:04Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.11","depends_on_id":"wire-server-5qh.9","type":"blocks","created_at":"2026-06-17T16:06:04Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.3","title":"WP-CFG: WebPushOpts + VAPID keypair in gundeck Env","description":"# Layer 6 — Config: WebPushOpts + VAPID keypair\n\n## Objective\nMake the server's VAPID P-256 key pair and web-push settings configurable, and load the keypair into `Env` at startup.\n\n## Files to change\n- **EDIT** `services/gundeck/src/Gundeck/Options.hs` — add `WebPushOpts`\n- **EDIT** `services/gundeck/src/Gundeck/Env.hs` — parse keypair, store in `Env`\n- **EDIT** deployment config sample (if present, e.g. `services/gundeck/` yaml under `deploy/`)\n\n## WebPushOpts (Options.hs)\n```haskell\ndata WebPushOpts = WebPushOpts\n { _vapidSubject :: !Text -- e.g. \"mailto:ops@wire.com\" or \"https://wire.com\"\n , _vapidPrivateKey :: !Text -- P-256 private key, base64url (raw 32 bytes) or PEM\n , _endpointAllowlist :: ![Text] -- allowed push-service host suffixes (SSRF mitigation)\n , _defaultTTL :: !(Maybe Word32)\n }\n deriving (Show, Generic)\n\nderiveFromJSON toOptionFieldName ''WebPushOpts\nmakeLenses ''WebPushOpts\n```\nAdd `_webpush :: !WebPushOpts` to `Opts` (around line 133).\n\n## Env (Env.hs)\n- Define `data VapidKeyPair = VapidKeyPair { vkpPrivate :: PrivateKey, vkpPublic :: PublicKey }` (crypton P-256 types) plus the derived base64url public key string for the client-facing endpoint.\n- In `createEnv` (line 69), decode `_vapidPrivateKey`, validate it is a valid P-256 scalar in range, derive the public point, and store a `VapidKeyPair` in `Env`.\n- Add `_vapid :: !VapidKeyPair` to `Env` (line 53).\n\n## Public key endpoint (hand-off to WP-ROUTES)\nExpose `GET /push/web/vapid-public-key` returning the uncompressed/base64url public key so web clients can pass it as `applicationServerKey` to `pushManager.subscribe()`. (Route wiring done in WP-ROUTES; key retrieval done here.)\n\n## Security\n- The private key is a long-lived secret: must come from a secret manager / env, never committed. Document loading via env var override (same pattern as `REDIS_PASSWORD` at `Env.hs:80`).\n- Validate `vapidSubject` is a `mailto:` or `https://` URL (RFC 8292 §3).\n\n## Acceptance criteria\n- [ ] Missing/malformed VAPID key fails fast at startup with a clear error.\n- [ ] `Env` carries the parsed `VapidKeyPair`.\n- [ ] Public key endpoint returns the base64url uncompressed point.\n\n## References\n- `services/gundeck/src/Gundeck/Options.hs:133-155` (Opts pattern)\n- `services/gundeck/src/Gundeck/Env.hs:69-108` (createEnv)\n- RFC 8292 §2-3 (VAPID key format, JWT claims)\n","status":"closed","priority":1,"issue_type":"task","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:48Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-17T17:12:55Z","started_at":"2026-06-17T16:20:26Z","closed_at":"2026-06-17T17:12:55Z","close_reason":"Implemented WP-CFG: WebPushOpts (Maybe, optional section) + VapidKeyPair in Env. Pure parse/validate helpers (mkVapidKeyPair/parseVapidKeyPair/validateVapidSubject) with explicit scalarIsValid range check (rejects d=0 and d\u003e=n, since crypton's decodePrivate only checks byte length). Env override GUNDECK_WEBPUSH_VAPID_PRIVATE_KEY mirrors REDIS_PASSWORD. Redacting Show instance on WebPushOpts (repo convention). RFC 8291 Appendix A KAT verifies the full crypto chain. 34/34 unit tests green, build clean, hlint clean, nix aligned. Commit cbace5a1e.\n\nDesign deviation (documented in commit): _webpush :: Maybe WebPushOpts / _vapid :: Maybe VapidKeyPair instead of non-Maybe, so absent config disables web push (incremental rollout, no breakage to existing deployments) and present-but-invalid fails fast. Downstream beads WP-CRYPTO/WP-ROUTES/WP-DISPATCH consume Env._vapid :: Maybe VapidKeyPair and must handle Nothing (feature disabled).","labels":["config","epic","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.3","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:47Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.5","title":"WP-CRYPTO: RFC 8291 encryption + RFC 8292 VAPID signing","description":"# Layer 3 — Crypto: RFC 8291 encryption + RFC 8292 VAPID signing\n\n## Objective\nImplement the two crypto primitives web push requires, using `crypton`. This is the correctness-critical core.\n\n## Files to create / change\n- **NEW** `services/gundeck/src/Gundeck/Push/Web/Crypto.hs`\n- **EDIT** `services/gundeck/gundeck.cabal` — add `crypton` (and possibly `jose`) to build-depends if not already visible transitively.\n\n## Public API\n```haskell\n-- RFC 8291: produce the aes128gcm content-coded body.\n-- 86-byte header (salt(16) || rs=4096(4) || idlen=65(1) || as_public(65)) + single encrypted record.\nencryptPayload ::\n WebPushKeys -- recipient p256dh (ECDH pub) + auth secret\n -\u003e ByteString -- plaintext (MUST be \u003c= 3993 bytes per RFC 8291 §4)\n -\u003e IO EncryptedBody -- generates ephemeral ECDH keypair + 16-byte salt internally\n\n-- RFC 8292: VAPID JWT (ES256) + header values.\nsignVapid ::\n VapidKeyPair -- server's static P-256 key (from WP-CFG)\n -\u003e Text -- subject (mailto: or https://)\n -\u003e Text -- audience origin, e.g. \"https://fcm.googleapis.com\"\n -\u003e IO VapidHeaders -- { authorization :: \"vapid t=\u003cjwt\u003e,k=\u003ckid\u003e\", cryptoKey :: \"p256ecdsa=\u003cpub\u003e\" }\n```\n\n## Implementation — follow RFC 8291 §3.4 pseudocode verbatim\n1. **ECDH P-256**: generate ephemeral keypair; `ecdh_secret = ECDH(as_private, ua_public)`. Use `Crypto.PubKey.ECC.DH` (crypton).\n2. **Validate** the recipient `p256dh` point is on the P-256 curve (RFC 8291 §7) — prevents invalid-curve attacks. Steps: not point-at-infinity; coords in range; satisfies curve equation.\n3. **HKDF-SHA256 key combine** (`Crypto.KDF.HKDF`):\n - `salt = auth_secret`, `IKM = ecdh_secret`\n - `key_info = \"WebPush: info\" || 0x00 || ua_public || as_public`\n - `IKM' = HMAC-SHA256(salt=auth_secret, key_info || 0x01)` → 32 bytes\n4. **RFC 8188 CEK/nonce** (HKDF with `salt = random 16-byte salt`):\n - `PRK = HMAC-SHA256(salt, IKM')`\n - `CEK = HMAC-SHA256(PRK, \"Content-Encoding: aes128gcm\" || 0x00 || 0x01)[0..15]`\n - `NONCE = HMAC-SHA256(PRK, \"Content-Encoding: nonce\" || 0x00 || 0x01)[0..11]`\n5. **AES-128-GCM** (`Crypto.Cipher.AES` + `Crypto.Cipher.Types.aeadEncrypt`): plaintext || padding-delimiter `0x02`, single record, `rs=4096`.\n6. **Header**: `salt || rs(4, big-endian) || idlen(1, =65) || as_public(65)` → 86 bytes; concat with ciphertext.\n\n## VAPID JWT (RFC 8292)\n- Claims: `{ \"aud\": \u003cendpoint origin\u003e, \"exp\": \u003cnow+12h\u003e, \"sub\": \u003csubject\u003e }`.\n- Signed ES256 (ECDSA P-256 SHA-256) with the server private key. Use `jose` (already a wire-api dep) or raw `Crypto.PubKey.ECDSA.sign`.\n- Header dict: `Authorization: vapid t=\u003cjwt\u003e,k=\u003ckid\u003e`; `Crypto-Key: p256ecdsa=\u003cbase64url pub\u003e`.\n\n## Acceptance criteria — MUST pass the RFC 8291 known-answer test\n- [ ] Encrypt the plaintext `\"When I grow up, I want to be a watermelon\"` with the RFC 8291 §5 / Appendix A test vectors (keys, salt, auth secret provided) and reproduce the exact ciphertext from §5. **This is the primary correctness proof.**\n- [ ] `signVapid` produces a JWT verifiable against the derived public key.\n- [ ] Rejects a `p256dh` not on the curve.\n\n## References\n- RFC 8291 §3.4 (pseudocode), §4 (record restrictions), §5 + Appendix A (test vectors)\n- RFC 8292 §2-3\n- Depends on: WP-CFG (VapidKeyPair), WP-API (WebPushKeys)\n","status":"closed","priority":1,"issue_type":"feature","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:48Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-17T19:28:16Z","started_at":"2026-06-17T18:43:52Z","closed_at":"2026-06-17T19:28:16Z","close_reason":"Closed","labels":["crypto","epic","security","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.5","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:48Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.5","depends_on_id":"wire-server-5qh.3","type":"blocks","created_at":"2026-06-17T16:06:00Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.1","title":"WP-API: API types + routes for WebPushSubscription (wire-api)","description":"# Layer 1 — API types + routes (libs/wire-api)\n\n## Objective\nDefine the public API contract for web push subscriptions in `wire-api`, consumed by gundeck and by clients.\n\n## Files to create / change\n- **NEW** `libs/wire-api/src/Wire/API/Push/V2/WebSubscription.hs` — types\n- **EDIT** `libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs` — add `WebPushAPI` to `GundeckAPI` (currently line 34)\n- **EDIT** `libs/wire-api/src/Wire/API/Push/V2.hs` — re-export new types (around line 58)\n- **NEW** `libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/WebPushSubscription_user.hs`\n- **EDIT** `libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs` — register golden group (mirror `PushToken_user` at lines 156-157, 827-830)\n- **EDIT** `libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs` — add roundtrip (near line 212)\n\n## Types to define (in WebSubscription.hs)\n```haskell\nnewtype EndpointUrl = EndpointUrl { endpointUrlText :: Text }\n -- MUST validate: parses as https:// URI (RFC 8030 §8.2: must not leak user identity)\n\nnewtype P256dhKey = P256dhKey { ... } -- base64url, 65 bytes uncompressed P-256 point\nnewtype AuthSecret = AuthSecret { ... } -- base64url, 16 bytes\n\ndata WebPushKeys = WebPushKeys\n { _wpkP256dh :: !P256dhKey\n , _wpkAuth :: !AuthSecret\n }\n\ndata WebPushSubscription = WebPushSubscription\n { _wpsEndpoint :: !EndpointUrl\n , _wpsKeys :: !WebPushKeys\n , _wpsExpirationTime :: !(Maybe Word64) -- ms since epoch; Nothing = no expiry\n , _wpsClient :: !ClientId\n }\n\nnewtype WebPushSubscriptionList = WebPushSubscriptionList { wpsList :: [WebPushSubscription] }\n```\n- Derive `ToSchema`/`FromJSON`/`Arbitrary` via `Schema` (same pattern as `PushToken` in `Token.hs:79-109`).\n- Define API response types mirroring `AddTokenError`/`AddTokenSuccess`/`AddTokenResponses` in `Token.hs:191-219`: `AddWebPushError`, `AddWebPushSuccess`, `AddWebPushResponses`, `DeleteWebPushResponses`.\n\n## Routes (in Gundeck.hs)\n```haskell\ntype GundeckAPI = PushAPI :\u003c|\u003e WebPushAPI :\u003c|\u003e NotificationAPI :\u003c|\u003e TimeAPI\n\ntype WebPushAPI =\n Named \"register-web-push-subscription\"\n ( Summary \"Register a web push subscription\"\n :\u003e From '\u003c\u003e\u003e '\u003clatest-version\u003e -- gate behind new API version (see how get-server-time uses From 'V9, line 139)\n :\u003e ZUser\n :\u003e \"push\" :\u003e \"web\" :\u003e \"subscriptions\"\n :\u003e ReqBody '[JSON] WebPushSubscription\n :\u003e MultiVerb 'POST '[JSON] AddWebPushResponses (Either AddWebPushError AddWebPushSuccess) )\n :\u003c|\u003e Named \"delete-web-push-subscription\"\n ( ... :\u003e Capture \"endpoint\" EndpointUrl :\u003e MultiVerb 'DELETE '[JSON] DeleteWebPushResponses (Maybe ()) )\n :\u003c|\u003e Named \"get-web-push-subscriptions\"\n ( ... :\u003e Get '[JSON] WebPushSubscriptionList )\n```\n\n## Acceptance criteria\n- [ ] `make c package=wire-api` compiles with no warnings.\n- [ ] Golden JSON roundtrips for `WebPushSubscription`.\n- [ ] `EndpointUrl` rejects non-https inputs.\n- [ ] OpenAPI schema renders the new routes.\n\n## References\n- `libs/wire-api/src/Wire/API/Push/V2/Token.hs:79-219` (pattern to mirror)\n- `libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs:34-65` (route pattern)\n- W3C Push API §3.4 (PushSubscription), §8 (interface)\n","status":"closed","priority":1,"issue_type":"feature","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:47Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-17T16:12:21Z","started_at":"2026-06-17T15:04:21Z","closed_at":"2026-06-17T16:12:21Z","close_reason":"Implemented all API types and routes for WebPushSubscription.\n\nCreated Wire.API.Push.V2.WebSubscription with:\n- EndpointUrl (HTTPS-only), P256dhKey (65 bytes), AuthSecret (16 bytes)\n- WebPushKeys, WebPushSubscription, WebPushSubscriptionList\n- AddWebPushError/Success response types, DeleteWebPushRequest\n- Three routes (POST/DELETE/GET /push/web/subscriptions), all From 'V17\n- DELETE uses ReqBody with DeleteWebPushRequest (not Capture) to avoid URL-encoding issues with endpoint URLs containing slashes\n- Gundeck stub handlers for compilation\n- Golden + roundtrip tests for all new types\n\nAll tests pass: 462 unit + 2980 golden. Whole project type-checks with no warnings.","labels":["api","epic","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.1","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:46Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.2","title":"WP-DB: WebPushStore effect + Postgres interpreter + migration (wire-subsystems)","description":"# Layer 2 — WebPushStore effect + Postgres interpreter + migration (libs/wire-subsystems)\n\n## Objective\nDefine web-push subscription persistence as a **Polysemy effect** with a **Postgres interpreter**, mirroring the `Wire.CodeStore` + `Wire.CodeStore.Postgres` pattern. This is a *library* concern (in `wire-subsystems`), NOT a gundeck-owned Cassandra table. Gundeck-side wiring (pool + Sem runner) is in WP-STORE.\n\n## Why not Cassandra\nPer design decision: web push storage moves off Cassandra onto Postgres, structured as a reusable effect. This keeps web push decoupled from gundeck's legacy Cassandra stack and consistent with the refactored services (galley/brig) that already consume `wire-subsystems` effects.\n\n## Files to create / change\n- **NEW** `libs/wire-subsystems/postgres-migrations/\u003cYYYYMMDDhhmmss\u003e-create-webpush-subscriptions.sql`\n- **NEW** `libs/wire-subsystems/src/Wire/WebPushStore.hs` — effect (mirror `Wire.CodeStore.hs`)\n- **NEW** `libs/wire-subsystems/src/Wire/WebPushStore/Postgres.hs` — interpreter (mirror `Wire.CodeStore.Postgres.hs`)\n- **EDIT** `libs/wire-subsystems/wire-subsystems.cabal` — expose new modules\n- **EDIT** `libs/wire-api/...` (or wire-subsystems) — add `PostgresMarshall` instances for `EndpointUrl`, `P256dhKey`, `AuthSecret` (needed by `lmapPG`/`dimapPG`; see `Wire.API.PostgresMarshall`)\n\n## Migration SQL (timestamped file, auto-picked-up by `Wire.PostgresMigrations` `embedDir`)\n```sql\nCREATE TABLE webpush_subscriptions (\n user_id uuid NOT NULL,\n client_id uuid NOT NULL,\n endpoint text NOT NULL, -- browser push-service URL\n p256dh text NOT NULL, -- base64url ECDH P-256 pub key (65B uncompressed)\n auth text NOT NULL, -- base64url auth secret (16B)\n expiration bigint, -- ms since epoch; NULL = no expiry\n conn_id text NOT NULL,\n PRIMARY KEY (user_id, client_id, endpoint)\n);\n-- leading PK column user_id already indexes the per-user lookup hot path.\n```\nNOTE: migrations are SQL files dropped into the `postgres-migrations/` dir and sorted by filename timestamp — NOT a Haskell V13 module. No edit to `Gundeck.Schema.Run`.\n\n## Effect (Wire/WebPushStore.hs) — like CodeStore\n```haskell\nmodule Wire.WebPushStore where\nimport Data.Id (ClientId, ConnId, UserId)\nimport Imports\nimport Polysemy\nimport Wire.API.Push.V2.WebSubscription -- from WP-API\n\ndata WebPushStore m a where\n InsertSubscription :: UserId -\u003e WebPushSubscription -\u003e ConnId -\u003e WebPushStore m ()\n LookupSubscriptions :: UserId -\u003e WebPushStore m [WebPushAddress]\n DeleteSubscription :: UserId -\u003e EndpointUrl -\u003e WebPushStore m ()\n DeleteAllForUser :: UserId -\u003e WebPushStore m () -- GDPR / account delete\n PurgeExpired :: UserId -\u003e WebPushStore m ()\n\nmakeSem ''WebPushStore\n```\n`WebPushAddress` is the dispatch-time record (user, conn, endpoint, keys, client) — analogous to `Address` in `Native/Types.hs:52`. Define it here or in a sibling `Wire/WebPushStore/Types.hs`.\n\n## Interpreter (Wire/WebPushStore/Postgres.hs) — like CodeStore.Postgres\n```haskell\nmodule Wire.WebPushStore.Postgres (interpretWebPushStoreToPostgres) where\nimport Wire.Postgres\nimport Wire.API.PostgresMarshall\nimport Hasql.TH\n...\n\ninterpretWebPushStoreToPostgres :: (PGConstraints r) =\u003e InterpreterFor WebPushStore r\ninterpretWebPushStoreToPostgres = interpret $ \\case\n InsertSubscription uid sub conn -\u003e insertSubscriptionImpl uid sub conn\n LookupSubscriptions uid -\u003e lookupSubscriptionsImpl uid\n DeleteSubscription uid ep -\u003e runStatement (uid, ep) deleteSub\n DeleteAllForUser uid -\u003e runStatement uid deleteAll\n PurgeExpired uid -\u003e purgeExpiredImpl uid\n```\nQueries via Hasql TH quasi-quoters:\n- `insertSub`: `[resultlessStatement| INSERT INTO webpush_subscriptions (...) VALUES (...) ON CONFLICT (user_id, client_id, endpoint) DO UPDATE SET ... |]` (upsert — re-register updates keys/expiry, no duplicate).\n- `lookupSubscriptionsImpl`: multi-row select `WHERE user_id = ($1 :: uuid)` using the vector/list Hasql.TH quasi-quoter; decode each row to `WebPushAddress`.\n- `deleteSub` / `deleteAll`: `resultlessStatement` DELETEs.\n- `purgeExpiredImpl`: `DELETE ... WHERE user_id = $1 AND expiration IS NOT NULL AND expiration \u003c \u003cnow_ms\u003e`.\n\nUse `lmapPG`/`dimapPG` (from `Wire.API.PostgresMarshall`) to encode/decode domain types exactly like `CodeStore.Postgres.hs:65-100`.\n\n## Acceptance criteria\n- [ ] `make c package=wire-subsystems` compiles (effect + interpreter).\n- [ ] Migration file present and sorted; `Wire.PostgresMigrations.allMigrations` picks it up.\n- [ ] `InsertSubscription` then `LookupSubscriptions` round-trips; re-insert upserts.\n- [ ] `PurgeExpired` removes only expired rows.\n\n## References\n- `libs/wire-subsystems/src/Wire/CodeStore.hs` (effect template)\n- `libs/wire-subsystems/src/Wire/CodeStore/Postgres.hs` (interpreter template)\n- `libs/wire-subsystems/src/Wire/Postgres.hs:94-155` (`PGConstraints`, `runStatement`)\n- `libs/wire-subsystems/src/Wire/PostgresMigrations.hs:40-41` (`embedDir` migration pickup)\n- `libs/wire-subsystems/src/Wire/AppStore/Postgres.hs` (second interpreter example)\n- Depends on: WP-API (types: `WebPushSubscription`, `EndpointUrl`, `WebPushKeys`)\n","status":"closed","priority":1,"issue_type":"task","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:47Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-17T18:35:48Z","started_at":"2026-06-17T17:25:50Z","closed_at":"2026-06-17T18:35:48Z","close_reason":"Implemented WebPushStore effect + Postgres interpreter + migration.\n\nCreated Wire.WebPushStore (effect + WebPushAddress dispatch record with 5 ops:\ninsert/lookup/delete/deleteAll/purgeExpired) and Wire.WebPushStore.Postgres\n(Hasql.TH interpreter with upsert ON CONFLICT, multi-row vectorStatement lookup,\nexpiry-based purge). Migration 20260617140000-create-webpush-subscriptions.sql\n(PK user_id, client_id, endpoint) auto-picked-up by embedDir.\n\nAdded PostgresMarshall instances in wire-api for EndpointUrl (text, HTTPS-validated),\nP256dhKey/AuthSecret (text, base64url), ConnId (bytea - matches Cassandra CqlBlob\nconvention), Word64 (int8). Custom Show on WebPushAddress redacts wpaKeys to avoid\nleaking the RFC 8291 auth secret (mirrors native Address pattern, per code review).\n\nIn-memory mock interpreter for downstream tests. Roundtrip property tests for all\nnew marshall pairs.\n\nAll tests pass: 467 (wire-api) + 311 (wire-subsystems). Whole project type-checks.\normolu + hlint clean.","labels":["epic","storage","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.2","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:47Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.2","depends_on_id":"wire-server-5qh.1","type":"blocks","created_at":"2026-06-17T16:21:52Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.8","title":"WP-ROUTES: Wire WebPushAPI routes in Gundeck/API/Public.hs","description":"# Layer 5.2 — Route wiring (Gundeck/API/Public.hs)\n\n## Objective\nWire the new `WebPushAPI` servant routes to the handlers from WP-HANDLERS, plus expose the VAPID public-key endpoint.\n\n## Files to change\n- **EDIT** `services/gundeck/src/Gundeck/API/Public.hs` — extend `servantSitemap` (line 40)\n\n## servantSitemap change\n```haskell\nservantSitemap :: ServerT GundeckAPI Gundeck\nservantSitemap = pushAPI :\u003c|\u003e webPushAPI :\u003c|\u003e notificationAPI :\u003c|\u003e timeAPI\n where\n pushAPI = Named @\"register-push-token\" Push.addToken :\u003c|\u003e ...\n webPushAPI =\n Named @\"register-web-push-subscription\" Push.addWebSubscription\n :\u003c|\u003e Named @\"delete-web-push-subscription\" Push.deleteWebSubscription\n :\u003c|\u003e Named @\"get-web-push-subscriptions\" Push.listWebSubscriptions\n :\u003c|\u003e Named @\"get-vapid-public-key\" getVapidPublicKey\n ...\n```\n\n## getVapidPublicKey\n```haskell\ngetVapidPublicKey :: Gundeck VapidPublicKeyResponse\ngetVapidPublicKey = VapidPublicKeyResponse . view (vapid . vkpPublicB64) \u003c$\u003e ask\n```\nReturns the base64url uncompressed P-256 public key (derived in WP-CFG) so web clients can call `pushManager.subscribe({ applicationServerKey })`.\n\n## Acceptance criteria\n- [ ] `make c package=gundeck` compiles and the servant server type-checks against `GundeckAPI`.\n- [ ] `GET /push/web/vapid-public-key` returns the key.\n- [ ] Routes versioned correctly (only served under the API version gated in WP-API).\n\n## References\n- `services/gundeck/src/Gundeck/API/Public.hs:40-56` (sitemap pattern)\n- Depends on: WP-API (routes), WP-HANDLERS (handlers), WP-CFG (vapid key)\n","status":"closed","priority":2,"issue_type":"task","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:50Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-17T22:23:04Z","started_at":"2026-06-17T22:02:31Z","closed_at":"2026-06-17T22:23:04Z","close_reason":"Implemented: GET /push/web/vapid-public-key endpoint wired. Added VapidPublicKeyResponse in wire-api (mirrors ServerTime), 4th route on WebPushAPI (From V17, no ZUser since the key is browser-facing), getVapidPublicKey handler in Gundeck.Push (503 via requireWebPushOpts when disabled, defensive 500 for impossible Nothing-after-Just). 4 unit tests added (wire-format pin, decode roundtrip, RFC 8291 KAT end-to-end, QuickCheck property). All 63 gundeck tests pass, no warnings, ormolu+hlint clean. Code-reviewer agent: ship-it.","labels":["api","epic","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.8","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:49Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.8","depends_on_id":"wire-server-5qh.3","type":"blocks","created_at":"2026-06-17T16:06:02Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.8","depends_on_id":"wire-server-5qh.6","type":"blocks","created_at":"2026-06-17T16:06:02Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.9","title":"WP-FLOW: Hook web push into pushAllLegacy dispatch flow","description":"# Layer (integration) — Hook web push into the pushAllLegacy flow\n\n## Objective\nMake web push recipients actually receive notifications: alongside native push dispatch, look up web subscriptions and dispatch via WP-DISPATCH.\n\n## Files to change\n- **EDIT** `services/gundeck/src/Gundeck/Push.hs` — extend `pushAllLegacy` (line 271) and the `MonadPushAll` class (line 106)\n\n## Approach\n1. Add to `MonadPushAll` (line 106):\n ```haskell\n mpaPushWeb :: Notification -\u003e Priority -\u003e [WebPushAddress] -\u003e m ()\n mpaWebTargets :: UserId -\u003e m [WebPushAddress] -- or reuse a generic targets effect\n ```\n Instance in `Gundeck` (line 120) calls `Web.push` / `Web.Data.lookup`.\n\n2. In `pushAllLegacy` (line 271), after the existing `pushNativeWithBudget` (line 292), add an analogous `pushWebWithBudget` that:\n - collects web recipients (same `dontPush` set semantics as `nativeTargets`, line 491),\n - looks up `WebPushAddress` per recipient,\n - calls `Web.push`.\n\n3. Respect the same `perNativePushConcurrency` budget (or a new `perWebPushConcurrency`) so web push doesn't overwhelm the manager / external push services. Web push is unmediated (no SNS throttling), so budgeting matters more here.\n\n## Filtering\n- Apply the same `shouldActuallyPush` / `eligibleClient` / `whitelistedOrNoWhitelist` logic as `nativeTargets` (lines 452-519) so origin-exclusion and connection allowlists behave identically for web and native.\n- Transient pushes (`pushTransient`) skip native (line 304); decide whether web push should also be skipped for transient (recommend: skip, matching native semantics — web push is for durable notices).\n\n## Acceptance criteria\n- [ ] A user with only a web subscription receives the notice (encrypted POST fired).\n- [ ] A user with both native + web receives both, unless already served over websocket (`dontPush`).\n- [ ] Budget limits web-push concurrency.\n- [ ] Origin client excluded.\n\n## References\n- `services/gundeck/src/Gundeck/Push.hs:271-306` (pushAllLegacy / pushNativeWithBudget)\n- `services/gundeck/src/Gundeck/Push.hs:474-523` (pushNative / nativeTargets)\n- Depends on: WP-DISPATCH (Web.push), WP-ROUTES (so routes exist), WP-STORE (lookup)\n","status":"closed","priority":2,"issue_type":"task","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:50Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-18T09:15:09Z","started_at":"2026-06-18T08:17:00Z","closed_at":"2026-06-18T09:15:09Z","close_reason":"WP-FLOW complete: web push hooked into pushAllLegacy alongside native push.\n\nPRODUCTION (services/gundeck/src/Gundeck/Push.hs):\n- MonadPushAll class extended with mpaPushWeb (wraps WebPush.push) and mpaWebTargets (Postgres lookup via runWebPush, log+[] on failure for resilient default).\n- pushWebWithBudget: mirror of pushNativeWithBudget (same transient skip, same mpaRunWithBudget cost).\n- webTargets: mirror of nativeTargets with the same 4 filters (origin/eligibleClient/whitelist/dontPush) adapted for WebPushAddress fields.\n- pushAllLegacy: calls pushWebWithBudget after pushNativeWithBudget with the SAME dontPush set.\n\nTESTS:\n- MockGundeck: MockEnv refactored to record with _meWebSubscriptions fixture; MockState gained _msWebPushQueue; Arbitrary/ToJSON/FromJSON WebPushAddress orphans; genMockEnv generates 0..2 web subs per (user, client); handlePushWeb mirrors handlePushNative + two extra conditions (not-ws-served matching production dontPush, and not-consumable because web push is only dispatched from pushAllLegacy never pushAllViaRabbitMq).\n- Push.hs: 5 explicit regression tests (web-only, both-subscribed offline, ws-served skip, origin skip, transient skip) plus the existing property test (pushAllProp) automatically extends via whole-state equality.\n\nVERIFICATION (2026-06-18):\n- make c (whole project) clean, no warnings.\n- make c package=gundeck test=1 -\u003e All 123 tests passed (was 118; +5 new explicit web push flow cases).\n- ormolu + hlint clean.\n- Code-reviewed by code-reviewer agent: APPROVE with one MEDIUM finding (singleUserEnv consumability hardening) addressed before commit.\n\nACCEPTANCE CRITERIA:\n[x] A user with only a web subscription receives the notice (web-only test).\n[x] A user with both native + web receives both, unless already served over websocket (both-subscribed offline test + ws-served-skip test).\n[x] Budget limits web-push concurrency (pushWebWithBudget uses mpaRunWithBudget like native; WebPush.push uses perNativePushConcurrency).\n[x] Origin client excluded (origin-client-skip test).\n\nDESIGN DECISIONS:\n- Web push dispatched ONLY from pushAllLegacy (not pushAllViaRabbitMq). Consumable (rabbitmq-routed) clients do not get web push because they receive notifications via the persistent rabbitmq queue. This asymmetry vs. native push (which is dispatched from both pipelines) is documented in handlePushWeb and tested via the notConsumable filter.\n- Reused perNativePushConcurrency budget (per spec) instead of adding a separate perWebPushConcurrency.\n- mpaWebTargets returns [] on PG failure (resilient default) rather than throwing, to isolate per-user lookup failures from the entire push batch.\n\nCommit: 767f8508c","labels":["epic","integration","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.9","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:50Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.9","depends_on_id":"wire-server-5qh.4","type":"blocks","created_at":"2026-06-17T16:06:03Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.9","depends_on_id":"wire-server-5qh.7","type":"blocks","created_at":"2026-06-17T16:06:02Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.9","depends_on_id":"wire-server-5qh.8","type":"blocks","created_at":"2026-06-17T16:06:03Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.6","title":"WP-HANDLERS: register/delete/list web push handlers","description":"# Layer 5.1 — API handlers (Gundeck/Push.hs)\n\n## Objective\nImplement the request handlers for register/delete/list web push subscriptions, mirroring `addToken`/`deleteToken`/`listTokens`.\n\n## Files to change\n- **EDIT** `services/gundeck/src/Gundeck/Push.hs` — add `addWebSubscription`, `deleteWebSubscription`, `listWebSubscriptions` (near the native handlers at lines 527-689)\n\n## Handlers\n```haskell\naddWebSubscription :: UserId -\u003e WebPushSubscription -\u003e Gundeck (Either AddWebPushError AddWebPushSuccess)\naddWebSubscription uid sub = ...\n -- validate: EndpointUrl is https AND host matches endpointAllowlist (SSRF mitigation)\n -- Web.Data.purgeExpired uid\n -- Web.Data.insert uid sub \u003cconn-from-ZConn\u003e\n -- return AddWebPushSuccess sub\n\ndeleteWebSubscription :: UserId -\u003e EndpointUrl -\u003e Gundeck (Maybe ())\ndeleteWebSubscription uid ep = ... Web.Data.delete uid ep $\u003e Just () ...\n\nlistWebSubscriptions :: UserId -\u003e Gundeck WebPushSubscriptionList\nlistWebSubscriptions uid = WebPushSubscriptionList . map (...) \u003c$\u003e Web.Data.lookup uid Data.LocalQuorum\n```\n\n## Validation (security — SSRF)\n- The `endpoint` URL is attacker-controllable and gundeck will later POST to it. **Reject** any `endpoint` that:\n - is not `https://`, or\n - whose host is not on the configured `_endpointAllowlist` (WP-CFG), or\n - resolves to/contains a private/loopback address (belt-and-suspenders; also enforce a hardened Manager in WP-DISPATCH).\n- On invalid endpoint return the `AddWebPushError` equivalent of `AddTokenErrorInvalid`.\n\n## ConnId\n- Native `addToken` takes `ConnId` from the `ZConn` route combinator (Gundeck.hs:41). Web subscription route should include `ZConn` too and pass it to `insert`.\n\n## Acceptance criteria\n- [ ] Registering the same `(client, endpoint)` twice upserts (no duplicate rows).\n- [ ] Invalid/non-https/disallowed-host endpoint returns the invalid error.\n- [ ] Delete by endpoint removes exactly that row.\n\n## References\n- `services/gundeck/src/Gundeck/Push.hs:527-689` (native handlers)\n- Depends on: WP-API (types/routes), WP-STORE (storage)\n","status":"closed","priority":2,"issue_type":"task","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:49Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-17T21:54:27Z","started_at":"2026-06-17T21:26:53Z","closed_at":"2026-06-17T21:54:27Z","close_reason":"Implemented WP-HANDLERS: addWebSubscription/deleteWebSubscription/listWebSubscriptions in Gundeck.Push, wired into servant sitemap (replacing WP-API stubs). Pure SSRF validator (validateEndpointHost/endpointHost) parses hostname via URI.ByteString, dot-boundary host matching, case-insensitive (RFC 3986), rejecting userinfo/prefix/suffix bypasses. addWebSubscription enforces per-user cap (32) before insert (bounds dispatch fan-out + purge cost). All handlers 503 when web push disabled (Nothing); delete idempotent (204); list reports expiration=Nothing (v1 limitation). Added ZConn to register route (wire-api) -- WP-API had shipped without it; needed for WP-FLOW conn filtering. 13 new unit tests (SSRF matrix + roundtrip); all 59 gundeck tests green; build/ormolu/hlint clean; default.nix regenerated. Commit 666dddad9. Design deviations vs task spec: ZConn added to route; TooMany wired up via cap (was assumed dead); delete idempotent-204 (not 404).","labels":["api","epic","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.6","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:48Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.6","depends_on_id":"wire-server-5qh.1","type":"blocks","created_at":"2026-06-17T16:06:00Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.6","depends_on_id":"wire-server-5qh.4","type":"blocks","created_at":"2026-06-17T16:06:01Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.7","title":"WP-DISPATCH: Web push HTTPS POST dispatch (RFC 8030)","description":"# Layer 4 — Web dispatch: HTTPS POST to push-service endpoints\n\n## Objective\nThe runtime counterpart to native `Aws.publish`: encrypt a notice payload (RFC 8291), sign VAPID (RFC 8292), and POST to the browser's push-service endpoint (RFC 8030).\n\n## Files to create / change\n- **NEW** `services/gundeck/src/Gundeck/Push/Web.hs` — dispatch loop (mirror `Gundeck/Push/Native.hs`)\n- **NEW** `services/gundeck/src/Gundeck/Push/Web/Serialise.hs` — build the plaintext notice JSON (mirror `Native/Serialise.hs`)\n\n## Dispatch (Web.hs) — analogous to Native.push/push1/publish\n```haskell\npush :: NativePush -\u003e [WebPushAddress] -\u003e Gundeck ()\npush m addrs = ... respect perPushConcurrency budget (pooledMapConcurrentlyN) ...\n\npush1 :: NativePush -\u003e WebPushAddress -\u003e Gundeck ()\npush1 m a = do\n let plaintext = serialiseWeb m (a ^. wpaUser) -- JSON: {type:\"notice\", data:{id}, user}\n vapid \u003c- signVapid \u003c$\u003e view vapid \u003c*\u003e pure subject \u003c*\u003e pure (origin (a^.wpaEndpoint))\n body \u003c- liftIO (encryptPayload (a ^. wpaKeys) plaintext)\n resp \u003c- httpPost (a ^. wpaEndpoint) body vapid (ttl, urgencyFrom (npPriority m))\n case resp of\n 201 / 202 -\u003e onSuccess -- accepted\n 404 / 410 -\u003e onGone -- subscription expired/unsubscribed -\u003e Web.Data.delete + onTokenRemoved\n 413 -\u003e onTooLarge\n 429 / 5xx -\u003e backoff/retry\n```\n\n## httpPost\n- Use the existing `_manager :: Manager` from `Env` (`Env.hs:57`). **Recommend a separate hardened Manager** with a custom `managerProxy`/`checkResponse` and an egress filter rejecting private IPs (SSRF), configured in `createEnv`. See how `Push/Websocket.hs:200` sets `checkResponse`.\n- Headers (RFC 8030):\n - `Content-Encoding: aes128gcm`\n - `TTL: \u003cseconds\u003e` (from `_defaultTTL` or push priority; transient → 0)\n - `Urgency: very-low|low|normal|high` (map from `Priority`; see RFC 8030 §5.3 Table 1)\n - `Topic: \u003ccollapse-key\u003e` (optional; RFC 8030 §5.4 for coalescing)\n - `Authorization` / `Crypto-Key` from `signVapid`\n- Body = the RFC 8291 encrypted body.\n\n## Serialise (Serialise.hs)\n- Same JSON shape as `Native.Serialise.serialise` (`{type:\"notice\", data:{id}, user}`) — **plaintext**, because encryption happens here, not via SNS. Enforce the RFC 8291 §4 plaintext limit (≤ 3993 bytes).\n\n## Prometheus counters\nMirror `Native.hs:84-122`: `web_push_success`, `web_push_gone`, `web_push_too_large`, `web_push_errors`.\n\n## onTokenRemoved\nOn 404/410, after deleting the row, emit a `PushRemove`-style event equivalent so the client knows (see `Native.hs:215-221` pattern). Web clients get this via the notification stream, not a native push.\n\n## Acceptance criteria\n- [ ] A 201 from the endpoint records success; a 410 deletes the subscription.\n- [ ] Retries 429/5xx with backoff, bounded.\n- [ ] No unencrypted plaintext reaches the wire.\n- [ ] Concurrency bounded by the push budget (does not flood FCM/Mozilla).\n\n## References\n- `services/gundeck/src/Gundeck/Push/Native.hs:124-237` (push/push1/publish pattern)\n- `services/gundeck/src/Gundeck/Push/Native/Serialise.hs` (serialise pattern)\n- `services/gundeck/src/Gundeck/Push/Websocket.hs:192-205` (Manager/checkResponse usage)\n- RFC 8030 §5 (request), §5.2 (TTL), §5.3 (Urgency)\n- Depends on: WP-STORE (WebPushAddress), WP-CRYPTO (encrypt/sign), WP-CFG (vapid key + manager)\n","status":"closed","priority":2,"issue_type":"feature","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:49Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-18T07:44:41Z","started_at":"2026-06-18T06:12:18Z","closed_at":"2026-06-18T07:44:41Z","close_reason":"Implemented: Gundeck.Push.Web dispatch (push/push1/httpPost + Prometheus counters + bounded retry), Serialise (plaintext JSON + RFC 8291 size guard), Ssrf (private-IP egress filter), hardened _webPushManager in Env. 118 unit tests pass. Code-reviewed and all M1-M4 findings addressed.","labels":["dispatch","epic","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.7","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:49Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.7","depends_on_id":"wire-server-5qh.3","type":"blocks","created_at":"2026-06-17T16:06:02Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.7","depends_on_id":"wire-server-5qh.4","type":"blocks","created_at":"2026-06-17T16:06:01Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.7","depends_on_id":"wire-server-5qh.5","type":"blocks","created_at":"2026-06-17T16:06:01Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":3,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"wire-server-5qh.4","title":"WP-STORE: Bootstrap Hasql pool + minimal Sem runner in gundeck","description":"# Layer (gundeck-side) — Bootstrap Hasql pool + minimal Sem runner in gundeck\n\n## Objective\nGundeck currently has ZERO Postgres/Polysemy footprint (`grep -ciE \"polysemy|hasql|wire-subsystems\" gundeck.cabal` = 0; its `Gundeck` monad is `ReaderT Env IO` with Cassandra `MonadClient`). This bead adds the minimal infrastructure to consume the `WebPushStore` effect from WP-DB: a Hasql connection pool, Postgres options, migration execution at startup, and a thin `Sem` runner embedded in the `Gundeck` monad.\n\n## Decision (confirmed)\n\"Effect in lib, minimal Sem runner in gundeck\" — do NOT rewrite gundeck into a full Polysemy app. Only embed the minimal `Sem` interpreter stack needed for `WebPushStore`.\n\n## Files to change\n- **EDIT** `services/gundeck/gundeck.cabal` — add deps: `wire-subsystems`, `hasql`, `hasql-pool`, `hasql-th`, `polysemy`, `postgresql-libpq` (as transitive), `postgresql-error-codes` (as needed)\n- **EDIT** `services/gundeck/src/Gundeck/Options.hs` — add `PostgresOpts` + `_postgres :: !PostgresOpts` to `Opts`\n- **EDIT** `services/gundeck/src/Gundeck/Env.hs` — acquire `Hasql.Pool` in `createEnv`, store in `Env`; run migrations at startup\n- **EDIT** `services/gundeck/src/Gundeck/Run.hs` (or `Env.hs`) — call `Wire.PostgresMigrations.runAllMigrations` before serving\n- **NEW** `services/gundeck/src/Gundeck/Push/Web/Runner.hs` — the minimal `Sem` runner (below)\n\n## PostgresOpts (Options.hs)\n```haskell\ndata PostgresOpts = PostgresOpts\n { _host :: !Text\n , _port :: !Word16\n , _db :: !Text\n , _user :: !Text\n , _password :: !(Maybe Text) -- from env to avoid committing secrets (pattern: REDIS_PASSWORD, Env.hs:80)\n , _poolSize :: !(Maybe Int)\n }\n deriving (Show, Generic)\nderiveFromJSON toOptionFieldName ''PostgresOpts\nmakeLenses ''PostgresOpts\n```\nAdd `_postgres :: !PostgresOpts` to `Opts` (Gundeck/Options.hs:133). Build the Hasql connection string from these.\n\n## Pool + migrations (Env.hs, createEnv ~line 69)\n```haskell\nimport Hasql.Pool qualified as Hasql\nimport Wire.PostgresMigrations qualified as PgMigrations\n\n-- in createEnv:\npgPool \u003c- Hasql.acquire (poolSize, connSettings) -- connSettings from PostgresOpts\nPgMigrations.runAllMigrations pgPool l -- runs webpush migration from wire-subsystems\n-- store pgPool in Env as _pgPool :: !Hasql.Pool\n```\n\n## Minimal Sem runner (Gundeck/Push/Web/Runner.hs)\n```haskell\nmodule Gundeck.Push.Web.Runner (runWebPush) where\nimport Hasql.Pool (Pool)\nimport Hasql.Errors (UsageError)\nimport Polysemy\nimport Polysemy.Error (runError)\nimport Polysemy.Input (runInputConst)\nimport Polysemy.Embed (embedToFinal, runFinal)\nimport Wire.WebPushStore\nimport Wire.WebPushStore.Postgres (interpretWebPushStoreToPostgres)\n\n-- | Run a WebPushStore effect stack inside the Gundeck monad.\nrunWebPush :: (MonadIO m) =\u003e Pool -\u003e Sem '[WebPushStore, Input Pool, Error UsageError, Embed IO] a -\u003e m (Either UsageError a)\nrunWebPush pool =\n liftIO\n . runFinal @IO\n . embedToFinal\n . runError @UsageError\n . runInputConst pool\n . interpretWebPushStoreToPostgres\n```\nCallers (`addWebSubscription`, `pushWeb`, `nativeTargets` web lookup in WP-HANDLERS / WP-DISPATCH / WP-FLOW) call effects via `runWebPush pool $ insertSubscription ...`, surfacing `UsageError` as the existing `mkError status500` server-error. This replaces the Cassandra `MonadClient` approach used by native push.\n\n## Notes\n- Gundeck now has TWO datastores: Cassandra (native push — unchanged) + Postgres (web push only). That's intentional and incremental.\n- Migration ownership: the `webpush_subscriptions` migration lives in `wire-subsystems/postgres-migrations` (WP-DB); gundeck only *runs* `runAllMigrations`, which executes ALL embedded migrations. Confirm with infra whether gundeck points at a dedicated Postgres DB or a shared schema.\n- Keep the pool sizing modest; web push dispatch is bounded by the push budget (see WP-DISPATCH).\n\n## Acceptance criteria\n- [ ] `make c package=gundeck` compiles with new deps.\n- [ ] Gundeck acquires a Hasql pool and runs migrations at startup.\n- [ ] A trivial `runWebPush pool (pure ())` returns `Right ()` from the `Gundeck` monad.\n\n## References\n- `libs/wire-subsystems/src/Wire/Postgres.hs:94-155` (PGConstraints, runStatement)\n- `libs/wire-subsystems/src/Wire/PostgresMigrations.hs:52` (runAllMigrations)\n- `services/galley/src/Galley/App.hs:407-447` (storage-backend selection + runFinal pipeline, for reference)\n- `services/gundeck/src/Gundeck/Env.hs:69-108` (createEnv — where pool is acquired)\n- `services/gundeck/src/Gundeck/Options.hs:133` (Opts)\n- Depends on: WP-DB (effect + interpreter + migration), WP-CFG (nothing — pool config lives here)\n","status":"closed","priority":2,"issue_type":"task","assignee":"Gautier DI FOLCO","owner":"gautier.difolco@wire.com","created_at":"2026-06-17T14:04:48Z","created_by":"Gautier DI FOLCO","updated_at":"2026-06-17T21:17:07Z","started_at":"2026-06-17T20:51:40Z","closed_at":"2026-06-17T21:17:07Z","close_reason":"Implemented: gundeck acquires a Hasql pool + runs Postgres migrations at startup (Options: _postgresql/_postgresqlPassword/_postgresqlPool via shared libpq convention; Env: initPostgresPool + runAllMigrations in createEnv, stored as _pgPool). Added Gundeck.Push.Web.Runner.runWebPush (minimal Sem runner using runM over WebPushStore/Input/Error/Embed). Unit test runWebPush pool (pure ()) == Right () passes; all 46 gundeck tests green; whole-repo cabal build all clean. Deploy YAMLs deferred to webpush rollout bead (consistent with galley/brig siblings).","labels":["epic","storage","web-push"],"dependencies":[{"issue_id":"wire-server-5qh.4","depends_on_id":"wire-server-5qh","type":"parent-child","created_at":"2026-06-17T16:04:48Z","created_by":"Gautier DI FOLCO","metadata":"{}"},{"issue_id":"wire-server-5qh.4","depends_on_id":"wire-server-5qh.2","type":"blocks","created_at":"2026-06-17T16:06:00Z","created_by":"Gautier DI FOLCO","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} diff --git a/changelog.d/1-api-changes/wp-api-web-push-subscriptions b/changelog.d/1-api-changes/wp-api-web-push-subscriptions new file mode 100644 index 00000000000..00dceb62537 --- /dev/null +++ b/changelog.d/1-api-changes/wp-api-web-push-subscriptions @@ -0,0 +1 @@ +In API version V17, gundeck adds new web push subscription endpoints under `POST/DELETE/GET /push/web/subscriptions` for delivering notifications to web browsers via the W3C Push API (RFC 8030/8291/8292). diff --git a/charts/integration/templates/integration-integration.yaml b/charts/integration/templates/integration-integration.yaml index bf4f676fabd..92fc8e8a53d 100644 --- a/charts/integration/templates/integration-integration.yaml +++ b/charts/integration/templates/integration-integration.yaml @@ -41,6 +41,10 @@ spec: configMap: name: "gundeck" + - name: "gundeck-secrets" + secret: + secretName: "gundeck" + - name: "cargohold-config" configMap: name: "cargohold" @@ -237,6 +241,9 @@ spec: - name: gundeck-config mountPath: /etc/wire/gundeck/conf + - name: gundeck-secrets + mountPath: /etc/wire/gundeck/secrets + - name: cargohold-config mountPath: /etc/wire/cargohold/conf diff --git a/charts/wire-server/templates/gundeck/configmap.yaml b/charts/wire-server/templates/gundeck/configmap.yaml index 9f102742700..839d5b69e57 100644 --- a/charts/wire-server/templates/gundeck/configmap.yaml +++ b/charts/wire-server/templates/gundeck/configmap.yaml @@ -29,6 +29,12 @@ data: tlsCa: /etc/wire/gundeck/cassandra/{{- (include "gundeck.tlsSecretRef" . | fromYaml).key }} {{- end }} + postgresql: {{ toYaml .postgresql | nindent 6 }} + postgresqlPool: {{ toYaml .postgresqlPool | nindent 6 }} + {{- if hasKey $.Values.gundeck.secrets "pgPassword" }} + postgresqlPassword: /etc/wire/gundeck/secrets/pgPassword + {{- end }} + {{- with .rabbitmq }} rabbitmq: host: {{ .host }} diff --git a/charts/wire-server/templates/gundeck/deployment.yaml b/charts/wire-server/templates/gundeck/deployment.yaml index b7d677c88c7..1aa83304a22 100644 --- a/charts/wire-server/templates/gundeck/deployment.yaml +++ b/charts/wire-server/templates/gundeck/deployment.yaml @@ -40,6 +40,11 @@ spec: - name: "gundeck-config" configMap: name: "gundeck" + {{- if not (empty .Values.gundeck.secrets) }} + - name: "gundeck-secrets" + secret: + secretName: "gundeck" + {{- end }} {{- if and .Values.gundeck.config.rabbitmq .Values.gundeck.config.rabbitmq.tlsCaSecretRef }} - name: "rabbitmq-ca" secret: @@ -71,6 +76,10 @@ spec: volumeMounts: - name: "gundeck-config" mountPath: "/etc/wire/gundeck/conf" + {{- if not (empty .Values.gundeck.secrets) }} + - name: "gundeck-secrets" + mountPath: "/etc/wire/gundeck/secrets" + {{- end }} {{- if eq (include "useCassandraTLS" .Values.gundeck.config.cassandra) "true" }} - name: "gundeck-cassandra" mountPath: "/etc/wire/gundeck/cassandra" diff --git a/charts/wire-server/templates/gundeck/secret.yaml b/charts/wire-server/templates/gundeck/secret.yaml index b1f744ff600..000255ba0a6 100644 --- a/charts/wire-server/templates/gundeck/secret.yaml +++ b/charts/wire-server/templates/gundeck/secret.yaml @@ -31,5 +31,8 @@ data: {{- if hasKey . "redisAdditionalWritePassword" }} redisAdditionalWritePassword: {{ .redisAdditionalWritePassword | b64enc | quote }} {{- end }} + {{- if hasKey . "pgPassword" }} + pgPassword: {{ .pgPassword | b64enc | quote }} + {{- end }} {{- end }} {{- end }} diff --git a/charts/wire-server/templates/gundeck/tests/gundeck-integration.yaml b/charts/wire-server/templates/gundeck/tests/gundeck-integration.yaml index b70752b3ead..a6816981d79 100644 --- a/charts/wire-server/templates/gundeck/tests/gundeck-integration.yaml +++ b/charts/wire-server/templates/gundeck/tests/gundeck-integration.yaml @@ -13,6 +13,9 @@ spec: - name: "gundeck-config" configMap: name: "gundeck" + - name: "gundeck-secrets" + secret: + secretName: "gundeck" {{- if eq (include "useCassandraTLS" .Values.gundeck.config.cassandra) "true" }} - name: "gundeck-cassandra" secret: @@ -69,6 +72,8 @@ spec: mountPath: "/etc/wire/integration" - name: "gundeck-config" mountPath: "/etc/wire/gundeck/conf" + - name: "gundeck-secrets" + mountPath: "/etc/wire/gundeck/secrets" {{- if eq (include "useCassandraTLS" .Values.gundeck.config.cassandra) "true" }} - name: "gundeck-cassandra" mountPath: "/etc/wire/gundeck/cassandra" diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index 1692ffb9b37..a4c38f69f18 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -764,6 +764,24 @@ gundeck: # To enable cells notifications # cellsEventQueue: cells_events + # Postgres connection settings for web push subscription storage. + # Web push is the first gundeck subsystem backed by Postgres; Cassandra + # remains the store for native push. Migrations run unconditionally at + # startup, so these settings are required even when web push is disabled. + # + # Values are described in https://www.postgresql.org/docs/17/libpq-connect.html#LIBPQ-PARAMKEYWORDS + # To set the password via a gundeck secret see `secrets.pgPassword`. + postgresql: + host: postgresql # DNS name without protocol + port: "5432" + user: wire-server + dbname: wire-server + postgresqlPool: + size: 100 + acquisitionTimeout: 10s + agingTimeout: 1d + idlenessTimeout: 10m + serviceAccount: # When setting this to 'false', either make sure that a service account named # 'gundeck' exists or change the 'name' field to 'default' diff --git a/deploy/dockerephemeral/federation-v2/gundeck.yaml b/deploy/dockerephemeral/federation-v2/gundeck.yaml index 51be1566f2a..10338069dfe 100644 --- a/deploy/dockerephemeral/federation-v2/gundeck.yaml +++ b/deploy/dockerephemeral/federation-v2/gundeck.yaml @@ -50,5 +50,17 @@ settings: disabledAPIVersions: [] cellsEventQueue: "cells_events" +# Web Push (RFC 8030/8291/8292). Optional: omit this section to disable web +# push entirely. When enabled, gundeck stores browser push subscriptions and +# POSTs encrypted notifications to them. The VAPID private key is a secret — +# prefer the GUNDECK_WEBPUSH_VAPID_PRIVATE_KEY env var over committing it here. +# webpush: +# vapidSubject: "mailto:ops@wire.com" +# vapidPrivateKey: "BASE64URL_P256_PRIVATE_KEY" # 32-byte scalar, base64url +# endpointAllowlist: # SSRF allowlist (host suffixes) +# - "fcm.googleapis.com" +# - "updates.push.services.mozilla.com" +# defaultTTL: 2419200 # seconds (~4 weeks) + logLevel: Warn logNetStrings: false diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index d3922380f9d..4e3a56f5f6e 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -505,10 +505,21 @@ gundeck: hard: 30 soft: 10 cellsEventQueue: cells_events + postgresql: + host: "postgresql" + port: "5432" + user: wire-server + dbname: wire-server + postgresqlPool: + size: 100 + acquisitionTimeout: 10s + agingTimeout: 1d + idlenessTimeout: 10m secrets: awsKeyId: dummykey awsSecretKey: dummysecret redisPassword: very-secure-redis-master-password + pgPassword: posty-the-gres rabbitmq: username: {{ .Values.rabbitmqUsername }} password: {{ .Values.rabbitmqPassword }} diff --git a/integration/test/Testlib/ModService.hs b/integration/test/Testlib/ModService.hs index 399aa950ee2..36e5296aa20 100644 --- a/integration/test/Testlib/ModService.hs +++ b/integration/test/Testlib/ModService.hs @@ -254,6 +254,7 @@ defaultOverrides resource = def { brigCfg = setField "postgresql.dbname" resource.berPostgresqlDBName, galleyCfg = setField "postgresql.dbname" resource.berPostgresqlDBName, + gundeckCfg = setField "postgresql.dbname" resource.berPostgresqlDBName, backgroundWorkerCfg = setField "postgresql.dbname" resource.berPostgresqlDBName } diff --git a/libs/wire-api/src/Wire/API/Error/Gundeck.hs b/libs/wire-api/src/Wire/API/Error/Gundeck.hs index 2fa60fbad31..b8f99aff20a 100644 --- a/libs/wire-api/src/Wire/API/Error/Gundeck.hs +++ b/libs/wire-api/src/Wire/API/Error/Gundeck.hs @@ -29,6 +29,9 @@ data GundeckError | AddTokenErrorApnsVoipNotSupported | TokenNotFound | NotificationNotFound + | WebPushErrorInvalid + | WebPushSubscriptionNotFound + | WebPushErrorTooMany instance (Typeable (MapError e), KnownError (MapError e)) => IsSwaggerError (e :: GundeckError) where addToOpenApi = addStaticErrorToSwagger @(MapError e) @@ -48,3 +51,9 @@ type instance MapError 'AddTokenErrorApnsVoipNotSupported = 'StaticError 400 "ap type instance MapError 'TokenNotFound = 'StaticError 404 "not-found" "Push token not found" type instance MapError 'NotificationNotFound = 'StaticError 404 "not-found" "Some notifications not found" + +type instance MapError 'WebPushErrorInvalid = 'StaticError 400 "invalid-web-push-subscription" "Invalid web push subscription" + +type instance MapError 'WebPushSubscriptionNotFound = 'StaticError 404 "not-found" "Web push subscription not found" + +type instance MapError 'WebPushErrorTooMany = 'StaticError 413 "too-many-web-push-subscriptions" "Too many web push subscriptions" diff --git a/libs/wire-api/src/Wire/API/PostgresMarshall.hs b/libs/wire-api/src/Wire/API/PostgresMarshall.hs index e1a6f55f18d..3e66226c7b9 100644 --- a/libs/wire-api/src/Wire/API/PostgresMarshall.hs +++ b/libs/wire-api/src/Wire/API/PostgresMarshall.hs @@ -28,6 +28,7 @@ where import Data.Aeson import Data.Bifunctor (first) import Data.ByteString qualified as BS +import Data.ByteString.Base64.URL qualified as B64U import Data.ByteString.Conversion (toByteString') import Data.ByteString.Conversion qualified as BSC import Data.Code qualified as Code @@ -49,6 +50,7 @@ import Hasql.Statement import Imports import SAML2.WebSSO qualified as SAML import Wire.API.EnterpriseLogin +import Wire.API.Push.V2.WebSubscription class PostgresMarshall db domain where postgresMarshall :: domain -> db @@ -536,6 +538,9 @@ instance PostgresMarshall Value Object where instance PostgresMarshall Int64 Milliseconds where postgresMarshall = msToInt64 +instance PostgresMarshall Int64 Word64 where + postgresMarshall = fromIntegral + instance PostgresMarshall Text Domain where postgresMarshall = domainText @@ -590,6 +595,18 @@ instance PostgresMarshall Int32 TeamInviteTag where instance PostgresMarshall UUID SAML.IdPId where postgresMarshall = SAML.fromIdPId +instance PostgresMarshall Text EndpointUrl where + postgresMarshall = endpointUrlText + +instance PostgresMarshall Text P256dhKey where + postgresMarshall = Text.decodeUtf8 . B64U.encodeUnpadded . p256dhKeyBytes + +instance PostgresMarshall Text AuthSecret where + postgresMarshall = Text.decodeUtf8 . B64U.encodeUnpadded . authSecretBytes + +instance PostgresMarshall ByteString ConnId where + postgresMarshall = fromConnId + --- class PostgresUnmarshall db domain where @@ -1021,6 +1038,9 @@ instance (PostgresUnmarshall a b, Ord b) => PostgresUnmarshall (Vector a) (Set b instance PostgresUnmarshall Int64 Milliseconds where postgresUnmarshall = Right . int64ToMs +instance PostgresUnmarshall Int64 Word64 where + postgresUnmarshall = Right . fromIntegral + instance PostgresUnmarshall Text Code.Key where postgresUnmarshall = mapLeft Text.pack . BSC.runParser BSC.parser . Text.encodeUtf8 @@ -1059,6 +1079,24 @@ instance PostgresUnmarshall UUID SAML.IdPId where instance PostgresUnmarshall Text Handle where postgresUnmarshall = mapLeft Text.pack . parseHandleEither +instance PostgresUnmarshall Text EndpointUrl where + postgresUnmarshall = mapLeft Text.pack . mkEndpointUrl + +instance PostgresUnmarshall Text P256dhKey where + postgresUnmarshall t = + case B64U.decodeUnpadded (Text.encodeUtf8 t) of + Left e -> Left $ "Invalid base64url p256dh key: " <> Text.pack e + Right bs -> mapLeft Text.pack (mkP256dhKey bs) + +instance PostgresUnmarshall Text AuthSecret where + postgresUnmarshall t = + case B64U.decodeUnpadded (Text.encodeUtf8 t) of + Left e -> Left $ "Invalid base64url auth secret: " <> Text.pack e + Right bs -> mapLeft Text.pack (mkAuthSecret bs) + +instance PostgresUnmarshall ByteString ConnId where + postgresUnmarshall = Right . ConnId + instance PostgresUnmarshall UTCTime UTCTimeMillis where postgresUnmarshall = Right . toUTCTimeMillis diff --git a/libs/wire-api/src/Wire/API/Push/V2.hs b/libs/wire-api/src/Wire/API/Push/V2.hs index a0258c66d09..8d22864401b 100644 --- a/libs/wire-api/src/Wire/API/Push/V2.hs +++ b/libs/wire-api/src/Wire/API/Push/V2.hs @@ -68,6 +68,25 @@ module Wire.API.Push.V2 Token (..), Transport (..), AppName (..), + + -- * WebPushSubscription (re-export) + WebPushSubscriptionList (..), + WebPushSubscription, + webPushSubscription, + wpsEndpoint, + wpsKeys, + wpsExpirationTime, + wpsClient, + EndpointUrl (..), + P256dhKey (..), + AuthSecret (..), + WebPushKeys (..), + AddWebPushError (..), + AddWebPushSuccess (..), + AddWebPushResponses, + DeleteWebPushRequest (..), + DeleteWebPushResponses, + VapidPublicKeyResponse (..), ) where @@ -86,6 +105,7 @@ import Imports import Test.QuickCheck (oneof) import Wire.API.Message (Priority (..)) import Wire.API.Push.V2.Token +import Wire.API.Push.V2.WebSubscription import Wire.Arbitrary ----------------------------------------------------------------------------- diff --git a/libs/wire-api/src/Wire/API/Push/V2/WebSubscription.hs b/libs/wire-api/src/Wire/API/Push/V2/WebSubscription.hs new file mode 100644 index 00000000000..7585a476988 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Push/V2/WebSubscription.hs @@ -0,0 +1,346 @@ +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StrictData #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Push.V2.WebSubscription + ( -- * WebPushSubscription + WebPushSubscriptionList (..), + WebPushSubscription, + webPushSubscription, + wpsEndpoint, + wpsKeys, + wpsExpirationTime, + wpsClient, + + -- * WebPushSubscription fields + EndpointUrl (..), + mkEndpointUrl, + P256dhKey (..), + mkP256dhKey, + AuthSecret (..), + mkAuthSecret, + WebPushKeys (..), + wpkP256dh, + wpkAuth, + + -- * API types + AddWebPushError (..), + AddWebPushSuccess (..), + AddWebPushResponses, + DeleteWebPushRequest (..), + DeleteWebPushResponses, + + -- * VAPID public key + VapidPublicKeyResponse (..), + ) +where + +import Control.Lens (makeLenses, (?~), (^.)) +import Data.Aeson qualified as A +import Data.Bifunctor (first) +import Data.ByteString qualified as BS +import Data.ByteString.Base64.URL qualified as B64U +import Data.Id (ClientId) +import Data.OpenApi (ToParamSchema (..)) +import Data.OpenApi qualified as S +import Data.SOP +import Data.Schema +import Data.Text qualified as Text +import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Generics.SOP qualified as GSOP +import Imports +import Servant (FromHttpApiData (..), Header, ToHttpApiData (..), type (.++)) +import Test.QuickCheck (Arbitrary (arbitrary)) +import URI.ByteString (schemeBSL, strictURIParserOptions, uriSchemeL) +import URI.ByteString qualified as URI +import Wire.API.Error +import Wire.API.Error.Gundeck qualified as E +import Wire.API.Routes.MultiVerb +import Wire.Arbitrary (GenericUniform (..)) + +-------------------------------------------------------------------------------- +-- EndpointUrl + +-- | The HTTPS endpoint of a browser push service to which encrypted web push +-- notifications are delivered (RFC 8030). The URL must use the @https:@ scheme; +-- non-HTTPS endpoints are rejected to avoid leaking the user identity over an +-- insecure transport (RFC 8030 §8.2). +newtype EndpointUrl = EndpointUrl + { endpointUrlText :: Text + } + deriving stock (Eq, Ord, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema EndpointUrl) + +mkEndpointUrl :: Text -> Either String EndpointUrl +mkEndpointUrl raw = do + uri <- first show (URI.parseURI strictURIParserOptions (encodeUtf8 raw)) + if uri ^. uriSchemeL . schemeBSL == "https" + then Right (EndpointUrl raw) + else Left ("Non-HTTPS endpoint URL: " <> show raw) + +instance ToSchema EndpointUrl where + schema = + endpointUrlText + .= parsedText "EndpointUrl" mkEndpointUrl + +instance ToParamSchema EndpointUrl where + toParamSchema _ = S.toParamSchema (Proxy @Text) + +instance ToHttpApiData EndpointUrl where + toUrlPiece = endpointUrlText + +instance FromHttpApiData EndpointUrl where + parseUrlPiece = first Text.pack . mkEndpointUrl + +instance Arbitrary EndpointUrl where + arbitrary = pure $ EndpointUrl "https://example.com/webpush/subscription" + +-------------------------------------------------------------------------------- +-- P256dhKey + +-- | The client's ECDH P-256 public key, in raw uncompressed form (65 bytes: +-- @0x04@ followed by the 32-byte X and 32-byte Y coordinates), as defined by +-- RFC 8291 §3.1. It is transported base64url-encoded and stored decoded. +newtype P256dhKey = P256dhKey + { p256dhKeyBytes :: BS.ByteString + } + deriving stock (Eq, Ord, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema P256dhKey) + +mkP256dhKey :: BS.ByteString -> Either String P256dhKey +mkP256dhKey bs + | BS.length bs == 65 = Right (P256dhKey bs) + | otherwise = + Left $ + "Invalid p256dh key: expected 65 bytes, got " <> show (BS.length bs) + +instance ToSchema P256dhKey where + schema = + (decodeUtf8 . B64U.encodeUnpadded . p256dhKeyBytes) + .= parsedText "P256dhKey" parseP256dh + where + parseP256dh :: Text -> Either String P256dhKey + parseP256dh t = do + bs <- first show (B64U.decodeUnpadded (encodeUtf8 t)) + mkP256dhKey bs + +instance Arbitrary P256dhKey where + arbitrary = P256dhKey . BS.pack <$> replicateM 65 (arbitrary @Word8) + +-------------------------------------------------------------------------------- +-- AuthSecret + +-- | The authentication secret shared between the application server and the +-- push service (RFC 8291 §2, 16 random bytes), transported base64url-encoded +-- and stored decoded. +newtype AuthSecret = AuthSecret + { authSecretBytes :: BS.ByteString + } + deriving stock (Eq, Ord, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema AuthSecret) + +mkAuthSecret :: BS.ByteString -> Either String AuthSecret +mkAuthSecret bs + | BS.length bs == 16 = Right (AuthSecret bs) + | otherwise = + Left $ + "Invalid auth secret: expected 16 bytes, got " <> show (BS.length bs) + +instance ToSchema AuthSecret where + schema = + (decodeUtf8 . B64U.encodeUnpadded . authSecretBytes) + .= parsedText "AuthSecret" parseAuthSecret + where + parseAuthSecret :: Text -> Either String AuthSecret + parseAuthSecret t = do + bs <- first show (B64U.decodeUnpadded (encodeUtf8 t)) + mkAuthSecret bs + +instance Arbitrary AuthSecret where + arbitrary = AuthSecret . BS.pack <$> replicateM 16 (arbitrary @Word8) + +-------------------------------------------------------------------------------- +-- WebPushKeys + +data WebPushKeys = WebPushKeys + { _wpkP256dh :: !P256dhKey, + _wpkAuth :: !AuthSecret + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform WebPushKeys) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema WebPushKeys) + +instance ToSchema WebPushKeys where + schema = + object $ + WebPushKeys + <$> _wpkP256dh + .= field "p256dh" schema + <*> _wpkAuth + .= field "auth" schema + +-------------------------------------------------------------------------------- +-- WebPushSubscription + +data WebPushSubscription = WebPushSubscription + { _wpsEndpoint :: !EndpointUrl, + _wpsKeys :: !WebPushKeys, + -- | Optional expiry, in milliseconds since the Unix epoch. 'Nothing' means + -- the subscription does not expire (W3C Push API §3.4). + _wpsExpirationTime :: !(Maybe Word64), + _wpsClient :: !ClientId + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform WebPushSubscription) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema WebPushSubscription) + +instance ToSchema WebPushSubscription where + schema = + object $ + WebPushSubscription + <$> _wpsEndpoint + .= field "endpoint" schema + <*> _wpsKeys + .= field "keys" schema + <*> _wpsExpirationTime + .= maybe_ (optField "expiration_time" schema) + <*> _wpsClient + .= field "client" schema + +webPushSubscription :: + EndpointUrl -> + WebPushKeys -> + Maybe Word64 -> + ClientId -> + WebPushSubscription +webPushSubscription = WebPushSubscription + +newtype WebPushSubscriptionList = WebPushSubscriptionList + { wpsList :: [WebPushSubscription] + } + deriving stock (Eq, Show) + deriving newtype (Arbitrary) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema WebPushSubscriptionList) + +instance ToSchema WebPushSubscriptionList where + schema = + objectWithDocModifier (description ?~ "List of Web Push Subscriptions") $ + WebPushSubscriptionList + <$> wpsList + .= fieldWithDocModifier "subscriptions" (description ?~ "Web push subscriptions") (array schema) + +makeLenses ''WebPushKeys +makeLenses ''WebPushSubscription + +-------------------------------------------------------------------------------- +-- Add web push subscription types + +type AddWebPushErrorResponses = + '[ ErrorResponse 'E.WebPushErrorInvalid, + ErrorResponse 'E.WebPushErrorTooMany + ] + +type AddWebPushSuccessResponses = + WithHeaders + '[ Header "Location" EndpointUrl + ] + AddWebPushSuccess + (Respond 201 "Web push subscription registered" WebPushSubscription) + +type AddWebPushResponses = AddWebPushErrorResponses .++ '[AddWebPushSuccessResponses] + +data AddWebPushError + = AddWebPushErrorInvalid + | AddWebPushErrorTooMany + deriving (Show, Generic) + deriving (AsUnion AddWebPushErrorResponses) via GenericAsUnion AddWebPushErrorResponses AddWebPushError + +instance GSOP.Generic AddWebPushError + +data AddWebPushSuccess = AddWebPushSuccess WebPushSubscription + +instance AsHeaders '[EndpointUrl] WebPushSubscription AddWebPushSuccess where + fromHeaders (I _ :* Nil, sub) = AddWebPushSuccess sub + toHeaders (AddWebPushSuccess sub) = (I (sub ^. wpsEndpoint) :* Nil, sub) + +instance (res ~ AddWebPushResponses) => AsUnion res (Either AddWebPushError AddWebPushSuccess) where + toUnion = eitherToUnion (toUnion @AddWebPushErrorResponses) (Z . I) + fromUnion = eitherFromUnion (fromUnion @AddWebPushErrorResponses) (unI . unZ) + +-------------------------------------------------------------------------------- +-- Delete web push subscription types + +newtype DeleteWebPushRequest = DeleteWebPushRequest + { deleteWebPushRequestEndpoint :: EndpointUrl + } + deriving stock (Eq, Show, Generic) + deriving newtype (Arbitrary) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema DeleteWebPushRequest) + +instance ToSchema DeleteWebPushRequest where + schema = + object $ + DeleteWebPushRequest + <$> deleteWebPushRequestEndpoint + .= field "endpoint" schema + +type DeleteWebPushResponses = + '[ ErrorResponse 'E.WebPushSubscriptionNotFound, + RespondEmpty 204 "Web push subscription unregistered" + ] + +-------------------------------------------------------------------------------- +-- VAPID public key response + +-- | Response body for @GET /push/web/vapid-public-key@. +-- +-- Carries the server's static VAPID P-256 public key (RFC 8292), in the wire +-- format expected by browsers as @applicationServerKey@: base64url-encoded +-- without padding, representing the uncompressed point (@0x04 || X || Y@, 65 +-- bytes). Web clients pass this value to +-- @pushManager.subscribe({ applicationServerKey })@ (W3C Push API §3.5). +-- +-- The key is derived once at startup from the configured private key (see +-- 'Gundeck.Env.VapidKeyPair' and its '_vkpPublicB64' field) and is stable for +-- the lifetime of the configured keypair — clients may cache it aggressively. +newtype VapidPublicKeyResponse = VapidPublicKeyResponse + { vapidPublicKeyResponseKey :: Text + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform VapidPublicKeyResponse) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema VapidPublicKeyResponse) + +instance ToSchema VapidPublicKeyResponse where + schema = + objectWithDocModifier vapidPublicKeyDoc $ + VapidPublicKeyResponse + <$> vapidPublicKeyResponseKey + .= fieldWithDocModifier + "key" + (description ?~ "base64url-unpadded uncompressed P-256 public point") + schema + where + vapidPublicKeyDoc = + description + ?~ "The server's static VAPID public key (RFC 8292), to be passed as\ + \ @applicationServerKey@ to @pushManager.subscribe()@." diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs b/libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs index f37500dd196..a76d0498ea6 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs @@ -25,13 +25,76 @@ import Wire.API.Error import Wire.API.Error.Gundeck as E import Wire.API.Notification import Wire.API.Push.V2.Token +import Wire.API.Push.V2.WebSubscription + ( AddWebPushError, + AddWebPushResponses, + AddWebPushSuccess, + DeleteWebPushRequest, + DeleteWebPushResponses, + VapidPublicKeyResponse, + WebPushSubscription, + WebPushSubscriptionList, + ) import Wire.API.Routes.API import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public import Wire.API.Routes.Version -type GundeckAPI = PushAPI :<|> NotificationAPI :<|> TimeAPI +type GundeckAPI = PushAPI :<|> WebPushAPI :<|> NotificationAPI :<|> TimeAPI + +type WebPushAPI = + Named + "register-web-push-subscription" + ( Summary "Register a web push subscription" + :> From 'V17 + :> ZUser + :> ZConn + :> "push" + :> "web" + :> "subscriptions" + :> ReqBody '[JSON] WebPushSubscription + :> MultiVerb 'POST '[JSON] AddWebPushResponses (Either AddWebPushError AddWebPushSuccess) + ) + :<|> Named + "delete-web-push-subscription" + ( Summary "Unregister a web push subscription" + :> From 'V17 + :> ZUser + :> "push" + :> "web" + :> "subscriptions" + :> "delete" + :> ReqBody '[JSON] DeleteWebPushRequest + :> MultiVerb 'POST '[JSON] DeleteWebPushResponses (Maybe ()) + ) + :<|> Named + "get-web-push-subscriptions" + ( Summary "List the user's registered web push subscriptions" + :> From 'V17 + :> ZUser + :> "push" + :> "web" + :> "subscriptions" + :> Get + '[JSON] + WebPushSubscriptionList + ) + :<|> Named + "get-vapid-public-key" + ( Summary "Get the server's VAPID public key" + :> Description + "Returns the base64url uncompressed P-256 public key that \ + \web clients must pass as `applicationServerKey` to \ + \`pushManager.subscribe()` (RFC 8292). No authentication: \ + \the key is browser-facing by definition and must be \ + \available before a session exists." + :> From 'V17 + :> "push" + :> "web" + :> "vapid-public-key" + :> Get '[JSON] VapidPublicKeyResponse + ) type PushAPI = Named diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs index 02dc1d21e43..2443b6ec37b 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs @@ -231,6 +231,7 @@ import Test.Wire.API.Golden.Generated.VerificationAction_user qualified import Test.Wire.API.Golden.Generated.VerifyDeleteUser_user qualified import Test.Wire.API.Golden.Generated.ViewLegalHoldServiceInfo_team qualified import Test.Wire.API.Golden.Generated.ViewLegalHoldService_team qualified +import Test.Wire.API.Golden.Generated.WebPushSubscription_user qualified import Test.Wire.API.Golden.Generated.Wrapped_20_22some_5fint_22_20Int_user qualified import Test.Wire.API.Golden.Runner import Wire.API.Routes.Version @@ -837,6 +838,15 @@ tests = "testObject_PushTokenList_user_2.json" ) ], + testGroup "Golden: WebPushSubscription_user" $ + testObjects + [ ( Test.Wire.API.Golden.Generated.WebPushSubscription_user.testObject_WebPushSubscription_user_1, + "testObject_WebPushSubscription_user_1.json" + ), + ( Test.Wire.API.Golden.Generated.WebPushSubscription_user.testObject_WebPushSubscription_user_2, + "testObject_WebPushSubscription_user_2.json" + ) + ], testGroup "Golden: NameUpdate_user" $ testObjects [ ( Test.Wire.API.Golden.Generated.NameUpdate_user.testObject_NameUpdate_user_1, diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/WebPushSubscription_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/WebPushSubscription_user.hs new file mode 100644 index 00000000000..be1f254292f --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/WebPushSubscription_user.hs @@ -0,0 +1,52 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Generated.WebPushSubscription_user where + +import Data.ByteString.Char8 qualified as BC8 +import Data.Id (ClientId (..)) +import Imports (Maybe (Just, Nothing)) +import Wire.API.Push.V2.WebSubscription + ( AuthSecret (..), + EndpointUrl (..), + P256dhKey (..), + WebPushKeys (..), + WebPushSubscription, + webPushSubscription, + ) + +-- | 0x04 (uncompressed point marker, RFC 8291) followed by 64 zero bytes. +testP256dh :: P256dhKey +testP256dh = P256dhKey (BC8.cons '\x04' (BC8.replicate 64 '\x00')) + +testObject_WebPushSubscription_user_1 :: WebPushSubscription +testObject_WebPushSubscription_user_1 = + webPushSubscription + (EndpointUrl "https://fcm.googleapis.com/fcm/send/cid") + (WebPushKeys testP256dh (AuthSecret (BC8.replicate 16 '\xA0'))) + (Just 1750291200000) + (ClientId 0x17) + +testObject_WebPushSubscription_user_2 :: WebPushSubscription +testObject_WebPushSubscription_user_2 = + webPushSubscription + (EndpointUrl "https://example.com/webpush/sub/2") + (WebPushKeys testP256dh (AuthSecret (BC8.replicate 16 '\x07'))) + Nothing + (ClientId 0x2) diff --git a/libs/wire-api/test/golden/testObject_WebPushSubscription_user_1.json b/libs/wire-api/test/golden/testObject_WebPushSubscription_user_1.json new file mode 100644 index 00000000000..c492c36b3dc --- /dev/null +++ b/libs/wire-api/test/golden/testObject_WebPushSubscription_user_1.json @@ -0,0 +1,9 @@ +{ + "client": "17", + "endpoint": "https://fcm.googleapis.com/fcm/send/cid", + "expiration_time": 1750291200000, + "keys": { + "auth": "oKCgoKCgoKCgoKCgoKCgoA", + "p256dh": "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} diff --git a/libs/wire-api/test/golden/testObject_WebPushSubscription_user_2.json b/libs/wire-api/test/golden/testObject_WebPushSubscription_user_2.json new file mode 100644 index 00000000000..32f410264f6 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_WebPushSubscription_user_2.json @@ -0,0 +1,8 @@ +{ + "client": "2", + "endpoint": "https://example.com/webpush/sub/2", + "keys": { + "auth": "BwcHBwcHBwcHBwcHBwcHBw", + "p256dh": "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } +} diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index b66a79c6076..36566aaa775 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -52,6 +52,7 @@ import Wire.API.Provider.External qualified as Provider.External import Wire.API.Provider.Service qualified as Provider.Service import Wire.API.Provider.Service.Tag qualified as Provider.Service.Tag import Wire.API.Push.Token qualified as Push.Token +import Wire.API.Push.V2 qualified as Push.V2 import Wire.API.Routes.FederationDomainConfig qualified as FederationDomainConfig import Wire.API.Routes.Internal.Brig.EJPD qualified as EJPD import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as TeamsIntra @@ -211,6 +212,13 @@ tests = testRoundTrip @Push.Token.AppName, testRoundTrip @Push.Token.PushToken, testRoundTrip @Push.Token.PushTokenList, + testRoundTrip @Push.V2.EndpointUrl, + testRoundTrip @Push.V2.P256dhKey, + testRoundTrip @Push.V2.AuthSecret, + testRoundTrip @Push.V2.WebPushKeys, + testRoundTrip @Push.V2.WebPushSubscription, + testRoundTrip @Push.V2.WebPushSubscriptionList, + testRoundTrip @Push.V2.DeleteWebPushRequest, testRoundTrip @Scim.CreateScimToken, testRoundTrip @Scim.CreateScimTokenResponse, testRoundTrip @SystemSettings.SystemSettings, diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs index 191f55bffd0..513a00f8b30 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs @@ -23,6 +23,7 @@ import Crypto.KDF.Argon2 qualified as Argon2 import Data.Aeson as A import Data.ByteString.Char8 qualified as BS8 import Data.Code qualified as Code +import Data.Id (ConnId) import Data.Misc (PlainTextPassword8, fromPlainTextPassword) import Data.Text.Encoding (encodeUtf8) import Imports @@ -33,6 +34,7 @@ import Wire.API.Password as Password import Wire.API.Password.Argon2id (Argon2HashedPassword (..), encodeArgon2HashedPassword) import Wire.API.Password.Scrypt (encodeScryptPassword) import Wire.API.PostgresMarshall +import Wire.API.Push.V2.WebSubscription import Wire.API.Team.Feature import Wire.Arbitrary qualified as Arbitrary () @@ -44,7 +46,12 @@ tests = testRoundTrip @ByteString @Password.Password, testRoundTrip @Int32 @FeatureStatus, testRoundTrip @Int32 @LockStatus, - testRoundTrip @A.Value @DbConfig + testRoundTrip @A.Value @DbConfig, + testRoundTrip @Text @EndpointUrl, + testRoundTrip @Text @P256dhKey, + testRoundTrip @Text @AuthSecret, + testRoundTrip @ByteString @ConnId, + testRoundTrip @Int64 @Word64 ] testRoundTrip :: diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 841fc2114b6..49b8d1abf76 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -163,6 +163,7 @@ library Wire.API.Push.Token Wire.API.Push.V2 Wire.API.Push.V2.Token + Wire.API.Push.V2.WebSubscription Wire.API.RawJson Wire.API.Routes.API Wire.API.Routes.AssetBody @@ -612,6 +613,7 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Generated.VerifyDeleteUser_user Test.Wire.API.Golden.Generated.ViewLegalHoldService_team Test.Wire.API.Golden.Generated.ViewLegalHoldServiceInfo_team + Test.Wire.API.Golden.Generated.WebPushSubscription_user Test.Wire.API.Golden.Generated.Wrapped_20_22some_5fint_22_20Int_user Test.Wire.API.Golden.Manual Test.Wire.API.Golden.Manual.Activate_user diff --git a/libs/wire-subsystems/postgres-migrations/20260617140000-create-webpush-subscriptions.sql b/libs/wire-subsystems/postgres-migrations/20260617140000-create-webpush-subscriptions.sql new file mode 100644 index 00000000000..72e399dc29b --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260617140000-create-webpush-subscriptions.sql @@ -0,0 +1,19 @@ +-- Web Push subscriptions (W3C Push API / RFC 8030 application-server side). +-- +-- One row per (user, client, endpoint) tuple: a single client may hold +-- distinct subscriptions for different browser push services, and a single +-- user may hold subscriptions across multiple clients. Re-registering the +-- same endpoint upserts (refreshes keys / expiry) rather than duplicating. +-- +-- The leading PRIMARY KEY column (user_id) doubles as the index for the +-- per-user dispatch lookup hot path (LookupSubscriptions). +CREATE TABLE webpush_subscriptions ( + user_id uuid NOT NULL, + client_id text NOT NULL, -- ClientId is a Word64 rendered as hex text + endpoint text NOT NULL, -- browser push-service URL (RFC 8030) + p256dh text NOT NULL, -- base64url ECDH P-256 pub key (65B uncompressed, RFC 8291) + auth text NOT NULL, -- base64url auth secret (16B, RFC 8291) + expiration bigint, -- ms since epoch; NULL = no expiry + conn_id bytea NOT NULL, + PRIMARY KEY (user_id, client_id, endpoint) +); diff --git a/libs/wire-subsystems/src/Wire/WebPushStore.hs b/libs/wire-subsystems/src/Wire/WebPushStore.hs new file mode 100644 index 00000000000..5d2d25c8f2f --- /dev/null +++ b/libs/wire-subsystems/src/Wire/WebPushStore.hs @@ -0,0 +1,112 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.WebPushStore where + +import Control.Lens ((^.)) +import Data.Id +import Data.UUID +import Imports +import Polysemy +import Wire.API.PostgresMarshall +import Wire.API.Push.V2.WebSubscription + +-- | A web push subscription resolved at dispatch time. The dispatcher fetches +-- these for a user, then POSTs an RFC 8030 message to each 'wpaEndpoint', +-- encrypting for the recipient's 'wpaKeys'. Analogous to the native +-- 'Gundeck.Push.Native.Types.Address'. +data WebPushAddress = WebPushAddress + { wpaUser :: !UserId, + wpaConn :: !ConnId, + wpaClient :: !ClientId, + wpaEndpoint :: !EndpointUrl, + wpaKeys :: !WebPushKeys + } + deriving stock (Eq) + +-- | Custom 'Show' that omits 'wpaKeys', mirroring the native +-- 'Gundeck.Push.Native.Types.Address' Show instance. 'wpaKeys' carries the RFC +-- 8291 auth secret which must not leak into logs or error contexts. +instance Show WebPushAddress where + show a = + showString "WebPushAddress" + . showString "{ user = " + . shows a.wpaUser + . showString ", conn = " + . shows a.wpaConn + . showString ", client = " + . shows a.wpaClient + . showString ", endpoint = " + . shows a.wpaEndpoint + . showString ", keys = " + $ "}" + +-- The 'PostgresMarshall' instances live here (next to the type they marshal), +-- matching the convention used by 'Wire.AppStore.StoredApp'. Keeping them here +-- avoids orphan instances. +instance + PostgresMarshall + (UUID, Text, Text, Text, Text, ByteString) + WebPushAddress + where + postgresMarshall a = + ( postgresMarshall a.wpaUser, + postgresMarshall a.wpaClient, + postgresMarshall a.wpaEndpoint, + postgresMarshall (a.wpaKeys ^. wpkP256dh), + postgresMarshall (a.wpaKeys ^. wpkAuth), + postgresMarshall a.wpaConn + ) + +instance + PostgresUnmarshall + (UUID, Text, Text, Text, Text, ByteString) + WebPushAddress + where + postgresUnmarshall (uid, client, endpoint, p256dh, auth, conn) = do + u <- postgresUnmarshall @UUID @UserId uid + c <- postgresUnmarshall @Text @ClientId client + ep <- postgresUnmarshall @Text @EndpointUrl endpoint + p <- postgresUnmarshall @Text @P256dhKey p256dh + a <- postgresUnmarshall @Text @AuthSecret auth + conn' <- postgresUnmarshall @ByteString @ConnId conn + pure $ WebPushAddress u conn' c ep (WebPushKeys p a) + +-- | Persistence effect for web push subscriptions. Stores browser-supplied +-- push-service endpoints so gundeck can deliver notifications via the W3C Push +-- API (acting as the RFC 8030 application server). +-- +-- This is a library concern in @wire-subsystems@ (Postgres-backed), decoupled +-- from gundeck's legacy Cassandra stack. +data WebPushStore m a where + -- | Register or refresh a subscription. Upserting on + -- @(user, client, endpoint)@ so re-registration updates keys / expiry. + InsertSubscription :: UserId -> WebPushSubscription -> ConnId -> WebPushStore m () + -- | Fetch all subscriptions for a user (the per-user dispatch hot path). + LookupSubscriptions :: UserId -> WebPushStore m [WebPushAddress] + -- | Remove a single subscription identified by its endpoint. + DeleteSubscription :: UserId -> EndpointUrl -> WebPushStore m () + -- | Remove every subscription for a user (GDPR / account deletion). + DeleteAllForUser :: UserId -> WebPushStore m () + -- | Drop subscriptions whose 'wpsExpirationTime' has passed. Called + -- periodically by the dispatcher to keep the table from accumulating stale + -- rows that the push service would only reject anyway. + PurgeExpired :: UserId -> WebPushStore m () + +makeSem ''WebPushStore diff --git a/libs/wire-subsystems/src/Wire/WebPushStore/Postgres.hs b/libs/wire-subsystems/src/Wire/WebPushStore/Postgres.hs new file mode 100644 index 00000000000..1e873f5512f --- /dev/null +++ b/libs/wire-subsystems/src/Wire/WebPushStore/Postgres.hs @@ -0,0 +1,147 @@ +{-# LANGUAGE RecordWildCards #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.WebPushStore.Postgres + ( interpretWebPushStoreToPostgres, + ) +where + +import Control.Lens ((^.)) +import Data.Id +import Hasql.Statement qualified as Hasql +import Hasql.TH +import Imports +import Polysemy +import Wire.API.PostgresMarshall +import Wire.API.Push.V2.WebSubscription +import Wire.Postgres +import Wire.WebPushStore + +-- | Postgres interpreter for 'WebPushStore'. Mirrors +-- 'Wire.CodeStore.Postgres.interpretCodeStoreToPostgres'. +interpretWebPushStoreToPostgres :: + (PGConstraints r) => + InterpreterFor WebPushStore r +interpretWebPushStoreToPostgres = interpret $ \case + InsertSubscription uid sub conn -> insertSubscriptionImpl uid sub conn + LookupSubscriptions uid -> lookupSubscriptionsImpl uid + DeleteSubscription uid endpoint -> deleteSubscriptionImpl uid endpoint + DeleteAllForUser uid -> deleteAllForUserImpl uid + PurgeExpired uid -> purgeExpiredImpl uid + +insertSubscriptionImpl :: + (PGConstraints r) => + UserId -> + WebPushSubscription -> + ConnId -> + Sem r () +insertSubscriptionImpl uid sub conn = + runStatement + ( uid, + sub ^. wpsClient, + sub ^. wpsEndpoint, + sub ^. (wpsKeys . wpkP256dh), + sub ^. (wpsKeys . wpkAuth), + sub ^. wpsExpirationTime, + conn + ) + insertSub + +insertSub :: + Hasql.Statement + (UserId, ClientId, EndpointUrl, P256dhKey, AuthSecret, Maybe Word64, ConnId) + () +insertSub = + lmapPG + [resultlessStatement| + INSERT INTO webpush_subscriptions + (user_id, client_id, endpoint, p256dh, auth, expiration, conn_id) + VALUES + ($1 :: uuid, $2 :: text, $3 :: text, $4 :: text, $5 :: text, $6 :: int8?, $7 :: bytea) + ON CONFLICT (user_id, client_id, endpoint) DO UPDATE SET + p256dh = ($4 :: text), + auth = ($5 :: text), + expiration = ($6 :: int8?), + conn_id = ($7 :: bytea) + |] + +lookupSubscriptionsImpl :: + (PGConstraints r) => + UserId -> + Sem r [WebPushAddress] +lookupSubscriptionsImpl uid = + runStatement uid lookupSubs + where + lookupSubs :: + Hasql.Statement UserId [WebPushAddress] + lookupSubs = + dimapPG + [vectorStatement| + SELECT (user_id :: uuid), + (client_id :: text), + (endpoint :: text), + (p256dh :: text), + (auth :: text), + (conn_id :: bytea) + FROM webpush_subscriptions + WHERE user_id = ($1 :: uuid) + |] + +deleteSubscriptionImpl :: + (PGConstraints r) => + UserId -> + EndpointUrl -> + Sem r () +deleteSubscriptionImpl uid endpoint = + runStatement (uid, endpoint) deleteSub + where + deleteSub :: Hasql.Statement (UserId, EndpointUrl) () + deleteSub = + lmapPG + [resultlessStatement| + DELETE FROM webpush_subscriptions + WHERE user_id = ($1 :: uuid) + AND endpoint = ($2 :: text) + |] + +deleteAllForUserImpl :: (PGConstraints r) => UserId -> Sem r () +deleteAllForUserImpl uid = + runStatement uid deleteAll + where + deleteAll :: Hasql.Statement UserId () + deleteAll = + lmapPG + [resultlessStatement| + DELETE FROM webpush_subscriptions + WHERE user_id = ($1 :: uuid) + |] + +purgeExpiredImpl :: (PGConstraints r) => UserId -> Sem r () +purgeExpiredImpl uid = + runStatement uid purgeStmt + where + purgeStmt :: Hasql.Statement UserId () + purgeStmt = + lmapPG + [resultlessStatement| + DELETE FROM webpush_subscriptions + WHERE user_id = ($1 :: uuid) + AND expiration IS NOT NULL + AND expiration < (SELECT FLOOR(EXTRACT(EPOCH FROM now()) * 1000)) + |] diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/WebPushStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/WebPushStore.hs new file mode 100644 index 00000000000..c559ef98c5a --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/WebPushStore.hs @@ -0,0 +1,84 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MockInterpreters.WebPushStore where + +import Control.Lens ((^.)) +import Data.Id +import Data.Map.Strict qualified as Map +import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds) +import Imports +import Polysemy +import Polysemy.State +import Wire.API.Push.V2.WebSubscription +import Wire.Sem.Now (Now) +import Wire.Sem.Now qualified as Now +import Wire.WebPushStore + +-- | Key identifying a single subscription row. Mirrors the Postgres primary +-- key @(user_id, client_id, endpoint)@ so upsert semantics match. +type WebPushKey = (UserId, ClientId, EndpointUrl) + +-- | In-memory store value: the connection id alongside the full subscription +-- (which carries expiration, needed by 'purgeExpired'). +data StoredWebPushSubscription = StoredWebPushSubscription + { swpsConn :: !ConnId, + swpsSubscription :: !WebPushSubscription + } + +type WebPushStoreState = Map WebPushKey StoredWebPushSubscription + +inMemoryWebPushStoreInterpreter :: + forall r. + (Member (State WebPushStoreState) r, Member Now r) => + InterpreterFor WebPushStore r +inMemoryWebPushStoreInterpreter = interpret $ \case + InsertSubscription uid sub conn -> + modify $ + Map.insert + (uid, sub ^. wpsClient, sub ^. wpsEndpoint) + StoredWebPushSubscription {swpsConn = conn, swpsSubscription = sub} + LookupSubscriptions uid -> + gets $ + map (toAddress uid) + . Map.elems + . Map.filterWithKey (\(u, _, _) _ -> u == uid) + DeleteSubscription uid endpoint -> + modify $ Map.filterWithKey \(u, _c, ep) _ -> not (u == uid && ep == endpoint) + DeleteAllForUser uid -> + modify $ Map.filterWithKey \(u, _, _) _ -> u /= uid + PurgeExpired uid -> do + now <- Now.get + let nowMs = round (utcTimeToPOSIXSeconds now * 1000) :: Word64 + modify $ + Map.filterWithKey + ( \(u, _, _) swps -> + u /= uid + || case swps.swpsSubscription ^. wpsExpirationTime of + Nothing -> True + Just expMs -> expMs >= nowMs + ) + +toAddress :: UserId -> StoredWebPushSubscription -> WebPushAddress +toAddress uid swps = + WebPushAddress + { wpaUser = uid, + wpaConn = swps.swpsConn, + wpaClient = swps.swpsSubscription ^. wpsClient, + wpaEndpoint = swps.swpsSubscription ^. wpsEndpoint, + wpaKeys = swps.swpsSubscription ^. wpsKeys + } diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 881b3965068..9c5b8206174 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -469,6 +469,8 @@ library Wire.VerificationCodeStore.Cassandra Wire.VerificationCodeSubsystem Wire.VerificationCodeSubsystem.Interpreter + Wire.WebPushStore + Wire.WebPushStore.Postgres hs-source-dirs: src build-depends: @@ -630,6 +632,7 @@ test-suite wire-subsystems-tests Wire.MockInterpreters.UserStore Wire.MockInterpreters.UserSubsystem Wire.MockInterpreters.VerificationCodeStore + Wire.MockInterpreters.WebPushStore Wire.NotificationSubsystem.InterpreterSpec Wire.PropertySubsystem.InterpreterSpec Wire.RateLimited.InterpreterSpec diff --git a/services/gundeck/default.nix b/services/gundeck/default.nix index 55ad533405f..f125b6091cc 100644 --- a/services/gundeck/default.nix +++ b/services/gundeck/default.nix @@ -15,6 +15,7 @@ , auto-update , base , base16-bytestring +, base64-bytestring , bilge , bytestring , bytestring-conversion @@ -22,6 +23,7 @@ , conduit , containers , criterion +, crypton , crypton-x509-store , data-timeout , errors @@ -29,6 +31,7 @@ , extended , extra , foldl +, hasql-pool , hedis , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk @@ -41,6 +44,7 @@ , lens , lens-aeson , lib +, memory , metrics-core , metrics-wai , MonadRandom @@ -49,6 +53,7 @@ , network , network-uri , optparse-applicative +, polysemy , prometheus-client , psqueues , QuickCheck @@ -78,6 +83,7 @@ , types-common-aws , unliftio , unordered-containers +, uri-bytestring , uuid , wai , wai-extra @@ -86,6 +92,7 @@ , websockets , wire-api , wire-otel +, wire-subsystems , yaml }: mkDerivation { @@ -105,11 +112,13 @@ mkDerivation { attoparsec auto-update base + base64-bytestring bilge bytestring bytestring-conversion cassandra-util containers + crypton crypton-x509-store data-timeout errors @@ -117,6 +126,7 @@ mkDerivation { extended extra foldl + hasql-pool hedis hs-opentelemetry-instrumentation-wai hs-opentelemetry-sdk @@ -126,10 +136,12 @@ mkDerivation { imports lens lens-aeson + memory metrics-core metrics-wai mtl network-uri + polysemy prometheus-client psqueues raw-strings-qq @@ -147,6 +159,7 @@ mkDerivation { types-common-aws unliftio unordered-containers + uri-bytestring uuid wai wai-extra @@ -154,6 +167,7 @@ mkDerivation { wai-utilities wire-api wire-otel + wire-subsystems yaml ]; executableHaskellDepends = [ @@ -204,12 +218,19 @@ mkDerivation { amqp async base + base64-bytestring + bytestring bytestring-conversion containers + crypton exceptions + extended + hasql-pool HsOpenSSL + http-types imports lens + memory MonadRandom mtl multiset @@ -224,9 +245,11 @@ mkDerivation { tasty-quickcheck text these + time tinylog types-common wire-api + wire-subsystems ]; benchmarkHaskellDepends = [ amazonka diff --git a/services/gundeck/gundeck.cabal b/services/gundeck/gundeck.cabal index 470cd7b5ae7..1b24a103f8e 100644 --- a/services/gundeck/gundeck.cabal +++ b/services/gundeck/gundeck.cabal @@ -37,6 +37,11 @@ library Gundeck.Push.Native Gundeck.Push.Native.Serialise Gundeck.Push.Native.Types + Gundeck.Push.Web + Gundeck.Push.Web.Crypto + Gundeck.Push.Web.Runner + Gundeck.Push.Web.Serialise + Gundeck.Push.Web.Ssrf Gundeck.Push.Websocket Gundeck.React Gundeck.Redis @@ -122,11 +127,13 @@ library , attoparsec >=0.10 , auto-update >=0.1 , base >=4.7 && <5 + , base64-bytestring >=1.0 , bilge >=0.21 , bytestring >=0.9 , bytestring-conversion >=0.2 , cassandra-util >=0.16.2 , containers >=0.5 + , crypton , crypton-x509-store , data-timeout , errors >=2.0 @@ -134,6 +141,7 @@ library , extended , extra >=1.1 , foldl + , hasql-pool , hedis >=0.14.0 , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk @@ -143,10 +151,12 @@ library , imports , lens >=4.4 , lens-aeson >=1.0 + , memory , metrics-core >=0.2.1 , metrics-wai , mtl >=2.2 , network-uri >=2.6 + , polysemy , prometheus-client , psqueues >=0.2.2 , raw-strings-qq @@ -164,6 +174,7 @@ library , types-common-aws , unliftio >=0.2 , unordered-containers >=0.2 + , uri-bytestring >=0.3.2.2 , uuid >=1.3 , wai >=3.2 , wai-extra >=3.0 @@ -171,6 +182,7 @@ library , wai-utilities >=0.16 , wire-api , wire-otel + , wire-subsystems , yaml >=0.8 default-language: GHC2021 @@ -482,6 +494,13 @@ test-suite gundeck-tests Paths_gundeck Push ThreadBudget + Vapid + WebPushCrypto + WebPushDispatch + WebPushHandlers + WebPushRunner + WebPushSerialise + WebPushSsrf hs-source-dirs: test/unit default-extensions: @@ -539,13 +558,20 @@ test-suite gundeck-tests , amqp , async , base + , base64-bytestring + , bytestring , bytestring-conversion , containers + , crypton , exceptions + , extended , gundeck + , hasql-pool , HsOpenSSL + , http-types , imports , lens + , memory , MonadRandom , mtl , multiset @@ -560,9 +586,11 @@ test-suite gundeck-tests , tasty-quickcheck , text , these + , time , tinylog , types-common , wire-api + , wire-subsystems default-language: GHC2021 diff --git a/services/gundeck/src/Gundeck/API/Public.hs b/services/gundeck/src/Gundeck/API/Public.hs index 90337190bb4..e360dc8938b 100644 --- a/services/gundeck/src/Gundeck/API/Public.hs +++ b/services/gundeck/src/Gundeck/API/Public.hs @@ -38,13 +38,19 @@ import Wire.API.Routes.Public.Gundeck -- Servant API servantSitemap :: ServerT GundeckAPI Gundeck -servantSitemap = pushAPI :<|> notificationAPI :<|> timeAPI +servantSitemap = pushAPI :<|> webPushAPI :<|> notificationAPI :<|> timeAPI where pushAPI = Named @"register-push-token" Push.addToken :<|> Named @"delete-push-token" Push.deleteToken :<|> Named @"get-push-tokens" Push.listTokens + webPushAPI = + Named @"register-web-push-subscription" Push.addWebSubscription + :<|> Named @"delete-web-push-subscription" Push.deleteWebSubscription + :<|> Named @"get-web-push-subscriptions" Push.listWebSubscriptions + :<|> Named @"get-vapid-public-key" Push.getVapidPublicKey + notificationAPI = Named @"get-notification-by-id" Data.fetchId :<|> Named @"get-last-notification" Data.fetchLast diff --git a/services/gundeck/src/Gundeck/Env.hs b/services/gundeck/src/Gundeck/Env.hs index e3670c13a8e..95f2a6b6c54 100644 --- a/services/gundeck/src/Gundeck/Env.hs +++ b/services/gundeck/src/Gundeck/Env.hs @@ -24,12 +24,20 @@ import Cassandra (ClientState) import Cassandra.Util (initCassandraForService) import Control.AutoUpdate import Control.Concurrent.Async (Async) +import Control.Exception (ErrorCall (..), throwIO) import Control.Lens (makeLenses, (^.)) import Control.Retry (capDelay, exponentialBackoff) +import Crypto.ECC (Curve_P256R1) +import Crypto.Error (CryptoFailable (..)) +import Crypto.PubKey.ECDSA qualified as ECDSA +import Data.Bifunctor (first) +import Data.ByteString.Base64.URL qualified as B64U import Data.ByteString.Char8 qualified as BSChar8 import Data.Id import Data.Misc (Milliseconds (..)) +import Data.Proxy (Proxy (..)) import Data.Text qualified as Text +import Data.Text.Encoding (decodeUtf8, encodeUtf8) import Data.Time.Clock import Data.Time.Clock.POSIX import Data.X509.CertificateStore as CertStore @@ -37,18 +45,45 @@ import Database.Redis qualified as Redis import Gundeck.Aws qualified as Aws import Gundeck.Options as Opt hiding (host, port) import Gundeck.Options qualified as O +import Gundeck.Push.Web.Ssrf (isPrivateLiteralHost) import Gundeck.Redis qualified as Redis import Gundeck.Redis.HedisExtensions qualified as Redis import Gundeck.ThreadBudget +import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended (initPostgresPool) import Imports import Network.AMQP (Channel) import Network.AMQP.Extended qualified as Q -import Network.HTTP.Client (responseTimeoutMicro) +import Network.HTTP.Client + ( host, + managerSetProxy, + noProxy, + responseTimeoutMicro, + ) import Network.HTTP.Client.TLS (tlsManagerSettings) import Network.TLS as TLS import Network.TLS.Extra qualified as TLS import System.Logger qualified as Log import System.Logger.Extended qualified as Logger +import URI.ByteString (schemeBSL, strictURIParserOptions, uriSchemeL) +import URI.ByteString qualified as URI +import Wire.PostgresMigrations qualified as PgMigrations + +-- | The server's static VAPID P-256 keypair (RFC 8292), parsed once at startup +-- from 'WebPushOpts'. The public key is exposed to web clients via +-- @GET /push/web/vapid-public-key@ so they can pass it as +-- @applicationServerKey@ to @pushManager.subscribe()@. The private key signs +-- the per-request VAPID JWTs. +data VapidKeyPair = VapidKeyPair + { _vkpPrivate :: !(ECDSA.PrivateKey Curve_P256R1), + _vkpPublic :: !(ECDSA.PublicKey Curve_P256R1), + -- | Uncompressed P-256 public point (@0x04 || X || Y@, 65 bytes), + -- base64url-encoded without padding. This is the wire format expected by + -- browsers as @applicationServerKey@. + _vkpPublicB64 :: !Text + } + +makeLenses ''VapidKeyPair data Env = Env { _reqId :: !RequestId, @@ -61,6 +96,24 @@ data Env = Env _awsEnv :: !Aws.Env, _time :: !(IO Milliseconds), _threadBudgetState :: !(Maybe ThreadBudgetState), + -- | VAPID keypair used to authenticate web push requests (RFC 8292). + -- 'Nothing' when web push is disabled (no @webpush:@ config section). + _vapid :: !(Maybe VapidKeyPair), + -- | Hasql connection pool backing the Postgres stores. Currently only web + -- push subscription storage ('Wire.WebPushStore') uses it; native push + -- remains on Cassandra. Acquired unconditionally at startup and migrated + -- via 'Wire.PostgresMigrations.runAllMigrations' (runs every migration + -- embedded in @wire-subsystems@, idempotent on a shared schema). + _pgPool :: !Hasql.Pool, + -- | Hardened HTTP 'Manager' used exclusively by web push dispatch + -- ('Gundeck.Push.Web') to POST encrypted notifications to browser push + -- services (RFC 8030). Hardening: proxy disabled via + -- 'noProxy' and a 'managerModifyRequest' hook rejects literal private / + -- loopback IP hosts ('isPrivateLiteralHost') as an SSRF defense on top of + -- the registration-time @_endpointAllowlist@. 'Nothing' when web push is + -- disabled (no @webpush:@ config section), in which case dispatch is never + -- reached. + _webPushManager :: !(Maybe Manager), _rabbitMqChannel :: MVar Channel } @@ -104,8 +157,44 @@ createEnv o = do { updateAction = Ms . round . (* 1000) <$> getPOSIXTime } mtbs <- mkThreadBudgetState `mapM` (o ^. settings . maxConcurrentNativePushes) + + -- Postgres pool for the web push subscription store (and any future + -- Postgres-backed gundeck subsystem). Acquired unconditionally, mirroring + -- galley/brig: even though only web push uses it today, a required pool keeps + -- the startup contract uniform and lets us run + -- 'Wire.PostgresMigrations.runAllMigrations' (idempotent — re-running + -- already-applied migrations is a no-op, so a shared schema with galley/brig + -- is safe). + pool <- initPostgresPool (o ^. postgresqlPool) (o ^. postgresql) (o ^. postgresqlPassword) + PgMigrations.runAllMigrations pool l + + -- VAPID keypair, only when the @webpush:@ config section is present. The env + -- var @GUNDECK_WEBPUSH_VAPID_PRIVATE_KEY@ overrides the YAML value, mirroring + -- the @REDIS_PASSWORD@ pattern: the private key is a long-lived secret and + -- should be injected via the environment in production rather than committed + -- to the config file. Missing/malformed key fails fast here. + -- + -- Note: an empty-but-set env var (@"\"\""@) overrides the YAML value with the + -- empty string, which then fails fast at decode — unlike @REDIS_PASSWORD@, + -- where empty means "no auth". This is intentional: an empty P-256 key is + -- never valid, so surfacing it as an error is safer than silently falling + -- back to the YAML value. + mVapid <- forM (o ^. webpush) $ \wp -> do + envVapidKey <- lookupEnv "GUNDECK_WEBPUSH_VAPID_PRIVATE_KEY" + let cfgVapidKey = wp ^. vapidPrivateKey + vapidKey = maybe cfgVapidKey Text.pack envVapidKey + case mkVapidKeyPair (wp ^. vapidSubject) vapidKey of + Left err -> + throwIO (ErrorCall ("gundeck.yaml/webpush: " <> err)) + Right kp -> pure kp + + -- Hardened HTTP manager for web push dispatch. Built only + -- when the @webpush:@ section is present, so non-web-push deployments pay no + -- extra connection pool. See '_webPushManager' for the hardening rationale. + mWebPushManager <- forM (o ^. webpush) $ \_ -> mkWebPushManager o + rabbitMqChannelMVar <- Q.mkRabbitMqChannelMVar l (Just "gundeck") (o ^. rabbitmq) - pure $! (rThread : rAdditionalThreads,) $! Env (RequestId defRequestId) o l n p r rAdditional a io mtbs rabbitMqChannelMVar + pure $! (rThread : rAdditionalThreads,) $! Env (RequestId defRequestId) o l n p r rAdditional a io mtbs mVapid pool mWebPushManager rabbitMqChannelMVar reqIdMsg :: RequestId -> Logger.Msg -> Logger.Msg reqIdMsg = ("request" Logger..=) . unRequestId @@ -158,3 +247,113 @@ createRedisPool l ep username password identifier = do safeShowConnInfo :: Redis.ConnectInfo -> String safeShowConnInfo connInfo = show $ connInfo {Redis.connectAuth = "[REDACTED]" <$ Redis.connectAuth connInfo} + +-------------------------------------------------------------------------------- +-- Hardened HTTP manager for web push dispatch +-------------------------------------------------------------------------------- + +-- | Build the 'Manager' used to POST encrypted web push notifications to +-- browser push-service endpoints (RFC 8030). Hardening: +-- +-- * @'managerSetProxy' 'noProxy'@ — never honour proxy environment variables +-- ('HTTP_PROXY' \/ 'http_proxy'). A misconfigured proxy in the deployment +-- environment could otherwise intercept the (already-encrypted) POSTs or, +-- worse, route them to an attacker-controlled upstream. Push services are +-- always directly reachable over the public internet. +-- +-- * A 'managerModifyRequest' hook that rejects any host which is a literal +-- private \/ loopback \/ link-local IP (or the hostname @"localhost"@) via +-- 'Gundeck.Push.Web.Ssrf.isPrivateLiteralHost'. This is the SSRF +-- belt-and-suspenders behind the registration-time @_endpointAllowlist@. +-- +-- * The connection count and idle count mirror the regular '_manager', so the +-- two pools share the same sizing assumptions; web push traffic is low +-- volume compared to native push \/ SNS. +-- +-- * The 5-second response timeout mirrors '_manager'; push services are +-- expected to acknowledge quickly (201 \/ 202 with an empty body). +mkWebPushManager :: Opts -> IO Manager +mkWebPushManager o = + newManager + (managerSetProxy noProxy tlsManagerSettings) + { managerConnCount = o ^. settings . httpPoolSize, + managerIdleConnectionCount = 3 * (o ^. settings . httpPoolSize), + managerResponseTimeout = responseTimeoutMicro 5000000, + managerModifyRequest = \req -> do + let h = host req + when (isPrivateLiteralHost h) $ + throwIO + ( ErrorCall + ( "gundeck web push: refusing to POST to private/loopback host: " + <> show h + ) + ) + pure req + } + +-------------------------------------------------------------------------------- +-- VAPID keypair parsing (pure, for unit testing) +-------------------------------------------------------------------------------- + +-- | Validate the VAPID subject and derive the public key from the private key. +-- Both inputs are taken from 'WebPushOpts'; errors are human-readable so they +-- surface cleanly as a startup crash from 'createEnv'. +mkVapidKeyPair :: + -- | VAPID subject (RFC 8292 §3 @sub@ claim). + Text -> + -- | Private key, base64url-encoded raw 32-byte P-256 scalar. + Text -> + Either String VapidKeyPair +mkVapidKeyPair subject keyText = do + validateVapidSubject subject + parseVapidKeyPair keyText + +-- | Decode a base64url raw 32-byte P-256 private scalar, validate it lies in +-- the range @[1, n-1]@ (where @n@ is the P-256 group order), and derive the +-- corresponding public point @Q = d*G@. Returns the keypair plus the +-- base64url-encoded uncompressed public point for the client-facing endpoint. +-- +-- crypton's 'ECDSA.decodePrivate' only checks the byte length, so an explicit +-- 'ECDSA.scalarIsValid' check is required to reject the degenerate @d=0@ +-- (which would yield the point at infinity) and scalars @>= n@. +parseVapidKeyPair :: Text -> Either String VapidKeyPair +parseVapidKeyPair keyText = do + let keyBs = encodeUtf8 keyText + raw <- + first ("private key is not valid base64url: " <>) $ + B64U.decodeUnpadded keyBs + case ECDSA.decodePrivate (Proxy @Curve_P256R1) raw of + CryptoFailed err -> + Left ("private key is not a valid P-256 scalar (expected 32 bytes): " <> show err) + CryptoPassed priv + | not (ECDSA.scalarIsValid (Proxy @Curve_P256R1) priv) -> + Left "private key scalar is out of range (must be in [1, n-1]; got 0 or >= n)" + | otherwise -> + let pub = ECDSA.toPublic (Proxy @Curve_P256R1) priv + pubBs :: ByteString + pubBs = ECDSA.encodePublic (Proxy @Curve_P256R1) pub + pubB64 = decodeUtf8 (B64U.encodeUnpadded pubBs) + in Right (VapidKeyPair priv pub pubB64) + +-- | Validate the VAPID @sub@ject is a @mailto:@ or @https:@ URL, per RFC 8292 +-- §3. Any other scheme is rejected: the subject identifies the application +-- server operator to the push service, and the two allowed schemes are the +-- only interoperable ones. +-- +-- Note: plain @http:@ is rejected even though RFC 8292 §3 permits it (and only +-- recommends @https:@). This is intentional: the VAPID JWT is a bearer token +-- identifying the server, and a cleartext transport would let an attacker +-- substitute their own @aud@ origin. Requiring @https:@ (or @mailto:@, which +-- carries no request) is a strict, safer stance. +validateVapidSubject :: Text -> Either String () +validateVapidSubject subj = + case URI.parseURI strictURIParserOptions (encodeUtf8 subj) of + Left e -> Left ("subject is not a valid URI: " <> show e) + Right uri -> + let scheme = uri ^. uriSchemeL . schemeBSL + in if scheme == "mailto" || scheme == "https" + then Right () + else + Left $ + "subject must use a mailto: or https: scheme, got: " + <> show scheme diff --git a/services/gundeck/src/Gundeck/Options.hs b/services/gundeck/src/Gundeck/Options.hs index d70bbc4f91d..c382099f527 100644 --- a/services/gundeck/src/Gundeck/Options.hs +++ b/services/gundeck/src/Gundeck/Options.hs @@ -24,6 +24,7 @@ import Control.Lens hiding (Level) import Data.Aeson.TH import Data.Yaml (FromJSON) import Gundeck.Aws.Arn +import Hasql.Pool.Extended (PoolConfig) import Imports import Network.AMQP.Extended import System.Logger.Extended (Level, LogFormat) @@ -130,17 +131,80 @@ makeLenses ''Settings deriveFromJSON toOptionFieldName ''Settings +-- | Configuration for the Web Push application server (RFC 8030/8291/8292). +-- Gundeck acts as the application server: it stores browser-supplied push +-- subscriptions and POSTs encrypted notifications to them, authenticated with +-- a static VAPID P-256 keypair (RFC 8292). +data WebPushOpts = WebPushOpts + { -- | VAPID subject, used as the @sub@ JWT claim. MUST be a @mailto:@ or + -- @https:@ URL identifying the application server's operator (RFC 8292 §3). + _vapidSubject :: !Text, + -- | Server's P-256 private key, base64url-encoded raw 32-byte scalar. + -- This is a long-lived secret; load it via the @GUNDECK_WEBPUSH_VAPID_PRIVATE_KEY@ + -- environment variable in production (see @Gundeck.Env.createEnv@) rather than + -- committing it to YAML. + _vapidPrivateKey :: !Text, + -- | Allowed push-service host suffixes. The @endpoint@ of an incoming + -- subscription must match one of these (SSRF mitigation). + -- Empty list means the allowlist is disabled (not recommended in prod). + -- + -- Note: match on the endpoint's parsed hostname (after IDNA normalization), + -- NOT a raw 'Text.isSuffixOf' on the URL — naive suffix matching is a + -- classic SSRF bypass (e.g. @evil.com@ matching @notevil.com@). + _endpointAllowlist :: ![Text], + -- | Default @TTL@ header value (seconds) sent with each web push, when the + -- notice priority does not dictate one (RFC 8030 §5.2). + _defaultTTL :: !(Maybe Word32) + } + deriving (Generic) + +deriveFromJSON toOptionFieldName ''WebPushOpts + +makeLenses ''WebPushOpts + +-- | The private key is a long-lived secret, so the derived 'Show' instance is +-- intentionally omitted and replaced with one that redacts it, mirroring the +-- repo's @PlainTextPassword'@ / @OAuthClientPlainTextSecret@ convention (see +-- @libs/types-common/src/Data/Misc.hs@, @libs/wire-api/src/Wire/API/OAuth.hs@). +instance Show WebPushOpts where + show wp = + concat + [ "WebPushOpts { _vapidSubject = ", + show (wp ^. vapidSubject), + ", _vapidPrivateKey = , _endpointAllowlist = ", + show (wp ^. endpointAllowlist), + ", _defaultTTL = ", + show (wp ^. defaultTTL), + " }" + ] + data Opts = Opts { -- | Hostname and port to bind to _gundeck :: !Endpoint, _brig :: !Endpoint, _cassandra :: !CassandraOpts, + -- | Postgresql settings for web push subscription storage. The key-value + -- pairs are libpq connection keywords + -- (https://www.postgresql.org/docs/17/libpq-connect.html#LIBPQ-PARAMKEYWORDS); + -- gundeck consumes them exactly as galley and brig do (via + -- 'Hasql.Pool.Extended.initPostgresPool'). Web push is the first (and + -- currently only) gundeck subsystem backed by Postgres — Cassandra remains + -- the store for native push, unchanged. + _postgresql :: !(Map Text Text), + _postgresqlPassword :: !(Maybe FilePathSecrets), + _postgresqlPool :: !PoolConfig, _redis :: !RedisEndpoint, _redisAdditionalWrite :: !(Maybe RedisEndpoint), _aws :: !AWSOpts, _rabbitmq :: !AmqpEndpoint, _discoUrl :: !(Maybe Text), _settings :: !Settings, + -- | Web Push (RFC 8030/8291/8292) application-server settings. When + -- 'Nothing' (the @webpush:@ section is absent from the config), web push + -- is fully disabled and gundeck behaves exactly as before — this allows + -- incremental rollout. When @Just@ but the VAPID key is missing or + -- malformed, startup fails fast (see @Gundeck.Env.createEnv@). + _webpush :: !(Maybe WebPushOpts), -- Logging -- | Log level (Debug, Info, etc) diff --git a/services/gundeck/src/Gundeck/Push.hs b/services/gundeck/src/Gundeck/Push.hs index 9a7e8d5295c..619166b2759 100644 --- a/services/gundeck/src/Gundeck/Push.hs +++ b/services/gundeck/src/Gundeck/Push.hs @@ -38,12 +38,19 @@ module Gundeck.Push addToken, listTokens, deleteToken, + addWebSubscription, + deleteWebSubscription, + listWebSubscriptions, + getVapidPublicKey, -- (for testing) pushAll, splitPush, MonadPushAll (..), MonadNativeTargets (..), MonadMapAsync (..), + validateEndpointHost, + endpointHost, + addressToSubscription, ) where @@ -62,6 +69,7 @@ import Data.Map qualified as Map import Data.Misc import Data.Set qualified as Set import Data.Text qualified as Text +import Data.Text.Encoding (decodeUtf8, encodeUtf8) import Data.These import Data.Timeout import Data.UUID qualified as UUID @@ -76,6 +84,8 @@ import Gundeck.Presence.Data qualified as Presence import Gundeck.Push.Data qualified as Data import Gundeck.Push.Native qualified as Native import Gundeck.Push.Native.Types +import Gundeck.Push.Web qualified as WebPush +import Gundeck.Push.Web.Runner (runWebPush) import Gundeck.Push.Websocket qualified as Web import Gundeck.ThreadBudget import Gundeck.Util @@ -86,6 +96,8 @@ import Network.HTTP.Types import Network.Wai.Utilities import System.Logger.Class (msg, val, (+++), (.=), (~~)) import System.Logger.Class qualified as Log +import URI.ByteString (strictURIParserOptions) +import URI.ByteString qualified as URI import UnliftIO (pooledMapConcurrentlyN) import Util.Options import Wire.API.Internal.Notification @@ -95,6 +107,13 @@ import Wire.API.Push.Token qualified as Public import Wire.API.Push.V2 import Wire.API.User (UserSet (..)) import Wire.API.User.Client (Client (..), UserClientsFull (..), supportsConsumableNotifications) +import Wire.WebPushStore + ( WebPushAddress (..), + deleteSubscription, + insertSubscription, + lookupSubscriptions, + purgeExpired, + ) push :: [Push] -> Gundeck () push ps = do @@ -112,6 +131,21 @@ class (MonadThrow m) => MonadPushAll m where mpaBulkPush :: [(Notification, [Presence])] -> m [(NotificationId, [Presence])] mpaStreamAdd :: NotificationId -> NonEmpty NotificationTarget -> NonEmpty Aeson.Object -> NotificationTTL -> m () mpaPushNative :: Notification -> Priority -> [Address] -> m () + + -- | Deliver a web push notification to a list of resolved web push + -- subscriptions. Analogous to 'mpaPushNative' but for the W3C Push API + -- (RFC 8030 application-server path) instead of AWS SNS. No-op when the + -- address list is empty; the underlying 'WebPush.push' handles fan-out + -- under the configured per-push concurrency budget. + mpaPushWeb :: Notification -> Priority -> [WebPushAddress] -> m () + + -- | Look up all web push subscriptions registered for a user. Returns + -- @[]@ on lookup failure (transient Postgres outage, etc.) after + -- logging; this matches the resilient semantics of the surrounding + -- dispatch code where a per-user lookup failure must not abort the + -- entire push batch. + mpaWebTargets :: UserId -> m [WebPushAddress] + mpaForkIO :: m () -> m () mpaRunWithBudget :: Int -> a -> m a -> m a mpaGetClients :: Set UserId -> m UserClientsFull @@ -126,6 +160,18 @@ instance MonadPushAll Gundeck where mpaBulkPush = Web.bulkPush mpaStreamAdd = Data.add mpaPushNative = pushNative + mpaPushWeb notif prio addrs = + WebPush.push (NativePush (ntfId notif) prio Nothing) addrs + mpaWebTargets uid = do + pool <- view pgPool + runWebPush pool (lookupSubscriptions uid) >>= \case + Left storeErr -> do + Log.err $ + msg (val "Web push subscription lookup failed") + . Log.field "user" (UUID.toASCIIBytes (toUUID uid)) + . Log.field "error" (show storeErr) + pure [] + Right addrs -> pure addrs mpaForkIO = void . forkIO mpaRunWithBudget = runWithBudget'' mpaGetClients = getClients @@ -289,7 +335,13 @@ pushAllLegacy newNotifications userClientsFull = do let alreadySentClients = Set.fromList $ mapMaybe (\p -> (p.userId,) <$> p.clientId) alreadySent rabbitmqClients = Map.map (Set.filter supportsConsumableNotifications) userClientsFull.userClientsFull rabbitmqClientIds = Map.foldMapWithKey (\uid clients -> Set.map (\c -> (uid, c.clientId)) clients) rabbitmqClients - pushNativeWithBudget notif psh (Set.toList $ Set.union alreadySentClients rabbitmqClientIds) + let dontPush = Set.toList $ Set.union alreadySentClients rabbitmqClientIds + pushNativeWithBudget notif psh dontPush + -- Web push uses the same 'dontPush' set as native: a client already + -- served over websocket must not receive a duplicate web push. The + -- underlying 'WebPush.push' fans out under the configured per-push + -- concurrency budget (reused from native for v1). + pushWebWithBudget notif psh dontPush pushNativeWithBudget :: (MonadMapAsync m, MonadPushAll m, MonadNativeTargets m) => Notification -> Push -> [(UserId, ClientId)] -> m () pushNativeWithBudget notif psh dontPush = do @@ -305,6 +357,29 @@ pushNativeWithBudget notif psh dontPush = do mpaRunWithBudget cost () $ mpaPushNative notif (psh ^. pushNativePriority) =<< nativeTargets psh rcps' dontPush +-- | Web push counterpart to 'pushNativeWithBudget'. Fans a notification out to +-- all eligible web push subscriptions under the same per-push concurrency +-- budget as native push (intentional for v1; web push is unmediated by SNS +-- but still bounded by 'perNativePushConcurrency' to avoid flooding browser +-- push services). +-- +-- Transient pushes are skipped, matching 'pushNativeWithBudget' semantics: +-- web push is for durable notices, and a transient push signals the client is +-- reachable over websocket right now. +pushWebWithBudget :: + (MonadMapAsync m, MonadPushAll m, MonadNativeTargets m) => + Notification -> + Push -> + [(UserId, ClientId)] -> + m () +pushWebWithBudget notif psh dontPush = do + perPushConcurrency <- mntgtPerPushConcurrency + let rcps' = nativeTargetsRecipients psh + cost = maybe (length rcps') (min (length rcps')) perPushConcurrency + unless (psh ^. pushTransient) $ + mpaRunWithBudget cost () $ + mpaPushWeb notif (psh ^. pushNativePriority) =<< webTargets psh rcps' dontPush + pushAllViaRabbitMq :: (MonadPushAll m, MonadMapAsync m, MonadNativeTargets m) => [NewNotification] -> UserClientsFull -> m () pushAllViaRabbitMq newNotifs userClientsFull = do for_ newNotifs $ pushViaRabbitMq @@ -522,6 +597,49 @@ nativeTargets psh rcps' dontPush = check (Left e) = mntgtLogErr e >> pure [] check (Right r) = pure r +-- | Web push counterpart to 'nativeTargets'. Looks up 'WebPushAddress'es per +-- recipient via 'mpaWebTargets' and filters them with the same semantics as +-- 'nativeTargets': exclude the origin connection, require the client to be +-- in the recipient's client set, require the connection to be on the push +-- allowlist (or no allowlist), and skip any client already served over +-- websocket (the @dontPush@ set). +-- +-- A single user may have multiple web push subscriptions per client (e.g. +-- multiple browsers); each is dispatched independently, so the returned list +-- can contain multiple 'WebPushAddress'es for the same @(user, client)@. +webTargets :: + forall m. + (MonadPushAll m, MonadNativeTargets m, MonadMapAsync m) => + Push -> + [Recipient] -> + [(UserId, ClientId)] -> + m [WebPushAddress] +webTargets psh rcps' dontPush = + mntgtMapAsync addresses rcps' >>= fmap concat . mapM check + where + addresses :: Recipient -> m [WebPushAddress] + addresses u = filter (eligible u) <$> mpaWebTargets (u ^. recipientId) + eligible :: Recipient -> WebPushAddress -> Bool + eligible u a + -- Never include the origin connection (matches nativeTargets). + | Just a.wpaUser == psh ^. pushOrigin && Just a.wpaConn == psh ^. pushOriginConnection = False + -- Is the specific client an intended recipient? + | not (eligibleWebClient a (u ^. recipientClients)) = False + -- Is the connection not on the push allowlist (or no allowlist)? + | not (whitelistedOrNoWebWhitelist a) = False + -- Skip clients already served over websocket. + | otherwise = (a.wpaUser, a.wpaClient) `notElem` dontPush + eligibleWebClient :: WebPushAddress -> RecipientClients -> Bool + eligibleWebClient _ RecipientClientsAll = True + eligibleWebClient a (RecipientClientsSome cs) = a.wpaClient `elem` cs + whitelistedOrNoWebWhitelist :: WebPushAddress -> Bool + whitelistedOrNoWebWhitelist a = + null (psh ^. pushConnections) + || a.wpaConn `elem` psh ^. pushConnections + check :: Either SomeException [a] -> m [a] + check (Left e) = mntgtLogErr e >> pure [] + check (Right r) = pure r + type AddTokenResponse = Either Public.AddTokenError Public.AddTokenSuccess addToken :: UserId -> ConnId -> PushToken -> Gundeck AddTokenResponse @@ -687,3 +805,188 @@ deleteToken uid tok = do listTokens :: UserId -> Gundeck PushTokenList listTokens uid = PushTokenList . map (^. addrPushToken) <$> Data.lookup uid Data.LocalQuorum + +-------------------------------------------------------------------------------- +-- Web push subscriptions + +-- | Register a web push subscription (RFC 8030 application-server side). +-- +-- Validates the endpoint against the configured host allowlist (SSRF +-- mitigation, see 'validateEndpointHost'), then — within a single store +-- transaction — enforces the per-user subscription cap +-- ('maxWebPushSubscriptionsPerUser'), purges any expired subscriptions for the +-- user, and upserts. Re-registering the same @(client, endpoint)@ updates +-- keys/expiry without duplicating rows: the store's primary key is +-- @(user, client, endpoint)@. +addWebSubscription :: + UserId -> + ConnId -> + WebPushSubscription -> + Gundeck (Either AddWebPushError AddWebPushSuccess) +addWebSubscription uid conn sub = do + wp <- requireWebPushOpts + case validateEndpointHost (wp ^. endpointAllowlist) (sub ^. wpsEndpoint) of + Left validationErr -> pure (Left validationErr) + Right () -> do + pool <- view pgPool + runWebPush pool register >>= \case + Left storeErr -> do + Log.err $ + msg (val "Web push subscription insert failed") + . Log.field "error" (show storeErr) + throwM (mkError status500 "web-push-error" "Web Push Error") + Right result -> pure result + where + register = do + -- Cap before insert: distinct endpoints are NOT deduped by the store's + -- upsert (only identical @(client, endpoint)@ pairs are), so without a + -- guard a client could register an unbounded number of rows and cause + -- unbounded fan-out at dispatch time (POSTs to every row). + -- The cap also bounds the cost of 'purgeExpired' below. + existing <- lookupSubscriptions uid + if length existing >= maxWebPushSubscriptionsPerUser + then pure (Left AddWebPushErrorTooMany) + else do + purgeExpired uid + insertSubscription uid sub conn + pure (Right (AddWebPushSuccess sub)) + +-- | Conservative v1 cap on the number of web push subscriptions a single user +-- may hold. Bounding this is what makes the per-register 'purgeExpired' and the +-- fan-out (which POSTs to every row for a user) safe against a buggy or hostile +-- client. The value is generous for legitimate use — a user typically holds one +-- subscription per browser profile — and is not yet configurable. +maxWebPushSubscriptionsPerUser :: Int +maxWebPushSubscriptionsPerUser = 32 + +-- | Unregister a web push subscription by endpoint. Idempotent: deleting an +-- unknown endpoint returns @'Just' ()@ (which the route maps to HTTP 204), +-- matching the REST convention for DELETE rather than 404-ing on a missing +-- resource. +deleteWebSubscription :: + UserId -> + DeleteWebPushRequest -> + Gundeck (Maybe ()) +deleteWebSubscription uid req = do + _ <- requireWebPushOpts + pool <- view pgPool + runWebPush pool (deleteSubscription uid req.deleteWebPushRequestEndpoint) >>= \case + Left storeErr -> do + Log.err $ + msg (val "Web push subscription delete failed") + . Log.field "error" (show storeErr) + throwM (mkError status500 "web-push-error" "Web Push Error") + Right () -> pure (Just ()) + +-- | List all web push subscriptions for a user. +-- +-- The store's 'lookupSubscriptions' returns 'WebPushAddress'es (the dispatch +-- shape), which carry everything except expiration time; the expiration is +-- therefore reported as 'Nothing' here. This is a known v1 limitation: the +-- per-user dispatch hot path does not need expiry, and the store exposes no +-- separate full-subscription lookup. +listWebSubscriptions :: + UserId -> + Gundeck WebPushSubscriptionList +listWebSubscriptions uid = do + _ <- requireWebPushOpts + pool <- view pgPool + runWebPush pool (lookupSubscriptions uid) >>= \case + Left storeErr -> do + Log.err $ + msg (val "Web push subscription lookup failed") + . Log.field "error" (show storeErr) + throwM (mkError status500 "web-push-error" "Web Push Error") + Right addrs -> pure (WebPushSubscriptionList (addressToSubscription <$> addrs)) + +-- | Surface the 'WebPushOpts' or reject with HTTP 503 when web push is disabled +-- (no @webpush:@ config section). Mirrors the contract: absent config +-- disables the feature uniformly across all handlers, so deployments without +-- the section behave exactly as before. +requireWebPushOpts :: Gundeck WebPushOpts +requireWebPushOpts = + view (options . webpush) >>= \case + Nothing -> throwM (mkError status503 "web-push-disabled" "Web push is not enabled") + Just wp -> pure wp + +-- | Convert a stored 'WebPushAddress' back into the API 'WebPushSubscription' +-- shape. 'wpsExpirationTime' is not retained by the dispatch address, so it is +-- reported as 'Nothing' (see 'listWebSubscriptions'). +addressToSubscription :: WebPushAddress -> WebPushSubscription +addressToSubscription a = + webPushSubscription a.wpaEndpoint a.wpaKeys Nothing a.wpaClient + +-- | Return the server's static VAPID public key (RFC 8292) so that web clients +-- can pass it as @applicationServerKey@ to @pushManager.subscribe()@. +-- +-- Rejects with HTTP 503 (@web-push-disabled@) when web push is not configured +-- (no @webpush:@ section), mirroring the contract enforced by +-- 'requireWebPushOpts'. The keypair itself is parsed once at startup +-- ('Gundeck.Env.mkVapidKeyPair'), so the '_vkpPublicB64' field here is just a +-- read — no crypto, no allocation, no failure mode other than the disabled +-- feature. The 500 branch below is purely defensive: if '_vapid' is 'Nothing' +-- while the option is present, startup parsing is broken and the operator +-- should know immediately. +getVapidPublicKey :: Gundeck VapidPublicKeyResponse +getVapidPublicKey = do + _ <- requireWebPushOpts + view vapid >>= \case + Nothing -> + throwM $ + mkError + status500 + "web-push-error" + "VAPID keypair missing despite enabled config" + Just kp -> pure (VapidPublicKeyResponse (kp ^. vkpPublicB64)) + +-------------------------------------------------------------------------------- +-- SSRF validation (application-layer) + +-- | Validate that the endpoint's host is on the configured allowlist. This is +-- the application-layer SSRF mitigation called out in the +-- @endpoint@ is attacker-controllable and gundeck will later POST to it, +-- so we reject endpoints whose host we did not pre-approve. +-- +-- Matching is performed on the endpoint's /parsed/ hostname, never on a raw +-- 'Text.isSuffixOf' over the whole URL: naive suffix matching is a classic +-- SSRF bypass (an allowlist entry @example.com@ must not match +-- @example.com.attacker.tld@, and @evil.com@ must not match @notevil.com@). +-- An entry matches when the host equals it exactly or is a subdomain on a dot +-- boundary. +-- +-- An empty allowlist disables the check (accept all) — intended for +-- development; see @_endpointAllowlist@ in 'WebPushOpts'. +-- +-- Note: matching is on the ASCII host as parsed. Full IDNA normalization +-- (punycode) is out of scope here; real browser push services (FCM, Mozilla +-- autopush) use ASCII hosts, and the manager-layer filter catches the rest. +validateEndpointHost :: + -- | Allowed push-service host suffixes (@_endpointAllowlist@). + [Text] -> + EndpointUrl -> + Either AddWebPushError () +validateEndpointHost allowlist url + | null allowlist = Right () + | otherwise = + case endpointHost url of + Nothing -> Left AddWebPushErrorInvalid + Just parsedHost + | any (hostMatches (Text.toLower parsedHost)) allowlist -> Right () + | otherwise -> Left AddWebPushErrorInvalid + where + -- Hostnames are case-insensitive (RFC 3986 §3.2.2); compare on the + -- normalized lowercase form of both sides so a mixed-case endpoint or + -- allowlist entry does not weaken (or accidentally bypass) the check. + hostMatches h entry = + let e = Text.toLower entry + in h == e || Text.isSuffixOf ("." <> e) h + +-- | Extract the hostname from an 'EndpointUrl'. 'EndpointUrl' is already +-- guaranteed HTTPS by its smart constructor 'mkEndpointUrl', so a parse +-- failure here is unexpected (the URL round-tripped validation on the way in) +-- and is treated as an invalid endpoint. +endpointHost :: EndpointUrl -> Maybe Text +endpointHost (EndpointUrl raw) = + case URI.parseURI strictURIParserOptions (encodeUtf8 raw) of + Left _ -> Nothing + Right uri -> (decodeUtf8 . URI.hostBS . URI.authorityHost) <$> URI.uriAuthority uri diff --git a/services/gundeck/src/Gundeck/Push/Web.hs b/services/gundeck/src/Gundeck/Push/Web.hs new file mode 100644 index 00000000000..838610a4f3f --- /dev/null +++ b/services/gundeck/src/Gundeck/Push/Web.hs @@ -0,0 +1,461 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Web push dispatch loop (RFC 8030 application server). +-- +-- This is the runtime counterpart to 'Gundeck.Push.Native': where native push +-- delegates to AWS SNS, web push has gundeck itself act as the RFC 8030 +-- application server. For each subscription it encrypts a notice payload +-- (RFC 8291 via 'Gundeck.Push.Web.Crypto.encryptPayload'), signs a VAPID JWT +-- (RFC 8292 via 'Gundeck.Push.Web.Crypto.signVapid'), and POSTs the encrypted +-- body to the browser push-service endpoint, using a hardened HTTP 'Manager' +-- with SSRF egress filtering ('Env._webPushManager'). +-- +-- Dispatch is structured to mirror 'Gundeck.Push.Native': @push@ fans out over +-- addresses under the push concurrency budget, and @push1@ handles a single +-- recipient with response classification and bounded retry. +module Gundeck.Push.Web + ( push, + + -- * Pure helpers (exported for unit testing) + ResponseOutcome (..), + classifyResponse, + urgencyFrom, + ttlFrom, + endpointOrigin, + ) +where + +import Control.Lens (Lens', view, (^.)) +import Control.Monad.Catch (Handler (..), handle, throwM) +import Control.Retry + ( RetryPolicy, + RetryStatus, + capDelay, + exponentialBackoff, + limitRetries, + recovering, + ) +import Data.ByteString.Conversion (toByteString, toByteString') +import Data.Id (UserId) +import Data.Text qualified as Text +import Data.Text.Encoding (encodeUtf8) +import Gundeck.Env +import Gundeck.Monad +import Gundeck.Options +import Gundeck.Push.Native.Types (NativePush (npPriority)) +import Gundeck.Push.Web.Crypto + ( CryptoError, + EncryptedBody (..), + VapidHeaders (..), + encryptPayload, + signVapid, + ) +import Gundeck.Push.Web.Runner (runWebPush) +import Gundeck.Push.Web.Serialise (serialise) +import Imports +import Network.HTTP.Client + ( HttpException (..), + HttpExceptionContent (..), + Request, + RequestBody (..), + httpLbs, + method, + parseRequest, + requestBody, + requestHeaders, + responseStatus, + ) +import Network.HTTP.Types + ( Status, + hAuthorization, + hContentEncoding, + statusCode, + ) +import Prometheus qualified as Prom +import System.Logger.Class (field, msg, val, (~~)) +import System.Logger.Class qualified as Log +import UnliftIO (handleAny, mapConcurrently, pooledMapConcurrentlyN_) +import Wire.API.Push.V2 (Priority (..)) +import Wire.API.Push.V2.WebSubscription (EndpointUrl (..)) +import Wire.WebPushStore (WebPushAddress (..)) +import Wire.WebPushStore qualified as Store + +-------------------------------------------------------------------------------- +-- Public API + +-- | Deliver a native push payload to all given web push subscriptions. +-- +-- Mirrors 'Gundeck.Push.Native.push': empty address lists are a no-op, a +-- singleton skips the concurrency overhead, and a list fans out under the +-- configured per-push concurrency budget (reused from native push for v1; +-- see '_perNativePushConcurrency'). +push :: NativePush -> [WebPushAddress] -> Gundeck () +push _ [] = pure () +push m [a] = push1 m a +push m addrs = do + perPushConcurrency <- view (options . settings . perNativePushConcurrency) + case perPushConcurrency of + Nothing -> void $ mapConcurrently (push1 m) addrs + Just chunkSize -> pooledMapConcurrentlyN_ chunkSize (push1 m) addrs + +-------------------------------------------------------------------------------- +-- Per-recipient dispatch + +-- | Encrypt, sign, and POST a single notice, with bounded retry on transient +-- failures (RFC 8030 §5 response codes, transport errors). Mirrors +-- 'Gundeck.Push.Native.push1'. +-- +-- Permanent failures (payload too large, recipient key invalid, non-retryable +-- HTTP status) are classified and counted without retrying. Transient failures +-- (429, 5xx, transport timeout) are retried with exponential backoff up to +-- 'webPushRetryPolicy' limits; retry exhaustion propagates to the catch-all +-- handler as an unexpected error. +push1 :: NativePush -> WebPushAddress -> Gundeck () +push1 m a = + handleAny onUnexpectedError + . handle onPrepareError + $ do + -- All three are constructed together by 'createEnv' when the @webpush:@ + -- section is present. A 'Nothing' here indicates a startup wiring bug, + -- not a runtime condition. + mgr <- require webPushManager + kp <- require vapid + wp <- require (options . webpush) + let uid = a.wpaUser + -- 1. Serialise plaintext (RFC 8030 \/ 8291 §4 size guard). + plaintext <- case serialise m uid of + Left _ -> throwM WebPushPrepareTooLarge + Right bs -> pure bs + -- 2. Encrypt body (RFC 8291). + EncryptedBody body <- + liftIO (encryptPayload a.wpaKeys plaintext) >>= \case + Left cryptoErr -> throwM (WebPushPrepareCryptoFailure cryptoErr) + Right encryptedBody -> pure encryptedBody + -- 3. Sign VAPID JWT (RFC 8292). + vapidHeaders <- + liftIO $ + signVapid + kp + (wp ^. vapidSubject) + (endpointOrigin a.wpaEndpoint) + -- 4. Build the HTTP request. + let ttl = ttlFrom (wp ^. defaultTTL) + urgency = urgencyFrom m.npPriority + req <- liftIO (buildRequest a.wpaEndpoint body vapidHeaders ttl urgency) + -- 5. Submit with bounded retry on transient failures. + recovering webPushRetryPolicy webPushRetryHandlers $ \_ -> do + resp <- liftIO (httpLbs req mgr) + case classifyResponse (responseStatus resp) of + ResponseSuccess -> onSuccess uid + ResponseGone -> onGone a + ResponseTooLarge -> onTooLarge uid + ResponseRetryable -> liftIO (throwM WebPushRetrySignal) + ResponseError {} -> onPermanentHttpError uid + where + onPrepareError :: WebPushPrepareError -> Gundeck () + onPrepareError = \case + WebPushPrepareTooLarge -> + onTooLarge a.wpaUser + WebPushPrepareCryptoFailure cryptoErr -> do + Prom.incCounter webPushErrorCounter + Log.err $ + field "user" (toByteString a.wpaUser) + ~~ field "error" (show cryptoErr) + ~~ msg (val "Web push encryption failed") + WebPushConfigMissing -> do + Prom.incCounter webPushErrorCounter + Log.err $ + msg + ( val + "Web push dispatch invoked with web push disabled \ + \(manager / VAPID keypair / options missing)" + ) + + onUnexpectedError :: SomeException -> Gundeck () + onUnexpectedError ex = do + Prom.incCounter webPushErrorCounter + Log.err $ + field "user" (toByteString a.wpaUser) + ~~ field "error" (displayException ex) + ~~ msg (val "Web push failed") + +-------------------------------------------------------------------------------- +-- Response handlers + +onSuccess :: UserId -> Gundeck () +onSuccess uid = do + Prom.incCounter webPushSuccessCounter + Log.debug $ + field "user" (toByteString uid) + ~~ msg (val "Web push success") + +-- | The push service returned 404 \/ 410: the subscription no longer exists. +-- Delete the row so future dispatches skip it. Mirrors +-- 'Gundeck.Push.Native.deleteTokens' for the native transport. Per the +onGone :: WebPushAddress -> Gundeck () +onGone a = handleAny logDeleteFailure $ do + Prom.incCounter webPushGoneCounter + Log.info $ + field "user" (toByteString a.wpaUser) + ~~ field "endpoint_origin" (endpointOrigin a.wpaEndpoint) + ~~ field "cause" ("Gone" :: Text) + ~~ msg (val "Web push endpoint gone, deleting subscription") + pool <- view pgPool + runWebPush pool (Store.deleteSubscription a.wpaUser a.wpaEndpoint) >>= \case + Left storeErr -> + Log.err $ + field "user" (toByteString a.wpaUser) + ~~ field "error" (show storeErr) + ~~ msg (val "Failed to delete gone web push subscription") + Right () -> pure () + where + logDeleteFailure ex = + Log.err $ + field "user" (toByteString a.wpaUser) + ~~ field "error" (displayException ex) + ~~ msg (val "Unexpected failure deleting gone web push subscription") + +onTooLarge :: UserId -> Gundeck () +onTooLarge uid = do + Prom.incCounter webPushTooLargeCounter + Log.warn $ + field "user" (toByteString uid) + ~~ msg (val "Web push payload too large") + +onPermanentHttpError :: UserId -> Gundeck () +onPermanentHttpError uid = do + Prom.incCounter webPushErrorCounter + Log.warn $ + field "user" (toByteString uid) + ~~ msg (val "Web push failed with non-retryable status") + +-------------------------------------------------------------------------------- +-- Request construction + +-- | Build the RFC 8030 POST request: aes128gcm body + RFC 8030\/8292 headers. +buildRequest :: + EndpointUrl -> + ByteString -> + VapidHeaders -> + -- | TTL (seconds). 0 = transient. + Word32 -> + -- | Urgency header value. + Text -> + IO Request +buildRequest endpoint body vapidHeaders ttl urgency = do + -- 'EndpointUrl' is validated HTTPS by its smart constructor, so + -- 'parseRequest' failing here is unexpected and propagates as a push + -- failure (caught by the per-recipient catch-all). + req0 <- parseRequest (Text.unpack (endpointUrlText endpoint)) + pure $ + req0 + { method = "POST", + requestBody = RequestBodyBS body, + requestHeaders = + [ (hContentEncoding, "aes128gcm"), + ("TTL", toByteString' ttl), + ("Urgency", encodeUtf8 urgency), + (hAuthorization, encodeUtf8 (vhAuthorization vapidHeaders)), + ("Crypto-Key", encodeUtf8 (vhCryptoKey vapidHeaders)) + ] + } + +-------------------------------------------------------------------------------- +-- Pure helpers (exported for testing) + +-- | Outcome of classifying an RFC 8030 push-service response status. Drives +-- the dispatch decision in 'push1' (record \/ delete \/ retry \/ error). +data ResponseOutcome + = -- | 201 Created or 202 Accepted. The push service accepted the message. + ResponseSuccess + | -- | 404 Not Found or 410 Gone. The subscription no longer exists; delete it. + ResponseGone + | -- | 413 Payload Too Large. The encrypted body exceeded the push service limit. + ResponseTooLarge + | -- | 429 Too Many Requests or any 5xx. Retry with backoff. + ResponseRetryable + | -- | Any other 4xx. A permanent client-side error; do not retry. + ResponseError + deriving stock (Eq, Show) + +-- | Map a push-service HTTP status to a dispatch decision (RFC 8030 §5). +-- +-- 201 and 202 indicate the message was accepted (RFC 8030 §5.1). 404 and 410 +-- indicate the subscription has expired or been unsubscribed (RFC 8030 §5.1). +-- 413 indicates the payload is too large (RFC 8030 §5.1). 429 and 5xx are +-- transient (RFC 8030 §5.1) and should be retried with backoff. All other +-- 4xx status codes are permanent client errors. +classifyResponse :: Status -> ResponseOutcome +classifyResponse s = + case statusCode s of + c + | c == 201 || c == 202 -> ResponseSuccess + | c == 404 || c == 410 -> ResponseGone + | c == 413 -> ResponseTooLarge + | c == 429 -> ResponseRetryable + | c >= 500 -> ResponseRetryable + | otherwise -> ResponseError + +-- | Map a Wire push 'Priority' to an RFC 8030 §5.3 @Urgency@ header value. +-- Wire has two levels; RFC 8030 defines four. The mapping is conservative: +-- @LowPriority@ maps to @"low"@ (not @"very-low"@) so the push service still +-- attempts timely delivery, and @HighPriority@ maps to @"high"@ so the user +-- agent wakes immediately. +urgencyFrom :: Priority -> Text +urgencyFrom LowPriority = "low" +urgencyFrom HighPriority = "high" + +-- | Resolve the RFC 8030 §5.2 @TTL@ header value (seconds) from the +-- configured default. 'Nothing' means transient (TTL=0): the message is +-- delivered only if the user agent is immediately reachable, and discarded +-- otherwise. Wire's notification stream (Cassandra) is the durable store, so +-- a transient push is the correct default — the client fetches missed +-- notifications on reconnect. +ttlFrom :: Maybe Word32 -> Word32 +ttlFrom = fromMaybe 0 + +-- | Extract the push-service endpoint origin (scheme + authority, i.e. +-- @https:\/\/host[:port]@) for use as the RFC 8292 @aud@ JWT claim. +-- 'EndpointUrl' is guaranteed HTTPS by its smart constructor, so the +-- @https:\/\/@ prefix is always present; the origin is everything up to the +-- first path separator. +endpointOrigin :: EndpointUrl -> Text +endpointOrigin (EndpointUrl raw) = + case Text.stripPrefix "https://" raw of + Nothing -> raw + Just rest -> "https://" <> Text.takeWhile (\c -> c /= '/' && c /= '?' && c /= '#') rest + +-------------------------------------------------------------------------------- +-- Retry policy and handlers + +-- | Bounded exponential backoff for transient web push failures: at most +-- 'webPushMaxRetries' attempts, starting at 50ms, capped at 5s. Mirrors the +-- shape of @x3@ used elsewhere in the codebase ('Wire.Rpc') but with shorter +-- delays appropriate for push service rate-limit recovery (RFC 8030 §5.1). +webPushRetryPolicy :: RetryPolicy +webPushRetryPolicy = + capDelay 5000000 $ + limitRetries webPushMaxRetries <> exponentialBackoff 50000 + +-- | Maximum retry attempts for a transient web push failure (429 \/ 5xx \/ +-- transport error). Three retries gives the push service four chances total, +-- which is generous for a best-effort notification signal. +webPushMaxRetries :: Int +webPushMaxRetries = 3 + +-- | Retry handlers for 'recovering'. Retries only on: +-- +-- * 'WebPushRetrySignal' — our own signal for 429 \/ 5xx response statuses. +-- * Transient 'HttpException' content (timeout, connection failure). +-- Stricter than 'Bilge.Retry.canRetry': omits 'InternalException' +-- (wraps the SSRF egress filter rejection and must NOT be retried), +-- omits 'StatusCodeException' (we inspect status ourselves via +-- 'httpNoBody'), and omits 'ProxyConnectException' (cannot arise with +-- 'noProxy'); adds 'ConnectionTimeout'. +webPushRetryHandlers :: [RetryStatus -> Handler Gundeck Bool] +webPushRetryHandlers = + [ const $ Handler $ \(_ :: WebPushRetrySignal) -> pure True, + const $ Handler $ \(e :: HttpException) -> pure (retryableHttp e) + ] + +-- | Classify an 'HttpException' as retryable. Conservative: only clearly +-- transient transport failures warrant a retry. +retryableHttp :: HttpException -> Bool +retryableHttp (HttpExceptionRequest _ content) = case content of + ResponseTimeout -> True + ConnectionTimeout -> True + ConnectionFailure {} -> True + ConnectionClosed -> True + _ -> False +retryableHttp _ = False + +-------------------------------------------------------------------------------- +-- Internal exceptions + +-- | Signal thrown inside the 'recovering' action to trigger a retry on a +-- retryable response status (429 \/ 5xx). Not exported; only the retry +-- handler matches it. +data WebPushRetrySignal = WebPushRetrySignal + deriving stock (Show) + +instance Exception WebPushRetrySignal + +-- | Permanent preparation failures, caught by 'onPrepareError' before the +-- retry loop is entered. +data WebPushPrepareError + = WebPushPrepareTooLarge + | WebPushPrepareCryptoFailure !CryptoError + | WebPushConfigMissing + deriving stock (Show) + +instance Exception WebPushPrepareError + +-- | Unwrap a 'Maybe' from the 'Env', throwing 'WebPushConfigMissing' if +-- absent. All three web-push env fields are constructed atomically by +-- 'createEnv', so a 'Nothing' is a wiring bug, not a runtime condition. +require :: Lens' Env (Maybe a) -> Gundeck a +require l = do + mVal <- view l + case mVal of + Just v -> pure v + Nothing -> throwM WebPushConfigMissing + +-------------------------------------------------------------------------------- +-- Prometheus counters + +{-# NOINLINE webPushSuccessCounter #-} +webPushSuccessCounter :: Prom.Counter +webPushSuccessCounter = + Prom.unsafeRegister $ + Prom.counter + Prom.Info + { Prom.metricName = "web_push_success", + Prom.metricHelp = "Number of times web pushes were successfully pushed" + } + +{-# NOINLINE webPushGoneCounter #-} +webPushGoneCounter :: Prom.Counter +webPushGoneCounter = + Prom.unsafeRegister $ + Prom.counter + Prom.Info + { Prom.metricName = "web_push_gone", + Prom.metricHelp = "Number of times web pushes were rejected with 404/410 (subscription gone)" + } + +{-# NOINLINE webPushTooLargeCounter #-} +webPushTooLargeCounter :: Prom.Counter +webPushTooLargeCounter = + Prom.unsafeRegister $ + Prom.counter + Prom.Info + { Prom.metricName = "web_push_too_large", + Prom.metricHelp = + "Number of times web pushes were not pushed due to payload being too large" + } + +{-# NOINLINE webPushErrorCounter #-} +webPushErrorCounter :: Prom.Counter +webPushErrorCounter = + Prom.unsafeRegister $ + Prom.counter + Prom.Info + { Prom.metricName = "web_push_errors", + Prom.metricHelp = + "Number of times web pushes were not pushed due to an unexpected error" + } diff --git a/services/gundeck/src/Gundeck/Push/Web/Crypto.hs b/services/gundeck/src/Gundeck/Push/Web/Crypto.hs new file mode 100644 index 00000000000..bb01939f5aa --- /dev/null +++ b/services/gundeck/src/Gundeck/Push/Web/Crypto.hs @@ -0,0 +1,336 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | RFC 8291 (Message Encryption for Web Push) and RFC 8292 (VAPID — Voluntary +-- Application Server Identification) primitives, implemented with @crypton@. +-- +-- This module is the correctness-critical crypto core of web push delivery. It +-- produces the @aes128gcm@ content-coded body that gundeck POSTs to a browser +-- push-service endpoint (RFC 8030), and signs the per-request VAPID JWT that +-- authenticates gundeck to the push service. +-- +-- == Design: pure core + IO wrapper +-- +-- The public 'encryptPayload' runs in 'IO' because it generates a fresh +-- ephemeral ECDH keypair and 16-byte salt per message (RFC 8291 §3.1). A +-- deterministic, fully-injectable pure core — 'encryptPayloadWith' — underpins +-- it so the RFC 8291 §5 known-answer test can reproduce the exact published +-- ciphertext from fixed test vectors. +module Gundeck.Push.Web.Crypto + ( -- * RFC 8291: aes128gcm content encryption + encryptPayload, + encryptPayloadWith, + EncryptedBody (..), + maxPlaintextLength, + + -- * RFC 8292: VAPID JWT signing + signVapid, + VapidHeaders (..), + + -- * Injection points (for the RFC 8291 KAT) + AsEphemeralKey (..), + mkAsEphemeralKey, + Salt (..), + randomSalt, + + -- * Errors + CryptoError (..), + ) +where + +import Control.Lens ((^.)) +import Crypto.Cipher.AES (AES128) +import Crypto.Cipher.Types (AEADMode (AEAD_GCM), aeadEncrypt, aeadFinalize, aeadInit, cipherInit) +import Crypto.ECC (Curve_P256R1) +import Crypto.Error (CryptoFailable (..), onCryptoFailure) +import Crypto.Hash (SHA256 (..)) +import Crypto.KDF.HKDF qualified as HKDF +import Crypto.Number.Serialize (i2ospOf_, os2ip) +import Crypto.PubKey.ECC.DH qualified as ECC.DH +import Crypto.PubKey.ECC.Prim qualified as ECC.Prim +import Crypto.PubKey.ECC.Types qualified as ECC.Types +import Crypto.PubKey.ECDSA qualified as ECDSA +import Crypto.Random (MonadRandom, getRandomBytes) +import Data.Aeson ((.=)) +import Data.Aeson qualified as Aeson +import Data.ByteArray (convert) +import Data.ByteString qualified as BS +import Data.ByteString.Base64.URL qualified as B64U +import Data.ByteString.Lazy qualified as LBS +import Data.Proxy (Proxy (..)) +import Data.Text.Encoding (decodeUtf8) +import Data.Time.Clock.POSIX (getPOSIXTime) +import Gundeck.Env (VapidKeyPair) +import Gundeck.Env qualified as Env +import Imports +import Wire.API.Push.V2.WebSubscription + ( AuthSecret (..), + P256dhKey (..), + WebPushKeys, + wpkAuth, + wpkP256dh, + ) + +-------------------------------------------------------------------------------- +-- Public types + +-- | The 86-byte header + single encrypted record (ciphertext + 16-byte GCM tag) +-- produced by RFC 8291 aes128gcm content coding. This is the exact body POSTed +-- to the push-service endpoint with @Content-Encoding: aes128gcm@. +newtype EncryptedBody = EncryptedBody + { encryptedBodyBytes :: ByteString + } + deriving stock (Eq, Show) + +-- | A 16-byte random salt (RFC 8291 §3.4). Carried in the first 16 octets of +-- the aes128gcm header. +newtype Salt = Salt + { saltBytes :: ByteString + } + deriving stock (Eq, Show) + +-- | The application server's ephemeral ECDH P-256 keypair for a single push +-- message (RFC 8291 §3.1). In production 'encryptPayload' generates a fresh one +-- per message; for the RFC 8291 KAT it is injected from the published test +-- vector via 'encryptPayloadWith'. +data AsEphemeralKey = AsEphemeralKey + { -- | The 32-byte raw P-256 scalar (big-endian), base64url in RFC §5. + asekPrivateScalar :: !ByteString, + -- | The 65-byte uncompressed public point (@0x04 || X || Y@). + asekPublicBytes :: !ByteString + } + deriving stock (Eq, Show) + +-- | The two HTTP header values RFC 8292 requires on a web push request: +-- +-- @Authorization: vapid t=,k=@ +-- @Crypto-Key: p256ecdsa=@ +data VapidHeaders = VapidHeaders + { vhAuthorization :: !Text, + vhCryptoKey :: !Text + } + deriving stock (Eq, Show) + +-- | Failures surfaced by the pure crypto core. The IO wrappers re-throw these +-- as an 'ErrorCall' / are surfaced by callers; the shape is kept explicit so +-- the KAT can assert on the specific failure mode (e.g. curve rejection). +data CryptoError + = -- | The recipient @p256dh@ key was not the expected 65 bytes. + CryptoInvalidP256dhLength !Int + | -- | The @p256dh@ is 65 bytes but lacks the @0x04@ uncompressed-format + -- prefix (e.g. a compressed point or garbage). + CryptoInvalidP256dhFormat + | -- | The @p256dh@ point does not satisfy the P-256 curve equation + -- (invalid-curve defense, RFC 8291 §7). + CryptoPointNotOnCurve + | -- | The @p256dh@ point is the point at infinity. + CryptoPointAtInfinity + | -- | Plaintext exceeds the single-record limit (RFC 8291 §4). + CryptoPlaintextTooLarge !Int + | -- | The derived CEK could not initialise an AES-128 cipher. + CryptoCipherInitFailed + | -- | AES-128-GCM AEAD initialisation failed. + CryptoAeadInitFailed + deriving stock (Eq, Show) + +-------------------------------------------------------------------------------- +-- Constants (RFC 8291 §3.4, §4) + +-- | P-256 curve in crypton's value-level ECC API. +p256Curve :: ECC.Types.Curve +p256Curve = ECC.Types.getCurveByName ECC.Types.SEC_p256r1 + +-- | Record size (@rs@) in the aes128gcm header. RFC 8291 §4 mandates a single +-- record; @rs@ must exceed plaintext + 1 (delimiter) + 16 (tag). 4096 is the +-- conventional value and the one all browser push services expect. +recordSize :: Integer +recordSize = 4096 + +-- | Maximum plaintext for a single-record message (RFC 8291 §4): the push +-- service budget of 4096 octets minus the 86-byte header, 1 padding delimiter +-- octet, and the 16-octet AEAD tag. +maxPlaintextLength :: Int +maxPlaintextLength = 3993 + +-------------------------------------------------------------------------------- +-- RFC 8291: encryption + +-- | Encrypt a web push payload (RFC 8291), generating a fresh ephemeral ECDH +-- keypair and 16-byte salt. This is what dispatch calls per message. +encryptPayload :: + (MonadRandom m) => + WebPushKeys -> + ByteString -> + m (Either CryptoError EncryptedBody) +encryptPayload keys plaintext = do + scalar <- ECC.DH.generatePrivate p256Curve + let asKey = mkAsEphemeralKey scalar + salt <- randomSalt + pure $! encryptPayloadWith keys asKey salt plaintext + +-- | Deterministic, fully-injectable pure core of RFC 8291 encryption. Follows +-- RFC 8291 §3.4 pseudocode verbatim. This is what the RFC 8291 §5 known-answer +-- test exercises with the published test vectors. +encryptPayloadWith :: + WebPushKeys -> + AsEphemeralKey -> + Salt -> + ByteString -> + Either CryptoError EncryptedBody +encryptPayloadWith keys asKey (Salt salt) plaintext + | BS.length plaintext > maxPlaintextLength = + Left (CryptoPlaintextTooLarge (BS.length plaintext)) + | otherwise = do + let P256dhKey uaPubBs = keys ^. wpkP256dh + AuthSecret authBs = keys ^. wpkAuth + uaPoint <- decodeValidatedP256Point uaPubBs + let asPubBs = asekPublicBytes asKey + ephemScalar = os2ip (asekPrivateScalar asKey) + -- ECDH shared secret (RFC 8291 §3.1): ECDH(as_private, ua_public). + shared = ECC.DH.getShared p256Curve ephemScalar uaPoint + -- HKDF to combine ECDH + auth secrets (RFC 8291 §3.3-3.4). + prkKey = HKDF.extract @SHA256 authBs shared + -- key_info = "WebPush: info" || 0x00 || ua_public || as_public. + -- HKDF.expand appends the 0x01 counter byte internally. + keyInfo = "WebPush: info" <> BS.singleton 0 <> uaPubBs <> asPubBs + ikm :: ByteString + ikm = HKDF.expand @SHA256 prkKey keyInfo 32 + -- RFC 8188 CEK/nonce derivation (HKDF with the random salt). + prk = HKDF.extract @SHA256 salt ikm + cekInfo = "Content-Encoding: aes128gcm" <> BS.singleton 0 + nonceInfo = "Content-Encoding: nonce" <> BS.singleton 0 + cek :: ByteString + cek = HKDF.expand @SHA256 prk cekInfo 16 + nonceBs :: ByteString + nonceBs = HKDF.expand @SHA256 prk nonceInfo 12 + -- AES-128-GCM over (plaintext || 0x02); single record, seq=0, so the + -- nonce is used as-is (RFC 8291 §3.4 final note). + ctTag <- aesGcmEncrypt cek nonceBs (plaintext <> BS.singleton 0x02) + let header = salt <> rsBytes <> BS.singleton 65 <> asPubBs + pure $! EncryptedBody (header <> ctTag) + where + -- rs (record size) as 4 big-endian octets. + rsBytes :: ByteString + rsBytes = i2ospOf_ 4 recordSize + +-- | Decode a 65-byte uncompressed P-256 point (@0x04 || X || Y@) and run the +-- three validation steps from RFC 8291 §7 / X9.62 §4.3.7: reject the point at +-- infinity, check coordinate ranges implicitly via curve-equation membership. +decodeValidatedP256Point :: ByteString -> Either CryptoError ECC.Types.Point +decodeValidatedP256Point bs + | BS.length bs /= 65 = Left (CryptoInvalidP256dhLength (BS.length bs)) + | BS.index bs 0 /= 0x04 = Left CryptoInvalidP256dhFormat + | otherwise = + let x = os2ip (BS.take 32 (BS.drop 1 bs)) + y = os2ip (BS.drop 33 bs) + pt = ECC.Types.Point x y + in if ECC.Prim.isPointAtInfinity pt + then Left CryptoPointAtInfinity + else + if ECC.Prim.isPointValid p256Curve pt + then Right pt + else Left CryptoPointNotOnCurve + +-- | AES-128-GCM encrypt with no associated data, returning ciphertext || tag. +aesGcmEncrypt :: + ByteString -> + ByteString -> + ByteString -> + Either CryptoError ByteString +aesGcmEncrypt cek nonce msg = + onCryptoFailure + (const (Left CryptoCipherInitFailed)) + ( \cipher -> + onCryptoFailure + (const (Left CryptoAeadInitFailed)) + ( \aead -> + let (ct, aead') = aeadEncrypt aead msg + tag = convert (aeadFinalize aead' 16) :: ByteString + in Right (ct <> tag) + ) + (aeadInit AEAD_GCM cipher nonce) + ) + (cipherInit cek :: CryptoFailable AES128) + +-------------------------------------------------------------------------------- +-- Ephemeral key construction + +-- | Build an 'AsEphemeralKey' from a generated private scalar, deriving the +-- public point via scalar base-point multiplication. +mkAsEphemeralKey :: Integer -> AsEphemeralKey +mkAsEphemeralKey scalar = + let pubPoint = ECC.Prim.pointBaseMul p256Curve scalar + pubBs = encodeUncompressedPoint pubPoint + scalarBs = i2ospOf_ 32 scalar + in AsEphemeralKey scalarBs pubBs + +-- | Serialise a P-256 point as the 65-byte uncompressed form @0x04 || X || Y@. +encodeUncompressedPoint :: ECC.Types.Point -> ByteString +encodeUncompressedPoint pt = case pt of + ECC.Types.Point x y -> BS.singleton 0x04 <> i2ospOf_ 32 x <> i2ospOf_ 32 y + ECC.Types.PointO -> BS.empty + +-- | Generate a fresh 16-byte salt (RFC 8291 §3.4) from the system CSPRNG. +randomSalt :: (MonadRandom m) => m Salt +randomSalt = Salt <$> getRandomBytes 16 + +-------------------------------------------------------------------------------- +-- RFC 8292: VAPID signing + +-- | Produce the VAPID 'VapidHeaders' for a web push request (RFC 8292 §2-3): +-- an ES256-signed JWT carrying @aud@, @exp@ (now + 12h), @sub@, plus the +-- server's static public key. The private key comes from 'VapidKeyPair'; +-- the public key is already base64url-encoded in '_vkpPublicB64'. +signVapid :: + VapidKeyPair -> + -- | @sub@ject: a @mailto:@ or @https:@ URL identifying the operator. + Text -> + -- | @aud@ience: the push-service endpoint origin. + Text -> + IO VapidHeaders +signVapid kp subject audience = do + now <- getPOSIXTime + let expiry :: Integer + expiry = round now + (12 * 60 * 60) + headerJson = + Aeson.object + [ "alg" .= ("ES256" :: Text), + "typ" .= ("JWT" :: Text) + ] + payloadJson = + Aeson.object + [ "aud" .= audience, + "exp" .= expiry, + "sub" .= subject + ] + headerBs = LBS.toStrict (Aeson.encode headerJson) + payloadBs = LBS.toStrict (Aeson.encode payloadJson) + signingInput = b64url headerBs <> "." <> b64url payloadBs + sig <- ECDSA.sign (Proxy @Curve_P256R1) (kp ^. Env.vkpPrivate) SHA256 signingInput + let (r, s) = ECDSA.signatureToIntegers (Proxy @Curve_P256R1) sig + -- JOSE ES256 requires the raw R||S (64 bytes), NOT DER. + sigBs = i2ospOf_ 32 r <> i2ospOf_ 32 s + jwt = signingInput <> "." <> b64url sigBs + pubB64 = kp ^. Env.vkpPublicB64 + in pure + VapidHeaders + { vhAuthorization = "vapid t=" <> decodeUtf8 jwt <> ",k=" <> pubB64, + vhCryptoKey = "p256ecdsa=" <> pubB64 + } + +b64url :: ByteString -> ByteString +b64url = B64U.encodeUnpadded diff --git a/services/gundeck/src/Gundeck/Push/Web/Runner.hs b/services/gundeck/src/Gundeck/Push/Web/Runner.hs new file mode 100644 index 00000000000..c072f4cb044 --- /dev/null +++ b/services/gundeck/src/Gundeck/Push/Web/Runner.hs @@ -0,0 +1,76 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Minimal 'Polysemy.Sem' runner embedding the 'Wire.WebPushStore' effect +-- stack inside gundeck's 'Gundeck.Gundeck' monad. +-- +-- Gundeck is not (yet) a full Polysemy application: its monad is a plain +-- @'ReaderT' 'Gundeck.Env.Env' 'Cassandra.Client'@ (see +-- 'Gundeck.Monad.Gundeck'). Rather than rewrite it, we run only the thin effect +-- stack that 'Wire.WebPushStore.Postgres.interpretWebPushStoreToPostgres' +-- demands — exactly the three effects in 'Wire.Postgres.PGConstraints' +-- (@'Polysemy.Input.Input' 'Hasql.Pool.Pool'@, @'Polysemy.Embed.Embed' 'IO'@, +-- @'Polysemy.Error.Error' 'Hasql.Errors.UsageError'@) — plus the store effect +-- on top. This mirrors the "effect in lib, minimal Sem runner in gundeck". +-- +-- Callers obtain the pool from 'Gundeck.Env.Env' and surface a 'Left' +-- 'UsageError' as an HTTP 500, e.g. +-- +-- @ +-- runWebPush pool (insertSubscription uid sub conn) >>= \case +-- Right () -> pure () +-- Left err -> 'Control.Monad.throwM' ('Network.Wai.Utilities.Server.mkError' 'Network.HTTP.Types.status500' "web-push-store-error" (show err)) +-- @ +module Gundeck.Push.Web.Runner (runWebPush) where + +import Hasql.Pool (Pool, UsageError) +import Imports +import Polysemy +import Polysemy.Error (Error, runError) +import Polysemy.Input (Input, runInputConst) +import Wire.WebPushStore (WebPushStore) +import Wire.WebPushStore.Postgres (interpretWebPushStoreToPostgres) + +-- | Run a 'WebPushStore' effect program against the shared Hasql 'Pool', +-- returning the result or a Postgres 'UsageError'. +-- +-- The effect order in the signature is significant: 'interpretWebPushStoreToPostgres' +-- requires 'Wire.Postgres.PGConstraints', i.e. @'Input' 'Pool'@, @'Embed' 'IO'@ +-- and @'Error' 'UsageError'@ must all be /members below/ 'WebPushStore' in the +-- stack. The body applies the interpreters in composition order +-- (rightmost\/innermost first): the store interpreter consumes 'WebPushStore' +-- while the three constraint effects are still present; 'runInputConst' then +-- feeds the pool; 'runError' surfaces 'UsageError' as an 'Either'; 'runM' +-- embeds the residual 'Embed' 'IO' into 'IO'. +-- +-- We use 'runM' rather than the @'runFinal' '.' 'embedToFinal'@ final-style +-- pipeline that galley uses: final-style requires @'Final' 'IO'@ to already +-- inhabit the effect row (so @'embedToFinal'@ has something to lower @'Embed'@ +-- onto), which would leak a @'Final' 'IO'@ member into every caller's 'Sem' +-- type. 'runM' is the idiomatic choice for a thin embedded runner like this, +-- where the (minor) performance difference is irrelevant. +runWebPush :: + (MonadIO m) => + Pool -> + Sem '[WebPushStore, Input Pool, Error UsageError, Embed IO] a -> + m (Either UsageError a) +runWebPush pool = + liftIO + . runM + . runError @UsageError + . runInputConst pool + . interpretWebPushStoreToPostgres diff --git a/services/gundeck/src/Gundeck/Push/Web/Serialise.hs b/services/gundeck/src/Gundeck/Push/Web/Serialise.hs new file mode 100644 index 00000000000..81c973cf9ba --- /dev/null +++ b/services/gundeck/src/Gundeck/Push/Web/Serialise.hs @@ -0,0 +1,80 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Plaintext notice JSON for web push (RFC 8030 \/ RFC 8291). +-- +-- This is the 'Gundeck.Push.Native.Serialise'-equivalent for the web transport: +-- the same logical @{"type":"notice","data":{"id":...},"user":...}@ shape, but +-- produced as a strict 'ByteString' for 'Gundeck.Push.Web.Crypto.encryptPayload' +-- rather than wrapped in a transport-specific (GCM\/APNS) envelope for SNS. +-- The native push payload is encrypted and delivered by AWS SNS; for web push, +-- gundeck itself is the RFC 8030 application server and encrypts the body per +-- RFC 8291 before POSTing (see "Gundeck.Push.Web"). +-- +-- RFC 8291 §4 mandates a single aes128gcm record per message, which caps the +-- plaintext at 'maxPlaintextLength' (3993) bytes. 'serialise' enforces this +-- bound and refuses to emit an oversized payload: the browser push service +-- would reject the resulting body anyway, and surfacing the failure here lets +-- dispatch ('Gundeck.Push.Web.push1') record it on the @web_push_too_large@ +-- counter instead of attempting a doomed HTTP POST. +module Gundeck.Push.Web.Serialise + ( serialise, + WebPushSerialiseError (..), + ) +where + +import Data.Aeson (object, (.=)) +import Data.Aeson qualified as Aeson +import Data.ByteString qualified as BS +import Data.ByteString.Lazy qualified as LBS +import Data.Id (UserId) +import Gundeck.Push.Native.Types (NativePush (npNotificationid)) +import Gundeck.Push.Web.Crypto (maxPlaintextLength) +import Imports + +-- | Reasons 'serialise' can refuse to build a payload. +data WebPushSerialiseError + = -- | The serialised JSON exceeds the RFC 8291 §4 single-record plaintext + -- limit ('maxPlaintextLength', 3993 bytes). Encrypting it would yield a + -- multi-record aes128gcm body, which browser push services reject. + WebPushPayloadTooLarge + deriving stock (Eq, Show) + +-- | Build the plaintext notice JSON that 'Gundeck.Push.Web.Crypto.encryptPayload' +-- encrypts for a single web push subscription. The shape mirrors the native +-- notice so the in-app payload is identical across transports: +-- +-- @{"type":"notice","data":{"id":""},"user":""}@ +-- +-- Returns 'Left' 'WebPushPayloadTooLarge' when the serialised JSON would not +-- fit in a single RFC 8291 record. In practice the JSON is well under the +-- 3993-byte budget — it carries only ids — but the check is mandated by +-- RFC 8291 §4 and defends against a pathological (or attacker-crafted) +-- 'NotificationId'. +serialise :: NativePush -> UserId -> Either WebPushSerialiseError ByteString +serialise np uid + | BS.length json > maxPlaintextLength = Left WebPushPayloadTooLarge + | otherwise = Right json + where + json = + LBS.toStrict $ + Aeson.encode $ + object + [ "type" .= ("notice" :: Text), + "data" .= object ["id" .= np.npNotificationid], + "user" .= uid + ] diff --git a/services/gundeck/src/Gundeck/Push/Web/Ssrf.hs b/services/gundeck/src/Gundeck/Push/Web/Ssrf.hs new file mode 100644 index 00000000000..fce315f9fc5 --- /dev/null +++ b/services/gundeck/src/Gundeck/Push/Web/Ssrf.hs @@ -0,0 +1,136 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Pure SSRF egress filter for the web push hardened HTTP 'Manager'. +-- +-- 'isPrivateLiteralHost' inspects a request's host string and reports whether +-- it is a /literal/ IP address in a private, loopback, link-local, or +-- unspecified range — i.e. an address gundeck must never POST a web push +-- notification to (RFC 1918, RFC 3927, RFC 4193, RFC 6598, RFC 4291). +-- +-- == Scope and limits (v1) +-- +-- This is a *literal* check only. It catches the obvious direct-literal cases: +-- @http://127.0.0.1\/...@, @http:\/\/169.254.169.254\/...@ (cloud metadata), +-- @http:\/\/[::1]\/...@, etc. It does /not/ catch: +-- +-- * DNS rebinding, where an allowlisted hostname resolves to a private IP at +-- request time. The registration-time @_endpointAllowlist@ +-- ('Gundeck.Options.endpointAllowlist') is the primary mitigation; this +-- module is belt-and-suspenders. +-- * Hostnames that are not literal IPs. Such strings return 'False' here — the +-- host allowlist handles them. +-- +-- Full DNS-resolution-time filtering is a deliberate v1 follow-up: it requires +-- an @getaddrinfo@ lookup at the @managerModifyRequest@ layer, which adds IO +-- and failure modes. +module Gundeck.Push.Web.Ssrf + ( isPrivateLiteralHost, + isPrivateIPv4, + parseIPv4, + ) +where + +import Data.Bits (shiftL, (.&.), (.|.)) +import Data.ByteString.Char8 qualified as BC +import Imports + +-- | Returns 'True' if the host is a literal IP address in a private, +-- loopback, link-local, cloud-metadata, CGNAT, or unspecified range. +-- +-- The conventional loopback hostname @"localhost"@ is also treated as +-- private. All other hostnames (non-IP strings) return 'False'. +isPrivateLiteralHost :: ByteString -> Bool +isPrivateLiteralHost h + | BC.map toLower h == "localhost" = True + | Just w <- parseIPv4 h = isPrivateIPv4 w + | otherwise = isPrivateIPv6Literal h + +-------------------------------------------------------------------------------- +-- IPv4 + +-- | Parse a dotted-decimal IPv4 literal (four octets, 0–255 each) into a +-- big-endian 'Word32'. Returns 'Nothing' for anything that is not exactly +-- four dot-separated decimal octets in range — including hostnames, IPv6 +-- literals, and malformed addresses. +-- +-- Leading zeros are accepted and parsed as decimal (i.e. @010.0.0.1@ becomes +-- @10.0.0.1@, /not/ octal); this matches what a browser would resolve. +parseIPv4 :: ByteString -> Maybe Word32 +parseIPv4 bs = + case BC.split '.' bs of + [a, b, c, d] -> do + a' <- readOctet a + b' <- readOctet b + c' <- readOctet c + d' <- readOctet d + Just $ (a' `shiftL` 24) .|. (b' `shiftL` 16) .|. (c' `shiftL` 8) .|. d' + _ -> Nothing + where + readOctet :: ByteString -> Maybe Word32 + readOctet s = + case readMaybe (BC.unpack s) of + Just n | n <= 255 -> Just n + _ -> Nothing + +-- | IPv4 private \/ reserved ranges: +-- +-- * @0.0.0.0\/8@ — "this network" (RFC 1122) +-- * @10.0.0.0\/8@ — private (RFC 1918) +-- * @100.64.0.0\/10@ — CGNAT (RFC 6598) +-- * @127.0.0.0\/8@ — loopback +-- * @169.254.0.0\/16@ — link-local (RFC 3927); includes the AWS / GCP cloud +-- metadata endpoints at @169.254.169.254@, the canonical SSRF target. +-- * @172.16.0.0\/12@ — private (RFC 1918) +-- * @192.168.0.0\/16@ — private (RFC 1918) +isPrivateIPv4 :: Word32 -> Bool +isPrivateIPv4 w = + (w .&. 0xFF000000) == 0x00000000 + || (w .&. 0xFF000000) == 0x0A000000 + || (w .&. 0xFFC00000) == 0x64400000 + || (w .&. 0xFF000000) == 0x7F000000 + || (w .&. 0xFFFF0000) == 0xA9FE0000 + || (w .&. 0xFFF00000) == 0xAC100000 + || (w .&. 0xFFFF0000) == 0xC0A80000 + +-------------------------------------------------------------------------------- +-- IPv6 (coarse literal-prefix matching for the common private ranges). +-- Full RFC 4291 parsing with '::' expansion is out of scope for v1. + +isPrivateIPv6Literal :: ByteString -> Bool +isPrivateIPv6Literal raw + -- An IPv6 literal always contains at least one ':'. Rejecting strings + -- without ':' here avoids false positives on hostnames that happen to + -- start with "fc" or "fd" (e.g. "fcm.googleapis.com"). + | not (BC.elem ':' h) = False + | otherwise = + -- IPv4-mapped (::ffff:a.b.c.d): the embedded IPv4 carries the real + -- destination. Strip the prefix and re-check the IPv4 part. + case BC.stripPrefix "::ffff:" h of + Just rest -> isJust (parseIPv4 rest) + Nothing -> + h == "::1" + || h == "::" + || h == "::0" + || "fc" `BC.isPrefixOf` h + || "fd" `BC.isPrefixOf` h + || "fe80:" `BC.isPrefixOf` h + || "fe90:" `BC.isPrefixOf` h + || "fea0:" `BC.isPrefixOf` h + || "feb0:" `BC.isPrefixOf` h + where + h = BC.map toLower raw diff --git a/services/gundeck/test/unit/Main.hs b/services/gundeck/test/unit/Main.hs index 826e49f401f..63194b7c516 100644 --- a/services/gundeck/test/unit/Main.hs +++ b/services/gundeck/test/unit/Main.hs @@ -30,6 +30,13 @@ import ParseExistsError qualified import Push qualified import Test.Tasty import ThreadBudget qualified +import Vapid qualified +import WebPushCrypto qualified +import WebPushDispatch qualified +import WebPushHandlers qualified +import WebPushRunner qualified +import WebPushSerialise qualified +import WebPushSsrf qualified main :: IO () main = @@ -42,5 +49,12 @@ main = Push.tests, ThreadBudget.tests, ParseExistsError.tests, - Aws.Arn.tests + Aws.Arn.tests, + Vapid.tests, + WebPushCrypto.tests, + WebPushDispatch.tests, + WebPushHandlers.tests, + WebPushRunner.tests, + WebPushSerialise.tests, + WebPushSsrf.tests ] diff --git a/services/gundeck/test/unit/MockGundeck.hs b/services/gundeck/test/unit/MockGundeck.hs index f6d6ebe44f0..3969d391d84 100644 --- a/services/gundeck/test/unit/MockGundeck.hs +++ b/services/gundeck/test/unit/MockGundeck.hs @@ -78,6 +78,7 @@ import Wire.API.Notification import Wire.API.Presence import Wire.API.Push.V2 hiding (recipient) import Wire.API.User.Client (Client (..), UserClientsFull (..), supportsConsumableNotifications) +import Wire.WebPushStore (WebPushAddress (..)) ---------------------------------------------------------------------- -- env @@ -96,8 +97,15 @@ data ClientInfo = ClientInfo } deriving (Eq, Show) -newtype MockEnv = MockEnv - { _meClientInfos :: Map UserId (Map ClientId ClientInfo) +data MockEnv = MockEnv + { -- | Per-user, per-client native push reachability info. + _meClientInfos :: Map UserId (Map ClientId ClientInfo), + -- | Per-user web push subscriptions (the browser-supplied push-service + -- endpoints the dispatcher POSTs to). A user may have multiple + -- subscriptions per client (multiple browsers, multiple devices). + -- The mock 'mpaWebTargets' reads this fixture; the mock + -- 'handlePushWeb' computes the expected deliveries from it. + _meWebSubscriptions :: Map UserId [WebPushAddress] } deriving (Eq, Show) @@ -106,6 +114,13 @@ data MockState = MockState _msWSQueue :: NotifQueue, -- | A record of notifications that have been pushed via native push. _msNativeQueue :: NotifQueue, + -- | A record of notifications that have been pushed via web push + -- (RFC 8030 application-server path). Mirrors '_msNativeQueue': the + -- key is @(user, client)@ and the value is a multiset of payload-IDs, + -- so a user with multiple web push subscriptions for the same client + -- accumulates one delivery per subscription (matching production + -- 'WebPush.push' which POSTs to each 'WebPushAddress' independently). + _msWebPushQueue :: NotifQueue, -- | Non-transient notifications that are stored in the database first thing before -- delivery (so clients can always come back and pick them up later until they expire). _msCassQueue :: NotifQueue, @@ -126,19 +141,28 @@ makeLenses ''MockEnv makeLenses ''MockState instance Show MockState where - show (MockState w n c r) = + show (MockState w n wp c r) = intercalate "\n" - ["", "websocket: " <> show w, "native: " <> show n, "cassandra: " <> show c, "rabbitmq: " <> show r, ""] + [ "", + "websocket: " <> show w, + "native: " <> show n, + "webpush: " <> show wp, + "cassandra: " <> show c, + "rabbitmq: " <> show r, + "" + ] emptyMockState :: MockState -emptyMockState = MockState mempty mempty mempty mempty +emptyMockState = MockState mempty mempty mempty mempty mempty -- these custom instances make for better error reports if tests fail. instance ToJSON MockEnv where - toJSON (MockEnv mp) = + toJSON env = Aeson.object - ["clientInfos" Aeson..= mp] + [ "clientInfos" Aeson..= (env ^. meClientInfos), + "webSubscriptions" Aeson..= (env ^. meWebSubscriptions) + ] instance ToJSON ClientInfo where toJSON (ClientInfo client native wsreach) = @@ -171,6 +195,7 @@ instance FromJSON MockEnv where parseJSON = withObject "MockEnv" $ \env -> MockEnv <$> env Aeson..: "clientInfos" + <*> env Aeson..:? "webSubscriptions" Aeson..!= mempty instance FromJSON ClientInfo where parseJSON = withObject "ClientInfo" $ \cinfo -> @@ -197,6 +222,47 @@ mkFakeAddrEndpoint (epid, transport, app) = Aws.mkSnsArn Tokyo (Account "acc") e where eptopic = mkEndpointTopic (ArnEnv "") transport app (EndpointId epid) +-- | Test-only 'ToJSON' for 'WebPushAddress', mirroring the 'Address' instance +-- above: the mock-env 'ToJSON' is used for test-failure diagnostics via +-- 'Pretty', so we encode the full address (minus the redaction the production +-- 'Show' applies — these are synthetic fixtures, not real keys). +instance ToJSON WebPushAddress where + toJSON a = + Aeson.object + [ "user" Aeson..= a.wpaUser, + "conn" Aeson..= a.wpaConn, + "client" Aeson..= a.wpaClient, + "endpoint" Aeson..= a.wpaEndpoint, + "keys" Aeson..= a.wpaKeys + ] + +instance FromJSON WebPushAddress where + parseJSON = withObject "WebPushAddress" $ \a -> + WebPushAddress + <$> (a Aeson..: "user") + <*> (a Aeson..: "conn") + <*> (a Aeson..: "client") + <*> (a Aeson..: "endpoint") + <*> (a Aeson..: "keys") + +-- | Generate a 'WebPushAddress' for a specific @(user, client)@ pair, with the +-- @conn@ derived from the client (matching 'fakeConnId' so production +-- filtering by @pushConnections@ / @pushOriginConnection@ behaves correctly) +-- and the @endpoint@\/@keys@ fully random. Multiple calls yield distinct +-- addresses (different endpoints), modelling a user with multiple browsers. +genWebPushAddress :: UserId -> ClientId -> Gen WebPushAddress +genWebPushAddress wpaUser wpaClient = do + wpaEndpoint <- arbitrary + wpaKeys <- arbitrary + let wpaConn = fakeConnId wpaClient + pure WebPushAddress {..} + +instance Arbitrary WebPushAddress where + arbitrary = do + uid <- arbitrary + cid <- ClientId <$> arbitrary + genWebPushAddress uid cid + ---------------------------------------------------------------------- -- env generators @@ -254,18 +320,42 @@ genMockEnv = do in nubrec <$> forM uids gencids -- Build an 'MockEnv' containing a map with all those 'ClientInfo's, and -- check that it validates - env <- - MockEnv . Map.fromList . fmap (_2 %~ Map.fromList) <$> do + clientInfos <- + Map.fromList . fmap (_2 %~ Map.fromList) <$> do forM (zip uids cidss) $ \(uid, cids) -> (uid,) <$> do forM cids $ \cid -> (cid,) <$> genClientInfo uid cid + -- For some (user, client) pairs, generate 0..N web push subscriptions + -- (modelling multiple browsers). The (user, client) pair is drawn from + -- the same set as 'clientInfos' so 'mpaWebTargets' lookups align with + -- the recipient fixtures. + webSubsList <- + forM (zip uids cidss) $ \(uid, cids) -> do + -- Each client independently gets 0..2 web subscriptions with a bias + -- toward none (so the env exercises the no-web-sub case often). + subsPerClient <- + forM cids $ \cid -> + QC.frequency + [ (5, pure []), + (3, pure <$> genWebPushAddress uid cid), + (1, (\a b -> [a, b]) <$> genWebPushAddress uid cid <*> genWebPushAddress uid cid) + ] + pure (uid, concat subsPerClient) + let webSubscriptions = Map.fromList webSubsList + env = MockEnv clientInfos webSubscriptions validateMockEnv env & either error (const $ pure env) --- Try to shrink a 'MockEnv' by removing some users from '_meClientInfos'. +-- Try to shrink a 'MockEnv' by removing some users from '_meClientInfos' +-- (and the corresponding entries from '_meWebSubscriptions'). shrinkMockEnv :: MockEnv -> [MockEnv] -shrinkMockEnv (MockEnv cis) = - MockEnv . Map.fromList - <$> filter (not . null) (shrinkList (const []) (Map.toList cis)) +shrinkMockEnv env = + [ env' + | keysToRemove <- filter (not . null) (shrinkList (const []) (Map.toList (env ^. meClientInfos))), + let env' = + env + & meClientInfos .~ Map.fromList keysToRemove + & meWebSubscriptions %~ Map.filterWithKey (\k _ -> k `Set.member` Set.fromList (fst <$> keysToRemove)) + ] validateMockEnv :: forall m. (MonadError String m) => MockEnv -> m () validateMockEnv env = do @@ -441,6 +531,8 @@ instance MonadPushAll MockGundeck where mpaBulkPush = mockBulkPush mpaStreamAdd = mockStreamAdd mpaPushNative = mockPushNative + mpaPushWeb = mockPushWeb + mpaWebTargets = mockWebTargets -- \| just don't fork. (this *may* cause deadlocks in principle, but as long as -- it doesn't, this is good enough for testing). @@ -485,6 +577,7 @@ mockPushAll pushes = do forM_ pushes $ \psh -> do handlePushWS psh handlePushNative psh + handlePushWeb psh handlePushCass psh handlePushRabbit psh @@ -546,6 +639,90 @@ handlePushNative Push {..} = do where origin = (_pushOrigin, clientIdFromConnId <$> _pushOriginConnection) +-- | From a single 'Push', deliver eligible 'Notification's via web push +-- (RFC 8030). Mirrors 'handlePushNative' but for the browser-subscription +-- path instead of the native SNS path. A client receives a web push when: +-- +-- 1. The push is not transient (matches native; web push is for durable +-- notices, and a transient push signals the client is reachable over +-- websocket right now). +-- 2. The route is not 'RouteDirect' (matches native routing). +-- 3. The user has at least one web push subscription for that client +-- (the equivalent of @nativeReachable@). +-- 4. The client is NOT served over websocket. The production +-- 'pushAllLegacy' computes @dontPush = alreadySentClients ∪ +-- rabbitmqClientIds@ where @alreadySentClients@ are the WS-delivered +-- clients; 'webTargets' then filters out addresses whose +-- @(user, client)@ is in @dontPush@. This is the equivalent of +-- 'handlePushNative's @not (wsReachable env (uid, cid))@ clause. +-- 5. The client is NOT consumable (does not support consumable +-- notifications). Production routes consumable clients to +-- 'pushAllViaRabbitMq', which does NOT call 'pushWebWithBudget' +-- (consumable clients receive notifications via the rabbitmq message +-- queue and do not need browser web push). The legacy recipient +-- iteration in 'pushAllLegacy' never sees consumable clients, so +-- production 'webTargets' never iterates them; the mock must therefore +-- skip them explicitly to match. This is the one structural +-- asymmetry vs. 'handlePushNative': native push is dispatched from +-- BOTH 'pushAllLegacy' and 'pushAllViaRabbitMq', whereas web push is +-- dispatched only from 'pushAllLegacy'. +-- 6. Origin rules match native. +-- 7. The connection allowlist permits delivery. +handlePushWeb :: + (HasCallStack, m ~ MockGundeck) => + Push -> + m () +handlePushWeb Push {..} + -- Condition 1: transient pushes are not sent via web push. + | _pushTransient = pure () +handlePushWeb Push {..} = do + env <- ask + forM_ _pushRecipients $ \(Recipient uid route cids) -> do + let cids' = case cids of + RecipientClientsAll -> clientIdsOfUser env uid + RecipientClientsSome cc -> toList cc + forM_ cids' $ \cid -> do + -- All web push subscriptions the user has for this client (a user may + -- have multiple browsers, each with its own subscription). + let subsForClient = filter ((== cid) . wpaClient) (webSubscriptionsOf env uid) + -- Condition 2: 'RouteDirect' pushes are not eligible (matches native). + let isWebEligible = route /= RouteDirect + -- Condition 3: subscription must exist (analog of nativeReachable). + let hasSub = not (null subsForClient) + -- Condition 4: not served over websocket (websocket takes priority). + let notWsServed = not (wsReachable env (uid, cid)) + -- Condition 5: not a consumable (rabbitmq-routed) client. + let notConsumable = not (clientIsConsumable env uid cid) + -- Condition 6: origin rules (matches native). + let isOriginUser = Just uid == fst origin + isOriginDevice = origin == (Just uid, Just cid) + isAllowedPerOriginRules = + not isOriginUser || (_pushNativeIncludeOrigin && not isOriginDevice) + -- Condition 7: connection allowlist (matches native). Each + -- subscription's wpaConn is derived from fakeConnId cid in the + -- generator, so all subs for the same cid share the same conn — + -- checking any one of them suffices, but we check per-sub to be + -- future-proof against a generator that diverges them. + when (isWebEligible && hasSub && notWsServed && notConsumable && isAllowedPerOriginRules) $ + forM_ subsForClient $ \sub -> + when (null _pushConnections || sub.wpaConn `elem` _pushConnections) $ + msWebPushQueue %= deliver (uid, cid) _pushPayload + where + origin = (_pushOrigin, clientIdFromConnId <$> _pushOriginConnection) + +-- | Is the given @(user, client)@ a "consumable notifications" client (i.e. +-- routed to the rabbitmq pipeline by 'splitPush', and therefore never seen +-- by 'pushAllLegacy' \/ 'pushWebWithBudget' in production)? Mirrors the +-- @supportsConsumableNotifications@ check used by 'handlePushCass'. +clientIsConsumable :: MockEnv -> UserId -> ClientId -> Bool +clientIsConsumable env uid cid = + maybe False (supportsConsumableNotifications . (^. ciClient)) $ + (Map.lookup uid >=> Map.lookup cid) (env ^. meClientInfos) + +-- | All web push subscriptions the mock env has registered for a user. +webSubscriptionsOf :: MockEnv -> UserId -> [WebPushAddress] +webSubscriptionsOf env uid = fromMaybe [] $ Map.lookup uid (env ^. meWebSubscriptions) + -- | From a single 'Push', store only those notifications that real Gundeck would put into -- Cassandra. handlePushCass :: Push -> MockGundeck () @@ -646,6 +823,24 @@ mockPushNative (ntfPayload -> payload) _ addrs = do msNativeQueue %= deliver (addr ^. addrUser, addr ^. addrClient) payload +-- | Mock 'mpaPushWeb' implementation. Records the delivered payload per +-- @(user, client)@ slot in 'msWebPushQueue', mirroring 'mockPushNative'. +-- Unlike 'mockPushNative' we do not re-check reachability here: the +-- @WebPushAddress@ list passed in is already the filtered output of +-- 'webTargets' (production) or the filtered list of subscriptions +-- (handlePushWeb), so each address represents a real subscription that +-- would receive a POST. Multiple addresses for the same @(user, client)@ +-- (multiple browsers) each accumulate one delivery, matching production +-- 'WebPush.push' which fans out per address. +mockPushWeb :: + Notification -> + Priority -> + [WebPushAddress] -> + MockGundeck () +mockPushWeb (ntfPayload -> payload) _ addrs = + forM_ addrs $ \a -> + msWebPushQueue %= deliver (a.wpaUser, a.wpaClient) payload + mockPushRabbitMq :: Text -> Text -> AMQP.Message -> MockGundeck () mockPushRabbitMq exchange routingKey message = do case Aeson.eitherDecode message.msgBody of @@ -653,6 +848,15 @@ mockPushRabbitMq exchange routingKey message = do Right (queuedNotif :: QueuedNotification) -> msRabbitQueue %= deliver (exchange, routingKey) (queuedNotif ^. queuedNotificationPayload) +-- | Mock 'mpaWebTargets' implementation: return the fixture subscriptions +-- for the user from 'MockEnv'. Mirrors 'mockLookupAddresses' for the +-- native path. Returns @[]@ for unknown users (matching the production +-- resilient behavior on lookup failure). +mockWebTargets :: + UserId -> + MockGundeck [WebPushAddress] +mockWebTargets uid = asks (^. meWebSubscriptions) <&> fromMaybe [] . Map.lookup uid + mockLookupAddresses :: (HasCallStack, m ~ MockGundeck) => UserId -> @@ -685,7 +889,7 @@ mockBulkSend uri notifs = do mockGetClients :: Set UserId -> MockGundeck UserClientsFull mockGetClients uids = do - MockEnv allClientInfos <- ask + allClientInfos <- asks (^. meClientInfos) let getClients uid = let clientInfos = foldMap Map.elems $ Map.lookup uid allClientInfos in Set.fromList $ map (^. ciClient) clientInfos @@ -737,14 +941,14 @@ mkWSStatus = do else PushStatusGone wsReachable :: MockEnv -> (UserId, ClientId) -> Bool -wsReachable (MockEnv mp) (uid, cid) = +wsReachable env (uid, cid) = maybe False (^. ciWSReachable) $ - (Map.lookup uid >=> Map.lookup cid) mp + (Map.lookup uid >=> Map.lookup cid) (env ^. meClientInfos) nativeReachable :: MockEnv -> (UserId, ClientId) -> Bool -nativeReachable (MockEnv mp) (uid, cid) = +nativeReachable env (uid, cid) = maybe False (^. _2) $ - (Map.lookup uid >=> Map.lookup cid >=> (^. ciNativeAddress)) mp + (Map.lookup uid >=> Map.lookup cid >=> (^. ciNativeAddress)) (env ^. meClientInfos) nativeReachableAddr :: MockEnv -> Address -> Bool nativeReachableAddr env addr = nativeReachable env (addr ^. addrUser, addr ^. addrClient) @@ -753,11 +957,11 @@ allUsers :: MockEnv -> [UserId] allUsers = fmap fst . allRecipients allRecipients :: MockEnv -> [(UserId, [ClientId])] -allRecipients (MockEnv mp) = (_2 %~ Map.keys) <$> Map.toList mp +allRecipients env = (_2 %~ Map.keys) <$> Map.toList (env ^. meClientInfos) clientIdsOfUser :: (HasCallStack) => MockEnv -> UserId -> [ClientId] -clientIdsOfUser (MockEnv mp) uid = - maybe (error "unknown UserId") Map.keys $ Map.lookup uid mp +clientIdsOfUser env uid = + maybe (error "unknown UserId") Map.keys $ Map.lookup uid (env ^. meClientInfos) -- | See also: 'fakePresence'. fakePresences :: (UserId, [ClientId]) -> [Presence] diff --git a/services/gundeck/test/unit/Push.hs b/services/gundeck/test/unit/Push.hs index fc661d82baf..2fb078e6766 100644 --- a/services/gundeck/test/unit/Push.hs +++ b/services/gundeck/test/unit/Push.hs @@ -20,8 +20,12 @@ module Push where +import Control.Lens hiding (united) import Data.Aeson qualified as Aeson +import Data.Aeson.KeyMap qualified as KeyMap import Data.Id +import Data.IntMultiSet qualified as MSet +import Data.List.NonEmpty (NonEmpty ((:|))) import Data.Map qualified as Map import Data.Set qualified as Set import Data.These.Combinators @@ -30,14 +34,18 @@ import Gundeck.Push.Websocket as Web (bulkPush) import Imports import MockGundeck import Test.QuickCheck +import Test.QuickCheck.Gen (unGen) import Test.QuickCheck.Instances () +import Test.QuickCheck.Random (mkQCGen) import Test.Tasty +import Test.Tasty.HUnit import Test.Tasty.QuickCheck import Wire.API.Internal.Notification import Wire.API.Presence import Wire.API.Push.V2 import Wire.API.User.Client import Wire.Arbitrary +import Wire.WebPushStore (WebPushAddress (..)) tests :: TestTree tests = @@ -48,7 +56,15 @@ tests = [ testProperty "web sockets" webBulkPushProps, testProperty "native pushes" pushAllProps ], - testGroup "splitPush" [testProperty "rabbitmq pushes" splitPushActualRecipients] + testGroup "splitPush" [testProperty "rabbitmq pushes" splitPushActualRecipients], + testGroup + "web push flow" + [ testCase "web-only user (no native addr) receives a web push" webOnlyUserGetsWebPush, + testCase "both-subscribed offline user receives both native and web" bothSubscribedOfflineGetsBoth, + testCase "websocket-served client does not receive a web push" wsServedSkipsWebPush, + testCase "origin client does not receive a web push" originClientSkipsWebPush, + testCase "transient push skips web push" transientPushSkipsWebPush + ] ] mkEnv :: (Pretty MockEnv -> Property) -> Positive Int -> Property @@ -188,3 +204,166 @@ instance Arbitrary PushWithUserClients where arbitraryClientWithId :: ClientId -> Gen Client arbitraryClientWithId cid = (\c -> c {clientId = cid} :: Client) <$> arbitrary + +-- | A throwaway 'Payload' for the explicit fixtures (the property test uses +-- 'genPayload'; here we just need a stable value to compare queues by). +fixturePayload :: Payload +fixturePayload = KeyMap.singleton "val" (Aeson.toJSON (42 :: Int)) :| [] + +-- | Deterministic generation helper: run a 'Gen' with a fixed seed so the +-- fixtures are reproducible across test runs. +generateWith :: Int -> Gen a -> a +generateWith seed g = unGen g (mkQCGen seed) 0 + +-- | Build a one-user, one-client 'MockEnv' with explicit reachability flags +-- and an optional list of web push subscriptions for that client. The +-- client is non-consumable (so it routes through 'pushAllLegacy', the only +-- pipeline that calls 'pushWebWithBudget'). +singleUserEnv :: + UserId -> + ClientId -> + -- | websocket-reachable + Bool -> + -- | native-reachable (has a native push address) + Bool -> + -- | web push subscriptions for this (user, client) + [WebPushAddress] -> + MockEnv +singleUserEnv uid cid isWsReachable isNativeReachable webSubs = + -- The client is generated via @arbitrary@ then pinned to @cid@ and + -- stripped of @clientCapabilities@. Empty capabilities means + -- @supportsConsumableNotifications@ is 'False', so the client is + -- guaranteed non-consumable: 'splitPush' will route it to the legacy + -- pipeline ('pushAllLegacy'), which is the only pipeline that calls + -- 'pushWebWithBudget'. Without this hardening, a future change to the + -- 'Arbitrary Client' instance could silently produce a consumable + -- client under the fixed seed, breaking all 5 fixtures with a confusing + -- "empty queue" failure. + let client = + generateWith 1 arbitrary + & \c -> c {clientId = cid, clientCapabilities = mempty} :: Client + nativeAddr = generateWith 2 (genProtoAddress uid cid) + clientInfo = + MockGundeck.ClientInfo + { _ciClient = client, + _ciNativeAddress = if isNativeReachable then Just (nativeAddr, True) else Nothing, + _ciWSReachable = isWsReachable + } + in MockEnv + { _meClientInfos = Map.singleton uid (Map.singleton cid clientInfo), + _meWebSubscriptions = if null webSubs then mempty else Map.singleton uid webSubs + } + +-- | Build a 'WebPushAddress' for the given @(user, client)@ deterministically. +-- The @conn@ field is 'fakeConnId' of the client (matching what production +-- filtering by @pushConnections@ / @pushOriginConnection@ expects). +mkWebSub :: UserId -> ClientId -> WebPushAddress +mkWebSub uid cid = generateWith 3 (genWebPushAddress uid cid) + +-- | A non-transient, non-'RouteDirect' push from a non-origin sender to one +-- recipient's client. The workhorse fixture for the explicit cases. +mkPushTo :: UserId -> ClientId -> Bool -> Push +mkPushTo uid cid transient = + newPush + Nothing + (Set.singleton (Recipient uid RouteAny (RecipientClientsSome (cid :| [])))) + fixturePayload + & pushTransient .~ transient + +-- | Run a push through the production 'pushAll' pipeline and return the +-- resulting mock state. This exercises 'pushAllLegacy' -> 'pushWebWithBudget' +-- -> 'webTargets' -> 'mpaWebTargets' -> 'mpaPushWeb'. +runProduction :: MockEnv -> Push -> MockState +runProduction env psh = snd (runMockGundeck env (pushAll [psh])) + +-- | Assert the web-push queue contains exactly the given @(user, client)@ +-- keys with the given delivery counts. +assertWebPushQueue :: MockState -> [((UserId, ClientId), Int)] -> Assertion +assertWebPushQueue st expected = + let actualCounts = [(k, MSet.size v) | (k, v) <- Map.toList (st ^. msWebPushQueue)] + in actualCounts @?= expected + +-- | Assert the native-push queue contains exactly the given @(user, client)@ +-- keys with the given delivery counts. +assertNativeQueue :: MockState -> [((UserId, ClientId), Int)] -> Assertion +assertNativeQueue st expected = + let actualCounts = [(k, MSet.size v) | (k, v) <- Map.toList (st ^. msNativeQueue)] + in actualCounts @?= expected + +-- | Fixed UUIDs so the assertions have readable output on failure. +testUid :: Int -> UserId +testUid 1 = Id (read "00000000-0000-0000-0000-000000000001") +testUid 2 = Id (read "00000000-0000-0000-0000-000000000002") +testUid 3 = Id (read "00000000-0000-0000-0000-000000000003") +testUid 4 = Id (read "00000000-0000-0000-0000-000000000004") +testUid 5 = Id (read "00000000-0000-0000-0000-000000000005") +testUid _ = error "testUid: only 1-5 defined" + +webOnlyUserGetsWebPush :: Assertion +webOnlyUserGetsWebPush = do + let uid = testUid 1 + cid = ClientId 1 + env = singleUserEnv uid cid False False [mkWebSub uid cid] + psh = mkPushTo uid cid False + st = runProduction env psh + -- Native queue empty (no native address); web push queue has one delivery. + assertNativeQueue st [] + assertWebPushQueue st [((uid, cid), 1)] + -- Cross-check: production and mock agree on the whole state. + let mockSt = snd (runMockGundeck env (mockPushAll [psh])) + st @?= mockSt + +bothSubscribedOfflineGetsBoth :: Assertion +bothSubscribedOfflineGetsBoth = do + let uid = testUid 2 + cid = ClientId 2 + -- nativeReachable=True (has native addr), wsReachable=False (offline): + -- client gets BOTH native and web push. + env = singleUserEnv uid cid False True [mkWebSub uid cid] + psh = mkPushTo uid cid False + st = runProduction env psh + assertNativeQueue st [((uid, cid), 1)] + assertWebPushQueue st [((uid, cid), 1)] + let mockSt = snd (runMockGundeck env (mockPushAll [psh])) + st @?= mockSt + +wsServedSkipsWebPush :: Assertion +wsServedSkipsWebPush = do + let uid = testUid 3 + cid = ClientId 3 + -- wsReachable=True: WS delivers the notification; the client is in + -- 'dontPush' (via 'alreadySentClients') and web push must not fire. + env = singleUserEnv uid cid True False [mkWebSub uid cid] + psh = mkPushTo uid cid False + st = runProduction env psh + assertWebPushQueue st [] + let mockSt = snd (runMockGundeck env (mockPushAll [psh])) + st @?= mockSt + +originClientSkipsWebPush :: Assertion +originClientSkipsWebPush = do + let uid = testUid 4 + cid = ClientId 4 + env = singleUserEnv uid cid False False [mkWebSub uid cid] + -- Mark this user as the originator of the push. + psh = + mkPushTo uid cid False + & pushOrigin ?~ uid + & pushOriginConnection ?~ fakeConnId cid + st = runProduction env psh + -- Origin connection is filtered out by 'webTargets' (matches native). + assertWebPushQueue st [] + let mockSt = snd (runMockGundeck env (mockPushAll [psh])) + st @?= mockSt + +transientPushSkipsWebPush :: Assertion +transientPushSkipsWebPush = do + let uid = testUid 5 + cid = ClientId 5 + env = singleUserEnv uid cid False False [mkWebSub uid cid] + psh = mkPushTo uid cid True -- transient + st = runProduction env psh + -- Transient pushes skip web push (matches native semantics). + assertWebPushQueue st [] + let mockSt = snd (runMockGundeck env (mockPushAll [psh])) + st @?= mockSt diff --git a/services/gundeck/test/unit/Vapid.hs b/services/gundeck/test/unit/Vapid.hs new file mode 100644 index 00000000000..99c6b498869 --- /dev/null +++ b/services/gundeck/test/unit/Vapid.hs @@ -0,0 +1,146 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Vapid where + +import Control.Lens ((^.)) +import Data.Aeson qualified as Aeson +import Data.ByteString.Lazy.Char8 qualified as LBS8 +import Data.Text qualified as Text +import Gundeck.Env +import Imports +import Test.QuickCheck ((===)) +import Test.QuickCheck.Instances () +import Test.Tasty +import Test.Tasty.HUnit +import Test.Tasty.QuickCheck (testProperty) +import Wire.API.Push.V2.WebSubscription (VapidPublicKeyResponse (..)) + +tests :: TestTree +tests = + testGroup + "Vapid" + [ testGroup "parseVapidKeyPair" parseVapidKeyPairTests, + testGroup "validateVapidSubject" validateVapidSubjectTests, + testGroup "mkVapidKeyPair" mkVapidKeyPairTests, + testGroup "VapidPublicKeyResponse" vapidPublicKeyResponseTests + ] + +-------------------------------------------------------------------------------- +-- Known-answer test vector: RFC 8291 Appendix A application-server keypair. +-- Reusing it here verifies the entire chain (base64url decode -> scalar +-- range validation -> Q = d*G derivation -> uncompressed point encode) against +-- published, independently-generated values. +-------------------------------------------------------------------------------- + +-- | The application-server private key from RFC 8291 §5 (base64url, 32 bytes). +rfc8291AsPrivateKey :: Text +rfc8291AsPrivateKey = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw" + +-- | The corresponding public key from RFC 8291 §5 (base64url, 65-byte +-- uncompressed point: 0x04 || X || Y). Parsing the private key above MUST +-- derive exactly this value. +rfc8291AsPublicKey :: Text +rfc8291AsPublicKey = "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8" + +parseVapidKeyPairTests :: [TestTree] +parseVapidKeyPairTests = + [ testCase "RFC 8291 Appendix A: derives the expected public key" $ do + case parseVapidKeyPair rfc8291AsPrivateKey of + Left e -> + assertFailure ("expected valid keypair, got: " <> e) + Right kp -> + (kp ^. vkpPublicB64) @?= rfc8291AsPublicKey, + testCase "rejects non-base64url input" $ + assertBool "expected Left" $ + isLeft (parseVapidKeyPair "not valid base64url!!!"), + testCase "rejects wrong byte length (16 bytes)" $ + assertBool "expected Left" $ + isLeft (parseVapidKeyPair "0123456789abcdef"), + testCase "rejects the zero scalar (out of range)" $ + -- 32 zero bytes, base64url-encoded (no padding). + assertBool "expected Left" $ + isLeft (parseVapidKeyPair "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + testCase "rejects the all-0xFF scalar (>= n, out of range)" $ + assertBool "expected Left" $ + isLeft (parseVapidKeyPair "________________________________________8") + ] + +validateVapidSubjectTests :: [TestTree] +validateVapidSubjectTests = + [ testCase "accepts mailto: subject" $ + validateVapidSubject "mailto:ops@wire.com" @?= Right (), + testCase "accepts https: subject" $ + validateVapidSubject "https://wire.com" @?= Right (), + testCase "rejects http: subject (must be https)" $ + assertBool "expected Left" $ + isLeft (validateVapidSubject "http://wire.com"), + testCase "rejects ftp: subject" $ + assertBool "expected Left" $ + isLeft (validateVapidSubject "ftp://example.com"), + testCase "rejects a bare string" $ + assertBool "expected Left" $ + isLeft (validateVapidSubject "not a url") + ] + +mkVapidKeyPairTests :: [TestTree] +mkVapidKeyPairTests = + [ testCase "succeeds with valid subject and key" $ do + case mkVapidKeyPair "mailto:ops@wire.com" rfc8291AsPrivateKey of + Left e -> assertFailure ("expected valid keypair, got: " <> e) + Right kp -> (kp ^. vkpPublicB64) @?= rfc8291AsPublicKey, + testCase "fails when subject is invalid even if key is valid" $ + assertBool "expected Left" $ + isLeft (mkVapidKeyPair "not-a-url" rfc8291AsPrivateKey), + testCase "fails when key is invalid even if subject is valid" $ + assertBool "expected Left" $ + isLeft (mkVapidKeyPair "mailto:ops@wire.com" "too-short") + ] + +-------------------------------------------------------------------------------- +-- VapidPublicKeyResponse: wire format for GET /push/web/vapid-public-key. + +vapidPublicKeyResponseTests :: [TestTree] +vapidPublicKeyResponseTests = + [ testCase "encodes as {\"key\": ...} with the b64url value" $ do + let resp = VapidPublicKeyResponse rfc8291AsPublicKey + encoded = Aeson.encode resp + expected = + LBS8.pack $ + "{\"key\":\"" <> Text.unpack rfc8291AsPublicKey <> "\"}" + encoded @?= expected, + testCase "decodes back from {\"key\": ...}" $ do + let blob = Aeson.encode (VapidPublicKeyResponse rfc8291AsPublicKey) + Aeson.decode blob @?= Just (VapidPublicKeyResponse rfc8291AsPublicKey), + testCase "end-to-end: derived public key round-trips through the response" $ + case parseVapidKeyPair rfc8291AsPrivateKey of + Left e -> + assertFailure + ("parseVapidKeyPair failed on RFC 8291 fixture: " <> e) + Right kp -> + -- This is exactly what 'Gundeck.Push.getVapidPublicKey' does once it + -- has a 'Just' keypair from 'view vapid'. + vapidPublicKeyResponseKey (VapidPublicKeyResponse (kp ^. vkpPublicB64)) + @?= rfc8291AsPublicKey, + -- Property test exercising both the 'GenericUniform Arbitrary' instance + -- and the deriving-via-'Schema' 'ToJSON'/'FromJSON' chain together: a + -- field rename, a missing key, or a broken Arbitrary generator would all + -- surface here. + testProperty "arbitrary value roundtrips through JSON" $ + \(resp :: VapidPublicKeyResponse) -> + Aeson.decode (Aeson.encode resp) === Just resp + ] diff --git a/services/gundeck/test/unit/WebPushCrypto.hs b/services/gundeck/test/unit/WebPushCrypto.hs new file mode 100644 index 00000000000..6bd86b15a4c --- /dev/null +++ b/services/gundeck/test/unit/WebPushCrypto.hs @@ -0,0 +1,377 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Tests for "Gundeck.Push.Web.Crypto" (RFC 8291 encryption + RFC 8292 VAPID). +-- +-- The centrepiece is the RFC 8291 §5 known-answer test: with the published +-- application-server/user-agent keypair, salt, auth secret and plaintext, the +-- implementation MUST reproduce the exact 144-byte aes128gcm body from the RFC. +module WebPushCrypto + ( tests, + ) +where + +import Control.Lens ((^.)) +import Crypto.Cipher.AES (AES128) +import Crypto.Cipher.Types (AEADMode (AEAD_GCM), AuthTag, aeadDecrypt, aeadFinalize, aeadInit, cipherInit) +import Crypto.ECC (Curve_P256R1) +import Crypto.Error (CryptoFailable (..)) +import Crypto.Hash (SHA256 (..)) +import Crypto.KDF.HKDF qualified as HKDF +import Crypto.Number.Serialize (i2ospOf_, os2ip) +import Crypto.PubKey.ECC.DH qualified as ECC.DH +import Crypto.PubKey.ECC.Prim qualified as ECC.Prim +import Crypto.PubKey.ECC.Types qualified as ECC.Types +import Crypto.PubKey.ECDSA qualified as ECDSA +import Crypto.Random (getRandomBytes) +import Data.Aeson (Value (..)) +import Data.Aeson qualified as Aeson +import Data.Aeson.Key qualified as Key +import Data.Aeson.KeyMap qualified as KeyMap +import Data.ByteArray (convert) +import Data.ByteString qualified as BS +import Data.ByteString.Base64.URL qualified as B64U +import Data.ByteString.Lazy qualified as LBS +import Data.Proxy (Proxy (..)) +import Data.Text.Encoding (encodeUtf8) +import Gundeck.Env (parseVapidKeyPair) +import Gundeck.Env qualified as Env +import Gundeck.Push.Web.Crypto + ( AsEphemeralKey (..), + CryptoError (..), + EncryptedBody (..), + Salt (..), + VapidHeaders (..), + encryptPayload, + encryptPayloadWith, + signVapid, + ) +import Imports +import Test.Tasty +import Test.Tasty.HUnit +import Wire.API.Push.V2.WebSubscription + ( P256dhKey (..), + WebPushKeys (..), + mkAuthSecret, + mkP256dhKey, + ) + +-------------------------------------------------------------------------------- +-- P-256 curve (value-level), used by the reference receiver in this test. + +p256Curve :: ECC.Types.Curve +p256Curve = ECC.Types.getCurveByName ECC.Types.SEC_p256r1 + +tests :: TestTree +tests = + testGroup + "WebPushCrypto" + [ testGroup "RFC 8291" rfc8291Tests, + testGroup "RFC 8292 VAPID" vapidTests, + testGroup "p256dh validation" p256dhValidationTests + ] + +-------------------------------------------------------------------------------- +-- RFC 8291 §5 / Appendix A test vectors (public, non-secret). + +rfc8291Plaintext :: ByteString +rfc8291Plaintext = "When I grow up, I want to be a watermelon" + +rfc8291UaPublicB64 :: ByteString +rfc8291UaPublicB64 = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4" + +rfc8291UaPrivateB64 :: ByteString +rfc8291UaPrivateB64 = "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94" + +rfc8291AsPrivateB64 :: ByteString +rfc8291AsPrivateB64 = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw" + +rfc8291AsPublicB64 :: ByteString +rfc8291AsPublicB64 = "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8" + +rfc8291AuthSecretB64 :: ByteString +rfc8291AuthSecretB64 = "BTBZMqHH6r4Tts7J_aSIgg" + +rfc8291SaltB64 :: ByteString +rfc8291SaltB64 = "DGv6ra1nlYgDCS1FRnbzlw" + +-- The complete 144-byte aes128gcm body (86 header + 58 ciphertext+tag) from +-- RFC 8291 §5, base64url without padding. +rfc8291ExpectedBodyB64 :: ByteString +rfc8291ExpectedBodyB64 = + "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgD\ + \ll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt\ + \2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN" + +rfc8291Keys :: WebPushKeys +rfc8291Keys = + let p256dh = unsafeRight (mkP256dhKey (b64 rfc8291UaPublicB64)) + auth = unsafeRight (mkAuthSecret (b64 rfc8291AuthSecretB64)) + in WebPushKeys p256dh auth + +rfc8291Tests :: [TestTree] +rfc8291Tests = + [ testCase "§5 known-answer: reproduces the exact published ciphertext" $ do + let asKey = AsEphemeralKey (b64 rfc8291AsPrivateB64) (b64 rfc8291AsPublicB64) + salt = Salt (b64 rfc8291SaltB64) + expected = b64 rfc8291ExpectedBodyB64 + case encryptPayloadWith rfc8291Keys asKey salt rfc8291Plaintext of + Left err -> assertFailure ("expected encryption to succeed, got: " <> show err) + Right (EncryptedBody body) -> body @?= expected, + testCase "§5 roundtrip: reference receiver recovers the plaintext" $ do + let asKey = AsEphemeralKey (b64 rfc8291AsPrivateB64) (b64 rfc8291AsPublicB64) + salt = Salt (b64 rfc8291SaltB64) + uaPriv = os2ip (b64 rfc8291UaPrivateB64) + authSecret = b64 rfc8291AuthSecretB64 + case encryptPayloadWith rfc8291Keys asKey salt rfc8291Plaintext of + Left err -> assertFailure ("encrypt failed: " <> show err) + Right (EncryptedBody body) -> + case referenceDecrypt authSecret uaPriv body of + Left e -> assertFailure ("decrypt failed: " <> e) + Right recovered -> recovered @?= rfc8291Plaintext, + testCase "fresh-key roundtrip: encrypt then decrypt with random keys" $ do + uaPriv <- ECC.DH.generatePrivate p256Curve + let uaPub = encodeUncompressed (ECC.Prim.pointBaseMul p256Curve uaPriv) + Right p256dh <- pure (mkP256dhKey uaPub) + authSecret <- getRandomBytes 16 + Right auth <- pure (mkAuthSecret authSecret) + ephemScalar <- ECC.DH.generatePrivate p256Curve + let asPub = encodeUncompressed (ECC.Prim.pointBaseMul p256Curve ephemScalar) + asKey = AsEphemeralKey (i2ospOf_ 32 ephemScalar) asPub + saltBs <- getRandomBytes 16 + let salt = Salt saltBs + payload = "hello, web push" + case encryptPayloadWith (WebPushKeys p256dh auth) asKey salt payload of + Left err -> assertFailure ("encrypt failed: " <> show err) + Right (EncryptedBody body) -> + case referenceDecrypt authSecret uaPriv body of + Left e -> assertFailure ("decrypt failed: " <> e) + Right recovered -> recovered @?= payload, + -- I1: exercises the production IO entry point (encryptPayload) and + -- mkAsEphemeralKey (pointBaseMul + encodeUncompressedPoint), which the + -- injectable-core tests above bypass. A regression in the ephemeral-key + -- derivation would silently break browser decryption. + testCase "encryptPayload (IO wrapper) roundtrips" $ do + uaPriv <- ECC.DH.generatePrivate p256Curve + let uaPub = encodeUncompressed (ECC.Prim.pointBaseMul p256Curve uaPriv) + Right p256dh <- pure (mkP256dhKey uaPub) + authSecret <- getRandomBytes 16 + Right auth <- pure (mkAuthSecret authSecret) + let payload = "hello via IO wrapper" + res <- encryptPayload (WebPushKeys p256dh auth) payload + case res of + Left err -> assertFailure ("encryptPayload failed: " <> show err) + Right (EncryptedBody body) -> + case referenceDecrypt authSecret uaPriv body of + Left e -> assertFailure ("decrypt failed: " <> e) + Right recovered -> recovered @?= payload + ] + +-------------------------------------------------------------------------------- +-- Reference receiver: an independent re-implementation of the RFC 8291 decrypt +-- side, as a browser push service would do. Used only for roundtrip tests. + +referenceDecrypt :: ByteString -> Integer -> ByteString -> Either String ByteString +referenceDecrypt authSecret uaPriv body + | BS.length body < 86 = Left "body shorter than 86-byte header" + | otherwise = + let (header, ctTag) = BS.splitAt 86 body + salt = BS.take 16 header + -- header: salt(16) || rs(4) || idlen(1) || as_public(65) + asPub = BS.drop 21 header + asPoint = decodePoint asPub + uaPub = encodeUncompressed (ECC.Prim.pointBaseMul p256Curve uaPriv) + shared = ECC.DH.getShared p256Curve uaPriv asPoint + (ct, tag) = BS.splitAt (BS.length ctTag - 16) ctTag + prkKey = HKDF.extract @SHA256 authSecret shared + keyInfo = "WebPush: info" <> BS.singleton 0 <> uaPub <> asPub + ikm :: ByteString + ikm = HKDF.expand @SHA256 prkKey keyInfo 32 + prk = HKDF.extract @SHA256 salt ikm + cek :: ByteString + cek = HKDF.expand @SHA256 prk cekInfo 16 + nonce :: ByteString + nonce = HKDF.expand @SHA256 prk nonceInfo 12 + in case aesGcmDecrypt cek nonce ct tag of + Just plain -> Right (BS.init plain) + Nothing -> Left "AES-GCM authentication tag mismatch" + where + decodePoint bs = + let x = os2ip (BS.take 32 (BS.drop 1 bs)) + y = os2ip (BS.drop 33 bs) + in ECC.Types.Point x y + +cekInfo :: ByteString +cekInfo = "Content-Encoding: aes128gcm" <> BS.singleton 0 + +nonceInfo :: ByteString +nonceInfo = "Content-Encoding: nonce" <> BS.singleton 0 + +aesGcmDecrypt :: ByteString -> ByteString -> ByteString -> ByteString -> Maybe ByteString +aesGcmDecrypt cek nonce ct tagBs = case (cipherInit cek :: CryptoFailable AES128) of + CryptoFailed _ -> Nothing + CryptoPassed cipher -> case aeadInit AEAD_GCM cipher nonce of + CryptoFailed _ -> Nothing + CryptoPassed aead -> + let (pt, aead') = aeadDecrypt aead ct + computed :: AuthTag + computed = aeadFinalize aead' 16 + in if (convert computed :: ByteString) == tagBs then Just pt else Nothing + +-------------------------------------------------------------------------------- +-- RFC 8292 VAPID tests + +vapidTests :: [TestTree] +vapidTests = + [ testCase "signVapid: JWT signature verifies against the derived public key" $ do + let kp = unsafeRight (parseVapidKeyPair rfc8291AsPrivateKeyText) + subject = "mailto:ops@wire.com" + audience = "https://fcm.googleapis.com" + VapidHeaders {vhAuthorization} <- signVapid kp subject audience + let authBs = encodeUtf8 vhAuthorization + assertBool "authorization has 'vapid t=' prefix" ("vapid t=" `BS.isPrefixOf` authBs) + let jwt = extractJwt vhAuthorization + (headerB64, payloadB64, sigB64) = splitDot3 jwt + signingInput = headerB64 <> "." <> payloadB64 + sigBs <- eitherFail (B64U.decodeUnpadded sigB64) + let (rBs, sBs) = BS.splitAt 32 sigBs + sig <- cryptoFail (ECDSA.signatureFromIntegers (Proxy @Curve_P256R1) (os2ip rBs, os2ip sBs)) + assertBool "signature verifies against vkpPublic" $ + ECDSA.verify (Proxy @Curve_P256R1) SHA256 (kp ^. Env.vkpPublic) sig signingInput, + -- I2: assert the JWT carries the RFC 8292 §3-mandated claims (aud, sub, + -- exp) with correct values, and the ES256 header. A regression dropping + -- @aud@ would pass signature verification but be rejected by push services. + testCase "signVapid: JWT carries aud/sub/exp claims and ES256 header" $ do + let kp = unsafeRight (parseVapidKeyPair rfc8291AsPrivateKeyText) + subject = "mailto:ops@wire.com" + audience = "https://fcm.googleapis.com" + VapidHeaders {vhAuthorization} <- signVapid kp subject audience + let jwt = extractJwt vhAuthorization + (headerB64, payloadB64, _sigB64) = splitDot3 jwt + -- Header: {"alg":"ES256","typ":"JWT"} + hdrBs <- eitherFail (B64U.decodeUnpadded headerB64) + hdr <- decodeObject hdrBs + KeyMap.lookup (Key.fromText "alg") hdr @?= Just (String "ES256") + KeyMap.lookup (Key.fromText "typ") hdr @?= Just (String "JWT") + -- Payload: aud, sub, exp + payBs <- eitherFail (B64U.decodeUnpadded payloadB64) + claims <- decodeObject payBs + KeyMap.lookup (Key.fromText "aud") claims @?= Just (String audience) + KeyMap.lookup (Key.fromText "sub") claims @?= Just (String subject) + assertBool "exp claim present" (isJust (KeyMap.lookup (Key.fromText "exp") claims)), + testCase "signVapid: Crypto-Key header carries the server public key" $ do + let kp = unsafeRight (parseVapidKeyPair rfc8291AsPrivateKeyText) + VapidHeaders {vhCryptoKey} <- signVapid kp "mailto:ops@wire.com" "https://example.com" + encodeUtf8 vhCryptoKey @?= ("p256ecdsa=" <> encodeUtf8 (kp ^. Env.vkpPublicB64)) + ] + +rfc8291AsPrivateKeyText :: Text +rfc8291AsPrivateKeyText = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw" + +extractJwt :: Text -> ByteString +extractJwt auth = + let rest = BS.drop (BS.length "vapid t=") (encodeUtf8 auth) + in -- rest = ",k="; take up to the comma. + BS.takeWhile (/= 0x2c) rest + +splitDot3 :: ByteString -> (ByteString, ByteString, ByteString) +splitDot3 s = + let (a, r1) = bsBreak (== 0x2e) s + (b, r2) = bsBreak (== 0x2e) r1 + in (a, b, r2) + where + bsBreak p xs = (BS.takeWhile (not . p) xs, BS.drop 1 (BS.dropWhile (not . p) xs)) + +-------------------------------------------------------------------------------- +-- p256dh validation tests + +p256dhValidationTests :: [TestTree] +p256dhValidationTests = + [ testCase "rejects a 64-byte p256dh (wrong length)" $ do + let asKey = AsEphemeralKey (b64 rfc8291AsPrivateB64) (b64 rfc8291AsPublicB64) + salt = Salt (b64 rfc8291SaltB64) + -- Bypass mkP256dhKey (which enforces 65 bytes) to feed the raw guard. + tooShort = P256dhKey (BS.init (b64 rfc8291UaPublicB64)) + auth = unsafeRight (mkAuthSecret (b64 rfc8291AuthSecretB64)) + case encryptPayloadWith (WebPushKeys tooShort auth) asKey salt rfc8291Plaintext of + Left (CryptoInvalidP256dhLength 64) -> pure () + other -> assertFailure ("expected CryptoInvalidP256dhLength 64, got: " <> show other), + testCase "rejects a 65-byte p256dh with wrong format byte (not 0x04)" $ do + let asKey = AsEphemeralKey (b64 rfc8291AsPrivateB64) (b64 rfc8291AsPublicB64) + salt = Salt (b64 rfc8291SaltB64) + -- 65 bytes, but first byte 0x02 (compressed form) instead of 0x04. + badFormat = BS.singleton 0x02 <> BS.drop 1 (b64 rfc8291UaPublicB64) + auth = unsafeRight (mkAuthSecret (b64 rfc8291AuthSecretB64)) + p256dh = P256dhKey badFormat + case encryptPayloadWith (WebPushKeys p256dh auth) asKey salt rfc8291Plaintext of + Left CryptoInvalidP256dhFormat -> pure () + other -> assertFailure ("expected CryptoInvalidP256dhFormat, got: " <> show other), + testCase "rejects a point not on the P-256 curve" $ do + let asKey = AsEphemeralKey (b64 rfc8291AsPrivateB64) (b64 rfc8291AsPublicB64) + salt = Salt (b64 rfc8291SaltB64) + -- (0x04 || X=1 || Y=1): valid 65-byte length, not on the curve. + offCurve = BS.singleton 0x04 <> pad32 1 <> pad32 1 + auth = unsafeRight (mkAuthSecret (b64 rfc8291AuthSecretB64)) + p256dh = P256dhKey offCurve + case encryptPayloadWith (WebPushKeys p256dh auth) asKey salt rfc8291Plaintext of + Left CryptoPointNotOnCurve -> pure () + Left e -> assertFailure ("expected CryptoPointNotOnCurve, got: " <> show e) + Right _ -> assertFailure "expected CryptoPointNotOnCurve, but encryption succeeded", + testCase "rejects plaintext larger than 3993 bytes (RFC 8291 §4)" $ do + let asKey = AsEphemeralKey (b64 rfc8291AsPrivateB64) (b64 rfc8291AsPublicB64) + salt = Salt (b64 rfc8291SaltB64) + oversized = BS.replicate 3994 0x41 + case encryptPayloadWith rfc8291Keys asKey salt oversized of + Left (CryptoPlaintextTooLarge 3994) -> pure () + other -> assertFailure ("expected CryptoPlaintextTooLarge 3994, got: " <> show other) + ] + +-------------------------------------------------------------------------------- +-- Small helpers + +pad32 :: Integer -> ByteString +pad32 = i2ospOf_ 32 + +encodeUncompressed :: ECC.Types.Point -> ByteString +encodeUncompressed = \case + ECC.Types.Point x y -> BS.singleton 0x04 <> i2ospOf_ 32 x <> i2ospOf_ 32 y + ECC.Types.PointO -> BS.empty + +b64 :: ByteString -> ByteString +b64 t = case B64U.decodeUnpadded t of + Right bs -> bs + Left e -> error ("bad base64url in test vector: " <> e) + +-- | Unwrap a known-good 'Right' from a test vector; crashes on 'Left' (which +-- would indicate a malformed test vector, not a code failure). +unsafeRight :: Either String a -> a +unsafeRight (Right a) = a +unsafeRight (Left e) = error ("test vector invariant violated: " <> e) + +eitherFail :: Either String a -> IO a +eitherFail = either (assertFailure . ("decode failed: " <>)) pure + +-- | Decode a JSON bytestring that is expected to be a flat object; fails the +-- test case if decoding yields anything other than a JSON object. +decodeObject :: ByteString -> IO (KeyMap.KeyMap Value) +decodeObject bs = case Aeson.decode (LBS.fromStrict bs) of + Just (Object o) -> pure o + _ -> assertFailure "expected JSON object" + +cryptoFail :: CryptoFailable a -> IO a +cryptoFail (CryptoFailed e) = assertFailure ("crypto-failable failed: " <> show e) +cryptoFail (CryptoPassed a) = pure a diff --git a/services/gundeck/test/unit/WebPushDispatch.hs b/services/gundeck/test/unit/WebPushDispatch.hs new file mode 100644 index 00000000000..574757b2c2f --- /dev/null +++ b/services/gundeck/test/unit/WebPushDispatch.hs @@ -0,0 +1,133 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Unit tests for the pure dispatch helpers in "Gundeck.Push.Web". +-- +-- These cover the response classification, RFC 8030 header value mapping, and +-- endpoint origin extraction — the pure decision logic that 'push1' builds on. +-- The Gundeck-monadic dispatch path itself is covered by integration tests. +module WebPushDispatch + ( tests, + ) +where + +import Gundeck.Push.Web +import Imports +import Network.HTTP.Types.Status + ( status200, + status201, + status202, + status400, + status401, + status403, + status404, + status410, + status413, + status429, + status500, + status502, + status503, + ) +import Test.Tasty +import Test.Tasty.HUnit +import Wire.API.Push.V2 (Priority (HighPriority, LowPriority)) +import Wire.API.Push.V2.WebSubscription (EndpointUrl, mkEndpointUrl) + +tests :: TestTree +tests = + testGroup + "WebPushDispatch" + [ testGroup + "classifyResponse" + [ testCase "201 Created → ResponseSuccess" $ + classifyResponse status201 @?= ResponseSuccess, + testCase "202 Accepted → ResponseSuccess" $ + classifyResponse status202 @?= ResponseSuccess, + testCase "404 Not Found → ResponseGone" $ + classifyResponse status404 @?= ResponseGone, + testCase "410 Gone → ResponseGone" $ + classifyResponse status410 @?= ResponseGone, + testCase "413 Payload Too Large → ResponseTooLarge" $ + classifyResponse status413 @?= ResponseTooLarge, + testCase "429 Too Many Requests → ResponseRetryable" $ + classifyResponse status429 @?= ResponseRetryable, + testCase "500 Internal Server Error → ResponseRetryable" $ + classifyResponse status500 @?= ResponseRetryable, + testCase "502 Bad Gateway → ResponseRetryable" $ + classifyResponse status502 @?= ResponseRetryable, + testCase "503 Service Unavailable → ResponseRetryable" $ + classifyResponse status503 @?= ResponseRetryable, + testCase "200 OK (unexpected) → ResponseError" $ + -- 200 is not in RFC 8030's success set (201/202); treat as + -- a permanent client error rather than silently accepting. + classifyResponse status200 @?= ResponseError, + testCase "400 Bad Request → ResponseError" $ + classifyResponse status400 @?= ResponseError, + testCase "401 Unauthorized → ResponseError" $ + classifyResponse status401 @?= ResponseError, + testCase "403 Forbidden → ResponseError" $ + classifyResponse status403 @?= ResponseError + ], + testGroup + "urgencyFrom" + [ testCase "LowPriority → 'low'" $ + urgencyFrom LowPriority @?= "low", + testCase "HighPriority → 'high'" $ + urgencyFrom HighPriority @?= "high" + ], + testGroup + "ttlFrom" + [ testCase "Nothing → 0 (transient)" $ + ttlFrom Nothing @?= 0, + testCase "Just 0 → 0" $ + ttlFrom (Just 0) @?= 0, + testCase "Just 3600 → 3600" $ + ttlFrom (Just 3600) @?= 3600, + testCase "Just maxBound → maxBound" $ + ttlFrom (Just maxBound) @?= maxBound + ], + testGroup + "endpointOrigin" + [ testCase "strips path, keeps origin" $ + endpointOrigin (ep "https://fcm.googleapis.com/fcm/send/abc") + @?= "https://fcm.googleapis.com", + testCase "preserves port" $ + endpointOrigin (ep "https://push.example.com:8443/webpush/abc") + @?= "https://push.example.com:8443", + testCase "handles no path" $ + endpointOrigin (ep "https://push.example.com") + @?= "https://push.example.com", + testCase "strips query string" $ + endpointOrigin (ep "https://push.example.com/wp?foo=bar") + @?= "https://push.example.com", + testCase "strips fragment" $ + endpointOrigin (ep "https://push.example.com/wp#/frag") + @?= "https://push.example.com", + testCase "strips query string when no path" $ + endpointOrigin (ep "https://push.example.com?foo=bar") + @?= "https://push.example.com" + ] + ] + +-- | Build an 'EndpointUrl', failing loudly if the URL is rejected. All test +-- URLs here are HTTPS (the production smart constructor enforces it), so a +-- 'Left' indicates a broken fixture. +ep :: Text -> EndpointUrl +ep raw = + case mkEndpointUrl raw of + Right e -> e + Left e -> error ("WebPushDispatch.ep: invalid fixture URL " <> show raw <> ": " <> e) diff --git a/services/gundeck/test/unit/WebPushHandlers.hs b/services/gundeck/test/unit/WebPushHandlers.hs new file mode 100644 index 00000000000..99b27636957 --- /dev/null +++ b/services/gundeck/test/unit/WebPushHandlers.hs @@ -0,0 +1,139 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +-- +-- The handler-specific, security-critical logic that lives in 'Gundeck.Push' +-- is the SSRF validation around the attacker-controllable @endpoint@ URL +-- ('validateEndpointHost' / 'endpointHost'): gundeck will later POST to that +-- URL. These tests cover that logic directly. +-- +-- The store-level acceptance criteria (upsert on re-register, delete-by-row) +-- are exercised against the in-memory interpreter and Postgres in +-- @wire-subsystems@; the full 'Gundeck.Gundeck'-monad handlers sit on top of +-- 'Gundeck.Push.Web.Runner.runWebPush', which needs a live pool and is +-- therefore covered by integration rather than unit tests. +module WebPushHandlers where + +import Control.Lens ((^.)) +import Data.ByteString qualified as BS +import Data.Id +import Gundeck.Push +import Imports +import Test.Tasty +import Test.Tasty.HUnit +import Wire.API.Push.V2 +import Wire.API.Push.V2.WebSubscription (mkEndpointUrl) +import Wire.WebPushStore (WebPushAddress (..)) + +tests :: TestTree +tests = + testGroup + "WebPushHandlers" + [ testGroup + "validateEndpointHost" + [ testCase "empty allowlist accepts any endpoint" $ + isAccepted $ + validateEndpointHost [] (ep "https://fcm.googleapis.com/fcm/send/abc"), + testCase "exact host match is accepted" $ + isAccepted $ + validateEndpointHost ["fcm.googleapis.com"] (ep "https://fcm.googleapis.com/fcm/send/abc"), + testCase "subdomain of an allowed host is accepted" $ + isAccepted $ + validateEndpointHost ["googleapis.com"] (ep "https://fcm.googleapis.com/fcm/send/abc"), + testCase "host not on the allowlist is rejected" $ + isRejected $ + validateEndpointHost ["fcm.googleapis.com"] (ep "https://example.com/push/abc"), + testCase "SSRF: suffix entry must not match an unrelated host" $ + -- 'evil.com' must NOT match 'notevil.com'. A naive Text.isSuffixOf + -- over the URL would wrongly accept this. + isRejected $ + validateEndpointHost ["evil.com"] (ep "https://notevil.com/push"), + testCase "SSRF: allowed host as a prefix-of-host is rejected" $ + -- 'example.com' must NOT match 'example.com.attacker.tld'. A naive + -- suffix check over the host string (without a dot boundary) would + -- wrongly accept this. + isRejected $ + validateEndpointHost ["example.com"] (ep "https://example.com.attacker.tld/push"), + testCase "SSRF: deeper subdomain of an allowed host is accepted" $ + isAccepted $ + validateEndpointHost ["googleapis.com"] (ep "https://fcm-x.googleapis.com/x"), + testCase "SSRF: userinfo must not fool the host check" $ + -- @https://allowed.com@evil.com/@ has userinfo 'allowed.com' but + -- the real destination host is 'evil.com'. Since we extract the + -- authority host (not the userinfo), this must be rejected. + isRejected $ + validateEndpointHost ["allowed.com"] (ep "https://allowed.com@evil.com/push"), + testCase "host matching is case-insensitive (RFC 3986)" $ + isAccepted $ + validateEndpointHost ["FCM.GoogleAPIs.COM"] (ep "https://fcm.googleapis.com/x"), + testCase "multiple allowlist entries: any match suffices" $ + isAccepted $ + validateEndpointHost ["fcm.googleapis.com", "updates.push.services.mozilla.com"] (ep "https://updates.push.services.mozilla.com/wpush/v2/xyz") + ], + testGroup + "endpointHost" + [ testCase "extracts the host of a well-formed https URL" $ + endpointHost (ep "https://fcm.googleapis.com/fcm/send/abc") + @?= Just "fcm.googleapis.com", + testCase "handles a URL with userinfo and port" $ + endpointHost (ep "https://u:p@fcm.googleapis.com:443/x") + @?= Just "fcm.googleapis.com" + ], + testCase "addressToSubscription preserves fields, reports expiration=Nothing" $ do + let addr = + WebPushAddress + { wpaUser = read "00000000-0000-0001-0000-000000000000", + wpaConn = ConnId "conn", + wpaClient = ClientId 7, + wpaEndpoint = ep "https://fcm.googleapis.com/fcm/send/abc", + wpaKeys = keys + } + sub = addressToSubscription addr + sub ^. wpsEndpoint @?= addr.wpaEndpoint + sub ^. wpsKeys @?= addr.wpaKeys + sub ^. wpsClient @?= addr.wpaClient + sub ^. wpsExpirationTime @?= Nothing + ] + +-- | Assert that validation accepted the endpoint. 'AddWebPushError' has no +-- 'Eq' instance, so we pattern-match rather than using '@?='. +isAccepted :: Either AddWebPushError () -> Assertion +isAccepted (Right ()) = pure () +isAccepted (Left e) = assertFailure ("expected acceptance (Right ()), got " <> show e) + +-- | Assert that validation rejected the endpoint with 'AddWebPushErrorInvalid'. +isRejected :: Either AddWebPushError () -> Assertion +isRejected (Left AddWebPushErrorInvalid) = pure () +isRejected (Left e) = assertFailure ("expected AddWebPushErrorInvalid, got " <> show e) +isRejected (Right ()) = assertFailure "expected rejection (Left AddWebPushErrorInvalid), got Right ()" + +-- | Build an 'EndpointUrl', failing loudly if the URL is rejected. All test +-- URLs here must already be HTTPS (the production smart constructor enforces +-- HTTPS), so a 'Left' indicates a broken fixture rather than behaviour. +ep :: Text -> EndpointUrl +ep raw = + case mkEndpointUrl raw of + Right e -> e + Left e -> error ("WebPushHandlers.ep: invalid fixture URL " <> show raw <> ": " <> e) + +-- | A fixed, valid key pair (RFC 8291: 65-byte uncompressed P-256 public key +-- + 16-byte auth secret). Only the shape matters here, not the cryptographic +-- value, since 'addressToSubscription' only moves the fields around. +keys :: WebPushKeys +keys = + WebPushKeys + (P256dhKey (BS.replicate 65 0x04)) + (AuthSecret (BS.replicate 16 0x01)) diff --git a/services/gundeck/test/unit/WebPushRunner.hs b/services/gundeck/test/unit/WebPushRunner.hs new file mode 100644 index 00000000000..fd5f2bbffde --- /dev/null +++ b/services/gundeck/test/unit/WebPushRunner.hs @@ -0,0 +1,67 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Unit tests for 'Gundeck.Push.Web.Runner.runWebPush'. +-- +-- These exercise the Polysemy stack /assembly/ (the order of interpreters and +-- the 'Wire.Postgres.PGConstraints' plumbing) without touching a real database: +-- @'runWebPush' pool ('pure' x)@ never invokes any effect handler, so the pool +-- is acquired but never connected to. 'Hasql.Pool.acquire' is itself lazy — it +-- only opens connections on demand — so a bogus libpq settings 'Map' is +-- sufficient. +module WebPushRunner where + +import Data.Map qualified as Map +import Data.Misc (Duration (..)) +import Data.Time.Clock (secondsToDiffTime) +import Gundeck.Push.Web.Runner +import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended (PoolConfig (..), initPostgresPool) +import Imports +import Test.Tasty +import Test.Tasty.HUnit + +tests :: TestTree +tests = + testGroup + "WebPushRunner" + [ testCase "runWebPush pool (pure ()) returns Right ()" $ do + pool <- acquireDummyPool + result <- runWebPush pool (pure ()) + Hasql.release pool + result @?= Right () + ] + +-- | A pool whose connection settings are immaterial: the runner's trivial +-- program never reaches the database, and 'Hasql.Pool.acquire' does not open +-- connections eagerly. We still pick a tiny @size@ and short timeouts so the +-- pool allocates no background resources of note. +acquireDummyPool :: IO Hasql.Pool +acquireDummyPool = + initPostgresPool dummyPoolConfig dummyPgSettings Nothing + where + -- Minimal libpq params; never used to connect. + dummyPgSettings :: Map Text Text + dummyPgSettings = Map.fromList [("host", "localhost"), ("port", "5432")] + dummyPoolConfig :: PoolConfig + dummyPoolConfig = + PoolConfig + { size = 1, + acquisitionTimeout = Duration (secondsToDiffTime 1), + agingTimeout = Duration (secondsToDiffTime 1), + idlenessTimeout = Duration (secondsToDiffTime 1) + } diff --git a/services/gundeck/test/unit/WebPushSerialise.hs b/services/gundeck/test/unit/WebPushSerialise.hs new file mode 100644 index 00000000000..2119535aff4 --- /dev/null +++ b/services/gundeck/test/unit/WebPushSerialise.hs @@ -0,0 +1,86 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Unit tests for "Gundeck.Push.Web.Serialise". +module WebPushSerialise + ( tests, + ) +where + +import Data.Aeson (Value (..)) +import Data.Aeson qualified as Aeson +import Data.Aeson.Key qualified as Key +import Data.Aeson.KeyMap qualified as KeyMap +import Data.ByteString qualified as BS +import Data.ByteString.Lazy qualified as LBS +import Data.Id (UserId) +import Gundeck.Push.Native.Types (NativePush (..)) +import Gundeck.Push.Web.Crypto (maxPlaintextLength) +import Gundeck.Push.Web.Serialise +import Imports +import Test.Tasty +import Test.Tasty.HUnit +import Wire.API.Notification (mkNotificationId) +import Wire.API.Push.V2 (Priority (HighPriority)) + +tests :: TestTree +tests = + testGroup + "WebPushSerialise" + [ testCase "produces {type:'notice', data:{id}, user} shape" $ do + nid <- mkNotificationId + let np = NativePush nid HighPriority Nothing + uid = read "00000000-0000-0001-0000-000000000000" :: UserId + case serialise np uid of + Left err -> assertFailure ("serialise failed: " <> show err) + Right json -> do + v <- decodeObject json + KeyMap.lookup (Key.fromText "type") v @?= Just (String "notice") + case KeyMap.lookup (Key.fromText "data") v of + Just (Object dataObj) -> + -- 'id' must be present; its exact value follows the + -- 'NotificationId' ToJSON instance (tested elsewhere). + assertBool "data.id key missing" (KeyMap.member (Key.fromText "id") dataObj) + other -> assertFailure ("expected data object, got " <> show other) + assertBool "user key missing" (KeyMap.member (Key.fromText "user") v), + testCase "roundtrips through Aeson decode (is valid JSON)" $ do + nid <- mkNotificationId + let np = NativePush nid HighPriority Nothing + uid = read "00000000-0000-0001-0000-000000000001" :: UserId + case serialise np uid of + Right json -> + case Aeson.decode (LBS.fromStrict json) :: Maybe Value of + Just _ -> pure () + Nothing -> assertFailure "serialise produced invalid JSON" + Left err -> assertFailure ("serialise failed: " <> show err), + testCase "payload is well under the RFC 8291 §4 limit" $ do + nid <- mkNotificationId + let np = NativePush nid HighPriority Nothing + uid = read "00000000-0000-0001-0000-000000000002" :: UserId + case serialise np uid of + Right json -> + assertBool + ("payload too large: " <> show (BS.length json)) + (BS.length json <= maxPlaintextLength) + Left err -> assertFailure ("serialise failed: " <> show err) + ] + +decodeObject :: ByteString -> IO (KeyMap.KeyMap Value) +decodeObject bs = + case Aeson.decode (LBS.fromStrict bs) of + Just (Object o) -> pure o + _ -> assertFailure "expected JSON object" diff --git a/services/gundeck/test/unit/WebPushSsrf.hs b/services/gundeck/test/unit/WebPushSsrf.hs new file mode 100644 index 00000000000..121d81bf9aa --- /dev/null +++ b/services/gundeck/test/unit/WebPushSsrf.hs @@ -0,0 +1,107 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Unit tests for "Gundeck.Push.Web.Ssrf". +module WebPushSsrf + ( tests, + ) +where + +import Data.ByteString.Char8 qualified as BS +import Gundeck.Push.Web.Ssrf +import Imports +import Test.Tasty +import Test.Tasty.HUnit + +tests :: TestTree +tests = + testGroup + "WebPushSsrf" + [ testGroup + "isPrivateLiteralHost — IPv4 private ranges" + [ testCase "rejects 10.0.0.1 (RFC 1918)" $ + isPrivate "10.0.0.1", + testCase "rejects 172.16.0.1 (RFC 1918)" $ + isPrivate "172.16.0.1", + testCase "rejects 172.31.255.255 (RFC 1918 upper bound)" $ + isPrivate "172.31.255.255", + testCase "rejects 192.168.1.1 (RFC 1918)" $ + isPrivate "192.168.1.1", + testCase "rejects 127.0.0.1 (loopback)" $ + isPrivate "127.0.0.1", + testCase "rejects 127.1.2.3 (loopback, non-0.0.1)" $ + isPrivate "127.1.2.3", + testCase "rejects 0.0.0.0 (this network)" $ + isPrivate "0.0.0.0", + testCase "rejects 169.254.169.254 (AWS metadata)" $ + isPrivate "169.254.169.254", + testCase "rejects 169.254.0.1 (link-local)" $ + isPrivate "169.254.0.1", + testCase "rejects 100.64.0.1 (CGNAT)" $ + isPrivate "100.64.0.1" + ], + testGroup + "isPrivateLiteralHost — IPv6 private ranges" + [ testCase "rejects ::1 (loopback)" $ + isPrivate "::1", + testCase "rejects :: (unspecified)" $ + isPrivate "::", + testCase "rejects fc00::1 (unique local)" $ + isPrivate "fc00::1", + testCase "rejects fd12:3456:789a::1 (unique local)" $ + isPrivate "fd12:3456:789a::1", + testCase "rejects fe80::1 (link-local)" $ + isPrivate "fe80::1", + testCase "rejects ::ffff:127.0.0.1 (IPv4-mapped loopback)" $ + isPrivate "::ffff:127.0.0.1", + testCase "rejects ::ffff:169.254.169.254 (IPv4-mapped metadata)" $ + isPrivate "::ffff:169.254.169.254" + ], + testGroup + "isPrivateLiteralHost — hostname and public IPs" + [ testCase "rejects 'localhost'" $ + isPrivate "localhost", + testCase "rejects 'LOCALHOST' (case-insensitive)" $ + isPrivate "LOCALHOST", + testCase "accepts a public IPv4" $ + isNotPrivate "142.250.190.46", + testCase "accepts 172.32.0.1 (just outside RFC 1918 172.16/12)" $ + -- 172.32.0.0/16 is NOT in 172.16.0.0/12 (which covers 172.16–172.31). + isNotPrivate "172.32.0.1", + testCase "accepts a public IPv6" $ + isNotPrivate "2606:4700:4700::1111", + testCase "accepts a hostname (not a literal IP)" $ + isNotPrivate "fcm.googleapis.com", + testCase "accepts a hostname with subdomain" $ + isNotPrivate "updates.push.services.mozilla.com", + testCase "treats hex-encoded IP as hostname (not literal)" $ + -- 0x7f000001 is 127.0.0.1 in hex; parseIPv4 rejects it (single + -- dot-group), so it is treated as a hostname and subject to the + -- registration allowlist rather than the literal-IP filter. + isNotPrivate "0x7f000001", + testCase "treats decimal-encoded IP as hostname" $ + isNotPrivate "2130706433", + testCase "treats FQDN 'localhost.' as a hostname" $ + isNotPrivate "localhost." + ] + ] + +isPrivate :: [Char] -> Assertion +isPrivate s = assertBool ("expected private: " <> s) (isPrivateLiteralHost (BS.pack s)) + +isNotPrivate :: [Char] -> Assertion +isNotPrivate s = assertBool ("expected NOT private: " <> s) (not (isPrivateLiteralHost (BS.pack s)))