You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by bt...@apache.org on 2026/10/19 13:43:15 UTC

(james-project) branch master updated (01f2d0b9b3 -> 0888cddf26)

This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git


    from 01f2d0b9b3 Bump com.rabbitmq:amqp-client from 5.35.0 to 5.37.0
     new 4b3d138155 [FIX] JMAP Web Push: accept the p256dh key of browsers
     new a73f5e2490 [ENHANCEMENT] JMAP Web Push: a TTL of four weeks
     new edb263173f [FIX] JMAP Web Push: tell that a PushVerification is encrypted
     new ec3c62e0c9 [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)
     new dbd127371b fixup! [ENHANCEMENT] JMAP Web Push: a TTL of four weeks
     new 5a7c2fd7aa fixup! [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)
     new 1293a61230 fixup! [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)
     new 0888cddf26 fixup! [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)

The 8 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 CHANGELOG.md                                       |   4 +
 docs/modules/servers/partials/configure/jmap.adoc  |  55 +++++
 .../sample-configuration/jmap.properties           |  12 +-
 .../sample-configuration/jmap.properties           |  12 +-
 .../sample-configuration/jmap.properties           |  12 +-
 .../java/org/apache/james/jmap/JMAPModule.java     |   6 +
 .../james/jmap/api/model/PushSubscription.scala    |  17 +-
 .../jmap/api/model/PushSubscriptionKeysTest.scala  |  76 ++++++
 .../rfc8621/contract/WebPushVapidContract.scala    | 260 +++++++++++++++++++++
 ...sabledTest.java => MemoryWebPushVapidTest.java} |  10 +-
 .../org/apache/james/jmap/core/Capabilities.scala  |   3 +-
 .../org/apache/james/jmap/core/Capability.scala    |  26 ++-
 .../james/jmap/core/JmapRfc8621Configuration.scala |  26 ++-
 .../james/jmap/json/ResponseSerializer.scala       |   3 +-
 .../PushSubscriptionSetCreatePerformer.scala       |   8 +-
 .../james/jmap/pushsubscription/PushListener.scala |   2 +-
 .../james/jmap/pushsubscription/PushRequest.scala  |   4 +
 .../apache/james/jmap/pushsubscription/Vapid.scala | 144 ++++++++++++
 .../jmap/pushsubscription/WebPushClient.scala      |  17 +-
 .../org/apache/james/jmap/routes/JmapApi.scala     |   2 +-
 .../apache/james/jmap/routes/SessionSupplier.scala |   3 +-
 .../DefaultWebPushClientVapidTest.java             |  65 ++++++
 .../jmap/core/JmapRfc8621ConfigurationTest.scala   |  22 ++
 .../jmap/pushsubscription/PushListenerTest.scala   |   4 +-
 .../PushSubscriptionSetCreateProcessorTest.scala   |   3 +
 .../james/jmap/pushsubscription/VapidTest.scala    | 240 +++++++++++++++++++
 .../james/jmap/routes/SessionSupplierTest.scala    |  55 ++++-
 27 files changed, 1065 insertions(+), 26 deletions(-)
 create mode 100644 server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/model/PushSubscriptionKeysTest.scala
 create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushVapidContract.scala
 copy server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/{MemoryWebPushDisabledTest.java => MemoryWebPushVapidTest.java} (86%)
 create mode 100644 server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/Vapid.scala
 create mode 100644 server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/pushsubscription/DefaultWebPushClientVapidTest.java
 create mode 100644 server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/VapidTest.scala


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


(james-project) 05/08: fixup! [ENHANCEMENT] JMAP Web Push: a TTL of four weeks

Posted by bt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit dbd127371b94f4fbc704d05ff96ef243841074fc
Author: Clement Guyon <cg...@linagora.com>
AuthorDate: Mon Oct 12 23:49:30 2026 +0200

    fixup! [ENHANCEMENT] JMAP Web Push: a TTL of four weeks
    
    One week: a push that could not be delivered for a week is not worth
    delivering any more.
---
 CHANGELOG.md                                                          | 2 +-
 .../apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala | 2 +-
 .../scala/org/apache/james/jmap/pushsubscription/PushListener.scala   | 2 +-
 .../scala/org/apache/james/jmap/pushsubscription/PushRequest.scala    | 4 ++--
 .../org/apache/james/jmap/pushsubscription/PushListenerTest.scala     | 4 ++--
 .../pushsubscription/PushSubscriptionSetCreateProcessorTest.scala     | 2 +-
 6 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40b94b7e1b..5049fa1332 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,7 +56,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
  - [ENHANCEMENT] Implement negative ACL for JMAP
  - JAMES-4157 Implement JMAP `Blob/copy` (#2896)
  - [ENHANCEMENT] JMAP Web Push: authenticate requests with VAPID and advertise the `urn:ietf:params:jmap:webpush-vapid` capability (RFC-9749), see `webpush.vapid.*` in `jmap.properties`
- - [ENHANCEMENT] JMAP Web Push: ask push services to retain pushes four weeks (`TTL`) instead of 68 years
+ - [ENHANCEMENT] JMAP Web Push: ask push services to retain pushes one week (`TTL`) instead of 68 years
  - JAMES-3728 Implement `MessageIdManager::updateEmail`, allowing `Email/set` update to combine move and setFlags
  - JAMES-4203 Add Identity events, plugged into `CustomIdentityDAO`
  - JAMES-3893 Allow deleting identities via WebAdmin
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala
index 361d307f17..50673d6dcb 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala
@@ -111,7 +111,7 @@ class PushSubscriptionSetCreateProcessor @Inject()(webPushClient: WebPushClient)
     SMono.fromCallable(() => Json.stringify(PushSerializer.serializePushVerification(pushVerification)).getBytes(StandardCharsets.UTF_8))
       .map(clearPayload => keys.map(keysValue => keysValue.encrypt(clearPayload)).getOrElse(clearPayload))
       .flatMap(payload => SMono.fromPublisher(webPushClient.push(pushSubscriptionServerURL, PushRequest(
-        ttl = PushTTL.FOUR_WEEKS,
+        ttl = PushTTL.ONE_WEEK,
         // Without it the client cannot tell that the payload is encrypted, nor read it
         contentCoding = keys.map(_ => Aes128gcm),
         payload = payload))))
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushListener.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushListener.scala
index 4777ea451b..0b52d4db3e 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushListener.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushListener.scala
@@ -90,7 +90,7 @@ class PushListener @Inject()(pushRepository: PushSubscriptionRepository,
       .fold(SMono.empty[Unit])(stateChange => SMono(webPushClient.push(pushSubscription.url, asPushRequest(stateChange, pushSubscription))))
 
   private def asPushRequest(stateChange: StateChange, pushSubscription: PushSubscription): PushRequest =
-    PushRequest(ttl = PushTTL.FOUR_WEEKS,
+    PushRequest(ttl = PushTTL.ONE_WEEK,
       urgency = Some(urgency(stateChange)),
       topic = Some(extractTopic(stateChange)),
       contentCoding = pushSubscription.keys.map(_ => Aes128gcm),
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushRequest.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushRequest.scala
index 85b4b4244a..e143a831d0 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushRequest.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushRequest.scala
@@ -88,8 +88,8 @@ object PushTTL {
       .map(new IllegalArgumentException(_))
 
   val MAX: PushTTL = validate(MAX_INT).toOption.get
-  // The longest push services commonly retain a message: they cap a longer TTL
-  val FOUR_WEEKS: PushTTL = validate(Duration.ofDays(28).getSeconds).toOption.get
+  // A push that could not be delivered for a week is not worth delivering any more
+  val ONE_WEEK: PushTTL = validate(Duration.ofDays(7).getSeconds).toOption.get
 }
 
 case class PushTopicConstraint()
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushListenerTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushListenerTest.scala
index 8c8fae737d..eb5e156b96 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushListenerTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushListenerTest.scala
@@ -126,7 +126,7 @@ class PushListenerTest {
     verify(webPushClient).push(ArgumentMatchers.eq(url), argumentCaptor.capture())
 
     SoftAssertions.assertSoftly(softly => {
-      softly.assertThat(argumentCaptor.getValue.ttl).isEqualTo(PushTTL.FOUR_WEEKS)
+      softly.assertThat(argumentCaptor.getValue.ttl).isEqualTo(PushTTL.ONE_WEEK)
       softly.assertThat(new String(argumentCaptor.getValue.payload, StandardCharsets.UTF_8))
         .isEqualTo(s"""{"@type":"StateChange","changed":{"$bobAccountId":{"Email":"${state1.value.toString}"}}}""")
     })
@@ -217,7 +217,7 @@ class PushListenerTest {
     verify(webPushClient).push(ArgumentMatchers.eq(url), argumentCaptor.capture())
 
     SoftAssertions.assertSoftly(softly => {
-      softly.assertThat(argumentCaptor.getValue.ttl).isEqualTo(PushTTL.FOUR_WEEKS)
+      softly.assertThat(argumentCaptor.getValue.ttl).isEqualTo(PushTTL.ONE_WEEK)
       softly.assertThat(new String(argumentCaptor.getValue.payload, StandardCharsets.UTF_8))
         .isEqualTo(s"""{"@type":"StateChange","changed":{"$bobAccountId":{"Email":"${state1.value.toString}","Mailbox":"${state2.value.toString}"}}}""")
     })
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushSubscriptionSetCreateProcessorTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushSubscriptionSetCreateProcessorTest.scala
index 333b670b39..593275b14d 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushSubscriptionSetCreateProcessorTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushSubscriptionSetCreateProcessorTest.scala
@@ -77,7 +77,7 @@ class PushSubscriptionSetCreateProcessorTest {
 
     assertThat(requests.size).isEqualTo(1)
     assertThat(requests.head.containsHeader("Content-Encoding")).isFalse
-    assertThat(requests.head.getFirstHeader("TTL")).isEqualTo("2419200")
+    assertThat(requests.head.getFirstHeader("TTL")).isEqualTo("604800")
     assertThatJson(requests.head.getBodyAsString)
       .isEqualTo(
         """{


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


(james-project) 04/08: [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)

Posted by bt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit ec3c62e0c905c030da4961f9fa3d56644fb5a6de
Author: Clement Guyon <cg...@linagora.com>
AuthorDate: Mon Oct 12 22:08:33 2026 +0200

    [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)
    
    Browsers only subscribe to a push service with the key of the application
    server, and their push services refuse requests that are not signed by it
    (RFC-8292).
    
    With a key pair in jmap.properties (webpush.vapid.public.key,
    webpush.vapid.private.key, webpush.vapid.subject):
    
     - each Web Push request, PushVerification included, carries
       `Authorization: vapid t=<JWT>, k=<public key>`, the token being signed
       for the origin of the push resource;
     - the session advertises the urn:ietf:params:jmap:webpush-vapid
       capability and its applicationServerKey.
    
    Without a key pair nothing changes: requests are not signed and the
    capability is not advertised.
---
 CHANGELOG.md                                       |   4 +
 docs/modules/servers/partials/configure/jmap.adoc  |  18 ++
 .../sample-configuration/jmap.properties           |  10 +-
 .../sample-configuration/jmap.properties           |  10 +-
 .../sample-configuration/jmap.properties           |  10 +-
 .../java/org/apache/james/jmap/JMAPModule.java     |   6 +
 .../rfc8621/contract/WebPushVapidContract.scala    | 230 +++++++++++++++++
 .../rfc8621/memory/MemoryWebPushVapidTest.java     |  51 ++++
 .../org/apache/james/jmap/core/Capabilities.scala  |   3 +-
 .../org/apache/james/jmap/core/Capability.scala    |  27 +-
 .../james/jmap/core/JmapRfc8621Configuration.scala |  15 +-
 .../james/jmap/json/ResponseSerializer.scala       |   3 +-
 .../apache/james/jmap/pushsubscription/Vapid.scala | 155 ++++++++++++
 .../jmap/pushsubscription/WebPushClient.scala      |  17 +-
 .../DefaultWebPushClientVapidTest.java             |  65 +++++
 .../jmap/core/JmapRfc8621ConfigurationTest.scala   |  22 ++
 .../james/jmap/pushsubscription/VapidTest.scala    | 276 +++++++++++++++++++++
 .../james/jmap/routes/SessionSupplierTest.scala    |  28 ++-
 18 files changed, 936 insertions(+), 14 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e718d1b99..40b94b7e1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
  - JAMES-4196 Allow CRUD operations on shared folders for JMAP (#2988)
  - [ENHANCEMENT] Implement negative ACL for JMAP
  - JAMES-4157 Implement JMAP `Blob/copy` (#2896)
+ - [ENHANCEMENT] JMAP Web Push: authenticate requests with VAPID and advertise the `urn:ietf:params:jmap:webpush-vapid` capability (RFC-9749), see `webpush.vapid.*` in `jmap.properties`
+ - [ENHANCEMENT] JMAP Web Push: ask push services to retain pushes four weeks (`TTL`) instead of 68 years
  - JAMES-3728 Implement `MessageIdManager::updateEmail`, allowing `Email/set` update to combine move and setFlags
  - JAMES-4203 Add Identity events, plugged into `CustomIdentityDAO`
  - JAMES-3893 Allow deleting identities via WebAdmin
@@ -89,6 +91,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 ### Fixes
 
  - [FIX] JMAP DownloadRoutes: avoid noisy benign `IllegalStateException` upon mid-stream errors
+ - [FIX] JMAP Web Push: accept the `p256dh` key of browsers, an uncompressed P-256 point (RFC-8291); its X.509 encoding is still accepted
+ - [FIX] JMAP Web Push: an encrypted `PushVerification` now carries `Content-Encoding: aes128gcm`, without which clients cannot read it
  - [FIX] `ICALAttributeDTO` should sanitize invalid dtstamp (#3084)
  - [FIX] Better handle `InterruptedException` in `receiveMessageInTimespan` (#3083)
  - JAMES-4214 Pass message rather than InputStream so that several body parts can be read (#3081)
diff --git a/docs/modules/servers/partials/configure/jmap.adoc b/docs/modules/servers/partials/configure/jmap.adoc
index 9bf073725e..190305a2ef 100644
--- a/docs/modules/servers/partials/configure/jmap.adoc
+++ b/docs/modules/servers/partials/configure/jmap.adoc
@@ -113,6 +113,24 @@ WebSocket nor EventSource, which do not rely on push subscriptions.
 | webpush.prevent.server.side.request.forgery
 | Optional boolean. Prevent server side request forgery by preventing calls to the private network ranges. Defaults to true, can be disabled for testing.
 
+| webpush.vapid.public.key
+| Optional string. Public key identifying this server to the push services (VAPID,
+https://www.rfc-editor.org/rfc/rfc8292[RFC-8292]): a P-256 public key in its uncompressed form (65 bytes), base64url
+encoded, as Web Push tools generate it. When set, with `webpush.vapid.private.key`, every Web Push request is
+authenticated with it, and the JMAP session advertises it as the `applicationServerKey` of the
+`urn:ietf:params:jmap:webpush-vapid` capability (https://www.rfc-editor.org/rfc/rfc9749[RFC-9749]), which browsers need
+to subscribe. Defaults to no VAPID: requests are not authenticated and the capability is not advertised.
+The key pair is meant to stay: key rotation (RFC-9749 section 5) is not implemented. After a change, push services
+refuse the pushes of the subscriptions created with the former key, until clients subscribe again.
+
+| webpush.vapid.private.key
+| Optional string. Private key matching `webpush.vapid.public.key`: a P-256 private key (32 bytes), base64url encoded.
+Required when the public key is set. Keep it secret.
+
+| webpush.vapid.subject
+| Optional string. Contact of the operator of this server, sent to the push services as the `sub` claim: a `mailto:`
+or an `https:` URI. Recommended when VAPID is configured, as some push services refuse requests without it.
+
 | cassandra.filter.projection.activated
 |Optional boolean. Defaults to false. Casandra backends only. Whether to use or not the Cassandra projection
 for JMAP filters. This projection optimizes reads, but needs to be correctly populated. Turning it on on
diff --git a/server/apps/distributed-app/sample-configuration/jmap.properties b/server/apps/distributed-app/sample-configuration/jmap.properties
index 0b27f829ed..a92f58ac46 100644
--- a/server/apps/distributed-app/sample-configuration/jmap.properties
+++ b/server/apps/distributed-app/sample-configuration/jmap.properties
@@ -55,4 +55,12 @@ tls.secret=james72laBalle
 # webpush.enabled=false
 
 # Prevent server side request forgery by preventing calls to the private network ranges. Defaults to true, can be disabled for testing.
-# webpush.prevent.server.side.request.forgery=false
\ No newline at end of file
+# webpush.prevent.server.side.request.forgery=false
+
+# VAPID (RFC-8292, RFC-9749): identifies this server to the push services, which browsers require to subscribe.
+# A P-256 key pair, base64url encoded as Web Push tools generate it: the public key in its uncompressed form (65 bytes),
+# the private key (32 bytes). Defaults to no VAPID.
+# webpush.vapid.public.key=
+# webpush.vapid.private.key=
+# Contact of the operator, a mailto: or an https: URI. Recommended: some push services refuse requests without it.
+# webpush.vapid.subject=mailto:postmaster@example.com
\ No newline at end of file
diff --git a/server/apps/memory-app/sample-configuration/jmap.properties b/server/apps/memory-app/sample-configuration/jmap.properties
index 6a8e20d6bf..720bdb80af 100644
--- a/server/apps/memory-app/sample-configuration/jmap.properties
+++ b/server/apps/memory-app/sample-configuration/jmap.properties
@@ -52,4 +52,12 @@ delay.sends.enabled=true
 # webpush.enabled=false
 
 # Prevent server side request forgery by preventing calls to the private network ranges. Defaults to true, can be disabled for testing.
-# webpush.prevent.server.side.request.forgery=false
\ No newline at end of file
+# webpush.prevent.server.side.request.forgery=false
+
+# VAPID (RFC-8292, RFC-9749): identifies this server to the push services, which browsers require to subscribe.
+# A P-256 key pair, base64url encoded as Web Push tools generate it: the public key in its uncompressed form (65 bytes),
+# the private key (32 bytes). Defaults to no VAPID.
+# webpush.vapid.public.key=
+# webpush.vapid.private.key=
+# Contact of the operator, a mailto: or an https: URI. Recommended: some push services refuse requests without it.
+# webpush.vapid.subject=mailto:postmaster@example.com
\ No newline at end of file
diff --git a/server/apps/postgres-app/sample-configuration/jmap.properties b/server/apps/postgres-app/sample-configuration/jmap.properties
index 0b27f829ed..a92f58ac46 100644
--- a/server/apps/postgres-app/sample-configuration/jmap.properties
+++ b/server/apps/postgres-app/sample-configuration/jmap.properties
@@ -55,4 +55,12 @@ tls.secret=james72laBalle
 # webpush.enabled=false
 
 # Prevent server side request forgery by preventing calls to the private network ranges. Defaults to true, can be disabled for testing.
-# webpush.prevent.server.side.request.forgery=false
\ No newline at end of file
+# webpush.prevent.server.side.request.forgery=false
+
+# VAPID (RFC-8292, RFC-9749): identifies this server to the push services, which browsers require to subscribe.
+# A P-256 key pair, base64url encoded as Web Push tools generate it: the public key in its uncompressed form (65 bytes),
+# the private key (32 bytes). Defaults to no VAPID.
+# webpush.vapid.public.key=
+# webpush.vapid.private.key=
+# Contact of the operator, a mailto: or an https: URI. Recommended: some push services refuse requests without it.
+# webpush.vapid.subject=mailto:postmaster@example.com
\ No newline at end of file
diff --git a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/JMAPModule.java b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/JMAPModule.java
index e5bef2bd9b..8b9d623d6d 100644
--- a/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/JMAPModule.java
+++ b/server/container/guice/protocols/jmap/src/main/java/org/apache/james/jmap/JMAPModule.java
@@ -45,6 +45,7 @@ import org.apache.james.jmap.core.QuotaCapabilityFactory$;
 import org.apache.james.jmap.core.SharesCapabilityFactory$;
 import org.apache.james.jmap.core.SubmissionCapabilityFactory;
 import org.apache.james.jmap.core.VacationResponseCapabilityFactory$;
+import org.apache.james.jmap.core.WebPushVapidCapabilityFactory;
 import org.apache.james.jmap.core.WebSocketCapabilityFactory$;
 import org.apache.james.jmap.mailet.filter.JMAPFiltering;
 import org.apache.james.jmap.rfc8621.RFC8621MethodsModule;
@@ -214,6 +215,11 @@ public class JMAPModule extends AbstractModule {
         return WebSocketCapabilityFactory$.MODULE$;
     }
 
+    @ProvidesIntoSet
+    CapabilityFactory webPushVapidCapability(JmapRfc8621Configuration configuration) {
+        return new WebPushVapidCapabilityFactory(configuration);
+    }
+
     @Provides
     @Singleton
     JMAPConfiguration provideConfiguration(PropertiesProvider propertiesProvider) throws ConfigurationException {
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushVapidContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushVapidContract.scala
new file mode 100644
index 0000000000..69c1e53651
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushVapidContract.scala
@@ -0,0 +1,230 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.contract
+
+import java.math.BigInteger
+import java.nio.charset.StandardCharsets
+import java.security.KeyPair
+import java.security.interfaces.{ECPrivateKey, ECPublicKey}
+import java.util.Base64
+import java.util.concurrent.TimeUnit
+
+import com.google.crypto.tink.apps.webpush.WebPushHybridDecrypt
+import com.google.crypto.tink.subtle.EllipticCurves.{CurveType, EcdsaEncoding, PointFormatType}
+import com.google.crypto.tink.subtle.Enums.HashType
+import com.google.crypto.tink.subtle.{EcdsaVerifyJce, EllipticCurves}
+import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT
+import io.restassured.RestAssured.{`given`, requestSpecification}
+import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson
+import org.apache.http.HttpStatus.{SC_CREATED, SC_OK}
+import org.apache.james.GuiceJamesServer
+import org.apache.james.jmap.core.JmapRfc8621Configuration
+import org.apache.james.jmap.http.UserCredential
+import org.apache.james.jmap.pushsubscription.VapidConfiguration
+import org.apache.james.jmap.rfc8621.contract.Fixture._
+import org.apache.james.jmap.rfc8621.contract.WebPushVapidContract.{APPLICATION_SERVER_KEY, KEY_PAIR}
+import org.apache.james.mailbox.model.MailboxPath
+import org.apache.james.modules.MailboxProbeImpl
+import org.apache.james.modules.protocols.SmtpGuiceProbe
+import org.apache.james.utils.{DataProbeImpl, SMTPMessageSender, SpoolerProbe}
+import org.assertj.core.api.Assertions.assertThat
+import org.awaitility.Awaitility
+import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS
+import org.awaitility.core.ConditionFactory
+import org.junit.jupiter.api.{BeforeEach, Test}
+import org.mockserver.integration.ClientAndServer
+import org.mockserver.model.HttpRequest
+import org.mockserver.model.HttpRequest.request
+import org.mockserver.model.HttpResponse.response
+import play.api.libs.json.{JsObject, JsString, Json}
+
+object WebPushVapidContract {
+  private val KEY_PAIR: KeyPair = EllipticCurves.generateKeyPair(CurveType.NIST_P256)
+  // As Web Push tools generate them: the uncompressed point and the private value, base64url encoded
+  private val APPLICATION_SERVER_KEY: String = Base64.getUrlEncoder.withoutPadding()
+    .encodeToString(EllipticCurves.pointEncode(CurveType.NIST_P256, PointFormatType.UNCOMPRESSED, KEY_PAIR.getPublic.asInstanceOf[ECPublicKey].getW))
+  private val PRIVATE_KEY: String = Base64.getUrlEncoder.withoutPadding()
+    .encodeToString(leftPad(KEY_PAIR.getPrivate.asInstanceOf[ECPrivateKey].getS))
+
+  val configuration: JmapRfc8621Configuration = JmapRfc8621Configuration(
+    urlPrefixString = "http://127.0.0.1",
+    websocketPrefixString = "ws://127.0.0.1",
+    preventServerSideRequestForgery = false,
+    vapid = VapidConfiguration.from(Some(APPLICATION_SERVER_KEY), Some(PRIVATE_KEY), Some("mailto:postmaster@example.com")))
+
+  private def leftPad(value: BigInteger): Array[Byte] = {
+    val bytes: Array[Byte] = value.toByteArray.dropWhile(_ == 0)
+    Array.fill[Byte](32 - bytes.length)(0) ++ bytes
+  }
+}
+
+trait WebPushVapidContract {
+  private lazy val awaitAtMostTenSeconds: ConditionFactory = Awaitility.`with`
+    .pollInterval(ONE_HUNDRED_MILLISECONDS)
+    .and.`with`.pollDelay(ONE_HUNDRED_MILLISECONDS)
+    .await
+    .atMost(10, TimeUnit.SECONDS)
+  private lazy val PUSH_URL_PATH: String = "/push-vapid"
+
+  @BeforeEach
+  def setUp(server: GuiceJamesServer, pushServer: ClientAndServer): Unit = {
+    server.getProbe(classOf[DataProbeImpl])
+      .fluent()
+      .addDomain(DOMAIN.asString())
+      .addUser(BOB.asString(), BOB_PASSWORD)
+      .addUser(ANDRE.asString(), ANDRE_PASSWORD)
+    server.getProbe(classOf[MailboxProbeImpl])
+      .createMailbox(MailboxPath.inbox(BOB))
+
+    requestSpecification = baseRequestSpecBuilder(server)
+      .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD)))
+      .addHeader(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER)
+      .build()
+
+    pushServer
+      .when(request.withPath(PUSH_URL_PATH).withMethod("POST"))
+      .respond(response.withStatusCode(SC_CREATED))
+  }
+
+  private def pushed(pushServer: ClientAndServer): Seq[HttpRequest] =
+    pushServer.retrieveRecordedRequests(request().withPath(PUSH_URL_PATH)).toSeq
+
+  private def createPushSubscription(pushServer: ClientAndServer, keys: String = ""): String =
+    `given`
+      .body(
+        s"""{
+           |  "using": ["urn:ietf:params:jmap:core"],
+           |  "methodCalls": [[
+           |    "PushSubscription/set",
+           |    {
+           |      "create": {
+           |        "4f29": {
+           |          "deviceClientId": "a889-ffea-910",
+           |          "url": "http://127.0.0.1:${pushServer.getLocalPort}$PUSH_URL_PATH",
+           |          $keys
+           |          "types": ["Mailbox"]
+           |        }
+           |      }
+           |    },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .extract()
+      .jsonPath()
+      .get("methodResponses[0][1].created.4f29.id")
+
+  // The claims of a `vapid t=<token>, k=<key>` header, once its signature is verified with the configured public key
+  private def verifiedClaims(pushRequest: HttpRequest): JsObject = {
+    val authorization: String = pushRequest.getFirstHeader("Authorization")
+    assertThat(authorization).startsWith("vapid t=").endsWith(s", k=$APPLICATION_SERVER_KEY")
+    val Array(header, payload, signature) = authorization.stripPrefix("vapid t=").takeWhile(_ != ',').split('.')
+
+    new EcdsaVerifyJce(KEY_PAIR.getPublic.asInstanceOf[ECPublicKey], HashType.SHA256, EcdsaEncoding.IEEE_P1363)
+      .verify(Base64.getUrlDecoder.decode(signature), s"$header.$payload".getBytes(StandardCharsets.US_ASCII))
+    Json.parse(Base64.getUrlDecoder.decode(payload)).as[JsObject]
+  }
+
+  @Test
+  def sessionShouldAdvertiseTheApplicationServerKey(): Unit = {
+    val applicationServerKey: String = `given`()
+    .when()
+      .get("/session")
+    .`then`
+      .statusCode(SC_OK)
+      .extract()
+      .jsonPath()
+      .get("capabilities.'urn:ietf:params:jmap:webpush-vapid'.applicationServerKey")
+
+    assertThat(applicationServerKey).isEqualTo(APPLICATION_SERVER_KEY)
+  }
+
+  @Test
+  def pushVerificationShouldBeAuthenticated(pushServer: ClientAndServer): Unit = {
+    createPushSubscription(pushServer)
+
+    awaitAtMostTenSeconds.untilAsserted(() => assertThat(pushed(pushServer).size).isEqualTo(1))
+    val claims: JsObject = verifiedClaims(pushed(pushServer).head)
+    assertThat(claims.value("aud").as[String]).isEqualTo(s"http://127.0.0.1:${pushServer.getLocalPort}")
+    assertThat(claims.value("sub").as[String]).isEqualTo("mailto:postmaster@example.com")
+    assertThat(claims.value("exp").as[Long]).isGreaterThan(System.currentTimeMillis() / 1000)
+  }
+
+  @Test
+  def stateChangeShouldBeAuthenticated(server: GuiceJamesServer, pushServer: ClientAndServer): Unit = {
+    val pushSubscriptionId: String = createPushSubscription(pushServer)
+    awaitAtMostTenSeconds.untilAsserted(() => assertThat(pushed(pushServer).size).isEqualTo(1))
+    val verificationCode: String = Json.parse(pushed(pushServer).head.getBodyAsString).as[JsObject]
+      .value("verificationCode").as[JsString].value
+    `given`
+      .body(
+        s"""{
+           |  "using": ["urn:ietf:params:jmap:core"],
+           |  "methodCalls": [[
+           |    "PushSubscription/set",
+           |    { "update": { "$pushSubscriptionId": { "verificationCode": "$verificationCode" } } },
+           |    "c1"]]
+           |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+
+    val smtpMessageSender: SMTPMessageSender = new SMTPMessageSender(DOMAIN.asString())
+    smtpMessageSender.connect("127.0.0.1", server.getProbe(classOf[SmtpGuiceProbe]).getSmtpPort)
+      .authenticate(ANDRE.asString, ANDRE_PASSWORD)
+      .sendMessage(ANDRE.asString, BOB.asString())
+    smtpMessageSender.close()
+    awaitAtMostTenSeconds.until(() => server.getProbe(classOf[SpoolerProbe]).processingFinished())
+
+    awaitAtMostTenSeconds.untilAsserted(() => assertThat(pushed(pushServer).size).isGreaterThanOrEqualTo(2))
+    val stateChange: HttpRequest = pushed(pushServer).last
+    assertThatJson(stateChange.getBodyAsString).node("@type").isEqualTo("StateChange")
+    assertThat(verifiedClaims(stateChange).value("aud").as[String]).isEqualTo(s"http://127.0.0.1:${pushServer.getLocalPort}")
+  }
+
+  @Test
+  def pushVerificationShouldBeReadableWithTheKeysOfABrowser(pushServer: ClientAndServer): Unit = {
+    val uaKeyPair: KeyPair = EllipticCurves.generateKeyPair(CurveType.NIST_P256)
+    val uaPublicKey: ECPublicKey = uaKeyPair.getPublic.asInstanceOf[ECPublicKey]
+    val authSecret: Array[Byte] = "secret123secret1".getBytes(StandardCharsets.UTF_8)
+    // RFC 8291: the public key as an uncompressed point, as `PushSubscription.getKey("p256dh")` gives it
+    val p256dh: String = Base64.getUrlEncoder.withoutPadding()
+      .encodeToString(EllipticCurves.pointEncode(CurveType.NIST_P256, PointFormatType.UNCOMPRESSED, uaPublicKey.getW))
+    val auth: String = Base64.getUrlEncoder.withoutPadding().encodeToString(authSecret)
+
+    val pushSubscriptionId: String = createPushSubscription(pushServer,
+      keys = s""""keys": { "p256dh": "$p256dh", "auth": "$auth" },""")
+
+    awaitAtMostTenSeconds.untilAsserted(() => assertThat(pushed(pushServer).size).isEqualTo(1))
+    val pushVerification: HttpRequest = pushed(pushServer).head
+    assertThat(pushVerification.getFirstHeader("Content-Encoding")).isEqualTo("aes128gcm")
+    verifiedClaims(pushVerification)
+    val clear: String = new String(new WebPushHybridDecrypt.Builder()
+      .withAuthSecret(authSecret)
+      .withRecipientPublicKey(uaPublicKey)
+      .withRecipientPrivateKey(uaKeyPair.getPrivate.asInstanceOf[ECPrivateKey])
+      .build
+      .decrypt(pushVerification.getBodyAsRawBytes, null), StandardCharsets.UTF_8)
+    assertThatJson(clear).node("pushSubscriptionId").isEqualTo(pushSubscriptionId)
+  }
+}
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryWebPushVapidTest.java b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryWebPushVapidTest.java
new file mode 100644
index 0000000000..c83d9cfc95
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621-integration-tests/memory-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/memory/MemoryWebPushVapidTest.java
@@ -0,0 +1,51 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.rfc8621.memory;
+
+import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT;
+
+import org.apache.james.JamesServerBuilder;
+import org.apache.james.JamesServerExtension;
+import org.apache.james.MemoryJamesConfiguration;
+import org.apache.james.MemoryJamesServerMain;
+import org.apache.james.jmap.core.JmapRfc8621Configuration;
+import org.apache.james.jmap.rfc8621.contract.PushServerExtension;
+import org.apache.james.jmap.rfc8621.contract.WebPushVapidContract;
+import org.apache.james.modules.TestJMAPServerModule;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+public class MemoryWebPushVapidTest implements WebPushVapidContract {
+    @RegisterExtension
+    static JamesServerExtension testExtension = new JamesServerBuilder<MemoryJamesConfiguration>(tmpDir ->
+        MemoryJamesConfiguration.builder()
+            .workingDirectory(tmpDir)
+            .configurationFromClasspath()
+            .usersRepository(DEFAULT)
+            .enableJMAP()
+            .build())
+        .server(configuration -> MemoryJamesServerMain.createServer(configuration)
+            .overrideWith(binder -> binder.bind(JmapRfc8621Configuration.class).toInstance(WebPushVapidContract.configuration()))
+            .overrideWith(new TestJMAPServerModule()))
+        .lifeCycle(JamesServerExtension.Lifecycle.PER_TEST)
+        .build();
+
+    @RegisterExtension
+    static PushServerExtension pushServerExtension = new PushServerExtension();
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capabilities.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capabilities.scala
index ef7939b733..7e26df1ec7 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capabilities.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capabilities.scala
@@ -36,7 +36,8 @@ object DefaultCapabilities {
     VacationResponseCapabilityFactory,
     SharesCapabilityFactory,
     SubmissionCapabilityFactory(Clock.systemUTC(), configuration.supportsDelaySends),
-    WebSocketCapabilityFactory)
+    WebSocketCapabilityFactory,
+    WebPushVapidCapabilityFactory(configuration))
 }
 
 object Capabilities {
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala
index 5f34db178d..363a791a29 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala
@@ -29,7 +29,7 @@ import eu.timepit.refined.auto._
 import eu.timepit.refined.collection.NonEmpty
 import eu.timepit.refined.string.Uri
 import org.apache.james.core.Username
-import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, EMAIL_SUBMISSION, JAMES_DELEGATION, JAMES_IDENTITY_SORTORDER, JAMES_QUOTA, JAMES_SHARES, JMAP_CORE, JMAP_MAIL, JMAP_MDN, JMAP_QUOTA, JMAP_VACATION_RESPONSE, JMAP_WEBSOCKET}
+import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, EMAIL_SUBMISSION, JAMES_DELEGATION, JAMES_IDENTITY_SORTORDER, JAMES_QUOTA, JAMES_SHARES, JMAP_CORE, JMAP_MAIL, JMAP_MDN, JMAP_QUOTA, JMAP_VACATION_RESPONSE, JMAP_WEBPUSH_VAPID, JMAP_WEBSOCKET}
 import org.apache.james.jmap.core.CoreCapabilityProperties.CollationAlgorithm
 import org.apache.james.jmap.core.MailCapability.EmailQuerySortOption
 import org.apache.james.jmap.core.SubmissionCapabilityFactory.maximumDelays
@@ -60,6 +60,7 @@ object CapabilityIdentifier {
   val JMAP_VACATION_RESPONSE: CapabilityIdentifier = "urn:ietf:params:jmap:vacationresponse"
   val EMAIL_SUBMISSION: CapabilityIdentifier = "urn:ietf:params:jmap:submission"
   val JMAP_WEBSOCKET: CapabilityIdentifier = "urn:ietf:params:jmap:websocket"
+  val JMAP_WEBPUSH_VAPID: CapabilityIdentifier = "urn:ietf:params:jmap:webpush-vapid"
   val JAMES_QUOTA: CapabilityIdentifier = "urn:apache:james:params:jmap:mail:quota"
   val JMAP_QUOTA: CapabilityIdentifier = "urn:ietf:params:jmap:quota"
   val JAMES_SHARES: CapabilityIdentifier = "urn:apache:james:params:jmap:mail:shares"
@@ -135,6 +136,26 @@ case object WebSocketCapabilityFactory extends CapabilityFactory {
     WebSocketCapabilityProperties(SupportsPush(true), new URI(urlPrefixes.webSocketURLPrefix.toString + "/jmap/ws")))
 }
 
+case class WebPushVapidCapability(properties: WebPushVapidCapabilityProperties, identifier: CapabilityIdentifier = JMAP_WEBPUSH_VAPID) extends Capability
+
+/**
+ * RFC 9749: tells clients the key that authenticates the Web Push requests of this server. Only advertised when
+ * Web Push is enabled and a VAPID key pair is configured.
+ */
+case class WebPushVapidCapabilityFactory(configuration: JmapRfc8621Configuration) extends CapabilityFactory {
+  override def id(): CapabilityIdentifier = JMAP_WEBPUSH_VAPID
+
+  override def create(urlPrefixes: UrlPrefixes, username: Username): Capability =
+    capability().getOrElse(throw new IllegalStateException("Web Push is disabled, or no VAPID key pair is configured"))
+
+  override def createReactive(urlPrefixes: UrlPrefixes, username: Username): Publisher[Capability] =
+    capability().map(SMono.just[Capability]).getOrElse(SMono.empty[Capability])
+
+  private def capability(): Option[Capability] = configuration.vapid
+    .filter(_ => configuration.webPushEnabled)
+    .map(vapid => WebPushVapidCapability(WebPushVapidCapabilityProperties(vapid.applicationServerKey)))
+}
+
 object MaxSizeUpload {
   def of(size: Size): Try[MaxSizeUpload] = refined.refineV[UnsignedIntConstraint](size.asBytes()) match {
     case Right(value) => Success(MaxSizeUpload(value))
@@ -178,6 +199,10 @@ final case class WebSocketCapabilityProperties(supportsPush: SupportsPush,
   override def jsonify(): JsObject = ResponseSerializer.webSocketPropertiesWrites.writes(this)
 }
 
+final case class WebPushVapidCapabilityProperties(applicationServerKey: String) extends CapabilityProperties {
+  override def jsonify(): JsObject = ResponseSerializer.webPushVapidPropertiesWrites.writes(this)
+}
+
 final case class SupportsPush(value: Boolean) extends AnyVal
 final case class MaxDelayedSend(value: Int) extends AnyVal
 final case class EhloName(value: String) extends AnyVal
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
index 346e0a63c3..a20ca85a25 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
@@ -28,7 +28,7 @@ import com.google.common.collect.ImmutableList
 import org.apache.commons.configuration2.Configuration
 import org.apache.james.jmap.core.CapabilityIdentifier.CapabilityIdentifier
 import org.apache.james.jmap.core.JmapRfc8621Configuration.{JMAP_EMAIL_GET_FULL_MAX_SIZE_DEFAULT, JMAP_MAX_OBJECT_IN_GET, JMAP_MAX_OBJECT_IN_SET, JMAP_UPLOAD_QUOTA_LIMIT_DEFAULT, MAX_SIZE_ATTACHMENTS_PER_MAIL_DEFAULT, UPLOAD_LIMIT_DEFAULT}
-import org.apache.james.jmap.pushsubscription.PushClientConfiguration
+import org.apache.james.jmap.pushsubscription.{PushClientConfiguration, VapidConfiguration}
 import org.apache.james.rrt.api.RecipientValidator
 import org.apache.james.util.{DurationParser, Size}
 
@@ -47,6 +47,9 @@ object JmapConfigProperties {
   val WEB_PUSH_MAX_TIMEOUT_SECONDS_PROPERTY: String = "webpush.maxTimeoutSeconds"
   val WEB_PUSH_MAX_CONNECTIONS_PROPERTY: String = "webpush.maxConnections"
   val WEB_PUSH_PREVENT_SERVER_SIDE_REQUEST_FORGERY: String = "webpush.prevent.server.side.request.forgery"
+  val WEB_PUSH_VAPID_PUBLIC_KEY_PROPERTY: String = "webpush.vapid.public.key"
+  val WEB_PUSH_VAPID_PRIVATE_KEY_PROPERTY: String = "webpush.vapid.private.key"
+  val WEB_PUSH_VAPID_SUBJECT_PROPERTY: String = "webpush.vapid.subject"
   val DYNAMIC_JMAP_PREFIX_RESOLUTION_ENABLED_PROPERTY: String = "dynamic.jmap.prefix.resolution.enabled"
   val DELAY_SENDS_ENABLED: String = "delay.sends.enabled"
   val AUTHENTICATION_STRATEGIES: String = "authentication.strategy.rfc8621"
@@ -116,6 +119,10 @@ object JmapRfc8621Configuration {
       maxTimeoutSeconds = Optional.ofNullable(configuration.getInteger(WEB_PUSH_MAX_TIMEOUT_SECONDS_PROPERTY, null)).map(Integer2int).toScala,
       maxConnections = Optional.ofNullable(configuration.getInteger(WEB_PUSH_MAX_CONNECTIONS_PROPERTY, null)).map(Integer2int).toScala,
       preventServerSideRequestForgery = Optional.ofNullable(configuration.getBoolean(WEB_PUSH_PREVENT_SERVER_SIDE_REQUEST_FORGERY, null)).orElse(true),
+      vapid = VapidConfiguration.from(
+        publicKey = Option(configuration.getString(WEB_PUSH_VAPID_PUBLIC_KEY_PROPERTY, null)),
+        privateKey = Option(configuration.getString(WEB_PUSH_VAPID_PRIVATE_KEY_PROPERTY, null)),
+        subject = Option(configuration.getString(WEB_PUSH_VAPID_SUBJECT_PROPERTY, null))),
       authenticationStrategies = Optional.ofNullable(configuration.getList(classOf[String], AUTHENTICATION_STRATEGIES, null)).toScala,
       disabledCapabilities = Optional.ofNullable(configuration.getList(classOf[String], DISABLED_CAPABILITIES, null))
         .orElse(ImmutableList.of())
@@ -144,12 +151,14 @@ case class JmapRfc8621Configuration(urlPrefixString: String,
                                     maxConnections: Option[Int] = None,
                                     authenticationStrategies: Option[java.util.List[String]] = None,
                                     preventServerSideRequestForgery: Boolean = true,
-                                    disabledCapabilities: Set[CapabilityIdentifier] = Set()) {
+                                    disabledCapabilities: Set[CapabilityIdentifier] = Set(),
+                                    vapid: Option[VapidConfiguration] = None) {
 
   val webPushConfiguration: PushClientConfiguration = PushClientConfiguration(
     maxTimeoutSeconds = maxTimeoutSeconds,
     maxConnections = maxConnections,
-    preventServerSideRequestForgery = preventServerSideRequestForgery)
+    preventServerSideRequestForgery = preventServerSideRequestForgery,
+    vapid = vapid)
 
   def urlPrefixes(): UrlPrefixes = UrlPrefixes(new URI(urlPrefixString), new URI(websocketPrefixString))
 
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ResponseSerializer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ResponseSerializer.scala
index 1a4fc8de48..99e30931ef 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ResponseSerializer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/json/ResponseSerializer.scala
@@ -27,7 +27,7 @@ import org.apache.james.jmap.core.CapabilityIdentifier.CapabilityIdentifier
 import org.apache.james.jmap.core.Id.IdConstraint
 import org.apache.james.jmap.core.Invocation.{Arguments, MethodCallId, MethodName}
 import org.apache.james.jmap.core.SetError.SetErrorDescription
-import org.apache.james.jmap.core.{Account, AccountId, Capabilities, Capability, ClientId, CoreCapabilityProperties, CreatedIds, EhloArg, EhloArgs, EhloName, Invocation, IsPersonal, IsReadOnly, MailCapabilityProperties, MaxCallsInRequest, MaxConcurrentRequests, MaxConcurrentUpload, MaxDelayedSend, MaxMailboxDepth, MaxMailboxesPerEmail, MaxObjectsInGet, MaxObjectsInSet, MaxSizeAttachmentsPerEmail, MaxSizeMailboxName, MaxSizeRequest, MaxSizeUpload, MayCreateTopLevelMailbox, ProblemDetails, [...]
+import org.apache.james.jmap.core.{Account, AccountId, Capabilities, Capability, ClientId, CoreCapabilityProperties, CreatedIds, EhloArg, EhloArgs, EhloName, Invocation, IsPersonal, IsReadOnly, MailCapabilityProperties, MaxCallsInRequest, MaxConcurrentRequests, MaxConcurrentUpload, MaxDelayedSend, MaxMailboxDepth, MaxMailboxesPerEmail, MaxObjectsInGet, MaxObjectsInSet, MaxSizeAttachmentsPerEmail, MaxSizeMailboxName, MaxSizeRequest, MaxSizeUpload, MayCreateTopLevelMailbox, ProblemDetails, [...]
 import play.api.libs.functional.syntax._
 import play.api.libs.json._
 
@@ -94,6 +94,7 @@ object ResponseSerializer {
   private implicit val supportsPushWrites: Writes[SupportsPush] = Json.valueWrites[SupportsPush]
   val submissionPropertiesWrites: OWrites[SubmissionProperties] = Json.writes[SubmissionProperties]
   val webSocketPropertiesWrites: OWrites[WebSocketCapabilityProperties] = Json.writes[WebSocketCapabilityProperties]
+  val webPushVapidPropertiesWrites: OWrites[WebPushVapidCapabilityProperties] = Json.writes[WebPushVapidCapabilityProperties]
 
   private implicit val setCapabilityWrites: Writes[Set[_ <: Capability]] =
     (set: Set[_ <: Capability]) =>
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/Vapid.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/Vapid.scala
new file mode 100644
index 0000000000..9c6d485785
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/Vapid.scala
@@ -0,0 +1,155 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.pushsubscription
+
+import java.math.BigInteger
+import java.net.URL
+import java.nio.charset.StandardCharsets
+import java.security.interfaces.{ECPrivateKey, ECPublicKey}
+import java.security.spec.ECPrivateKeySpec
+import java.security.{KeyFactory, Signature}
+import java.time.{Clock, Duration, Instant}
+import java.util.concurrent.ConcurrentHashMap
+import java.util.{Base64, Date, Locale}
+
+import com.google.common.base.Preconditions
+import com.google.crypto.tink.subtle.EllipticCurves
+import com.google.crypto.tink.subtle.EllipticCurves.{CurveType, PointFormatType}
+import io.jsonwebtoken.Jwts
+
+import scala.util.Try
+
+/**
+ * Voluntary Application Server Identification (VAPID, RFC 8292) of the Web Push requests,
+ * as JMAP servers advertise it (RFC 9749).
+ */
+object VapidConfiguration {
+  private val UNCOMPRESSED_POINT_LENGTH: Int = 65
+  private val UNCOMPRESSED_POINT_TAG: Byte = 0x04
+  private val COORDINATE_LENGTH: Int = 32
+
+  /**
+   * @param publicKey  P-256 public key, uncompressed (65 bytes), base64url encoded: the `applicationServerKey` of browsers
+   * @param privateKey P-256 private key (32 bytes), base64url encoded
+   * @param subject    contact of the operator, a `mailto:` or `https:` URI
+   */
+  def from(publicKey: Option[String], privateKey: Option[String], subject: Option[String]): Option[VapidConfiguration] =
+    (publicKey.map(_.trim).filter(_.nonEmpty), privateKey.map(_.trim).filter(_.nonEmpty)) match {
+      case (None, None) => None
+      case (Some(public), Some(secret)) => Some(parse(public, secret, subject.map(_.trim).filter(_.nonEmpty)))
+      case _ => throw new IllegalArgumentException("VAPID needs both its public and its private key")
+    }
+
+  private def parse(publicKey: String, privateKey: String, subject: Option[String]): VapidConfiguration = {
+    val point: Array[Byte] = decode(publicKey, "public")
+    Preconditions.checkArgument(point.length == UNCOMPRESSED_POINT_LENGTH && point(0) == UNCOMPRESSED_POINT_TAG,
+      "The VAPID public key must be an uncompressed P-256 point (65 bytes), base64url encoded".asInstanceOf[Object])
+    val scalar: Array[Byte] = decode(privateKey, "private")
+    Preconditions.checkArgument(scalar.length == COORDINATE_LENGTH,
+      "The VAPID private key must be a P-256 private key (32 bytes), base64url encoded".asInstanceOf[Object])
+    subject.foreach(value => Preconditions.checkArgument(value.startsWith("mailto:") || value.startsWith("https:"),
+      "The VAPID subject must be a `mailto:` or an `https:` URI".asInstanceOf[Object]))
+
+    // Checks that the point is on the curve, which the JDK key factory does not
+    val ecPublicKey: ECPublicKey = Try(EllipticCurves.getEcPublicKey(CurveType.NIST_P256, PointFormatType.UNCOMPRESSED, point))
+      .getOrElse(throw new IllegalArgumentException("The VAPID public key is not a point of the P-256 curve"))
+    val ecPrivateKey: ECPrivateKey = KeyFactory.getInstance("EC")
+      .generatePrivate(new ECPrivateKeySpec(new BigInteger(1, scalar), ecPublicKey.getParams)).asInstanceOf[ECPrivateKey]
+    Preconditions.checkArgument(isKeyPair(ecPublicKey, ecPrivateKey),
+      "The VAPID public key does not match the VAPID private key".asInstanceOf[Object])
+
+    VapidConfiguration(
+      applicationServerKey = Base64.getUrlEncoder.withoutPadding().encodeToString(point),
+      privateKey = ecPrivateKey,
+      subject = subject)
+  }
+
+  private def decode(value: String, name: String): Array[Byte] =
+    Try(Base64.getUrlDecoder.decode(value))
+      .getOrElse(throw new IllegalArgumentException(s"The VAPID $name key is not base64url encoded"))
+
+  private def isKeyPair(publicKey: ECPublicKey, privateKey: ECPrivateKey): Boolean = {
+    val challenge: Array[Byte] = "vapid".getBytes(StandardCharsets.UTF_8)
+    val signer: Signature = Signature.getInstance("SHA256withECDSA")
+    signer.initSign(privateKey)
+    signer.update(challenge)
+    val verifier: Signature = Signature.getInstance("SHA256withECDSA")
+    verifier.initVerify(publicKey)
+    verifier.update(challenge)
+    verifier.verify(signer.sign())
+  }
+}
+
+/**
+ * @param applicationServerKey the public key as push services and browsers expect it: uncompressed, base64url without padding
+ */
+case class VapidConfiguration(applicationServerKey: String,
+                              privateKey: ECPrivateKey,
+                              subject: Option[String]) {
+  // The private key never reaches the logs
+  override def toString: String = s"VapidConfiguration($applicationServerKey, $subject)"
+}
+
+object VapidSigner {
+  // RFC 8292 section 2: a token is valid for 24 hours at most
+  val TOKEN_VALIDITY: Duration = Duration.ofHours(12)
+
+  private case class Token(authorization: String, renewAt: Instant)
+}
+
+class VapidSigner(configuration: VapidConfiguration, clock: Clock) {
+  // RFC 8292 section 2: a token can be reused for the requests to a same origin. Signed again halfway through its validity.
+  private val tokens: ConcurrentHashMap[String, VapidSigner.Token] = new ConcurrentHashMap()
+
+  /**
+   * The `Authorization` header of a request to a push resource: a token signed for the origin of the push service.
+   */
+  def authorization(pushResource: URL): String = {
+    val now: Instant = clock.instant()
+    tokens.compute(origin(pushResource), (audience, token) =>
+      if (token == null || !now.isBefore(token.renewAt)) {
+        VapidSigner.Token(sign(audience, now), now.plus(VapidSigner.TOKEN_VALIDITY.dividedBy(2)))
+      } else {
+        token
+      }).authorization
+  }
+
+  private def sign(audience: String, now: Instant): String = {
+    val token: String = Jwts.builder()
+      .header().`type`("JWT").and()
+      // A single string, not an array: what push services expect
+      .audience().single(audience)
+      .expiration(Date.from(now.plus(VapidSigner.TOKEN_VALIDITY)))
+      .subject(configuration.subject.orNull)
+      .signWith(configuration.privateKey, Jwts.SIG.ES256)
+      .compact()
+    s"vapid t=$token, k=${configuration.applicationServerKey}"
+  }
+
+  // RFC 6454: scheme and host in lower case, the port unless it is the default one
+  private def origin(url: URL): String = {
+    val host: String = url.getHost.toLowerCase(Locale.ROOT)
+    if (url.getPort == -1 || url.getPort == url.getDefaultPort) {
+      s"${url.getProtocol}://$host"
+    } else {
+      s"${url.getProtocol}://$host:${url.getPort}"
+    }
+  }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/WebPushClient.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/WebPushClient.scala
index 01d2ebb843..0272e2fc34 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/WebPushClient.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/WebPushClient.scala
@@ -20,15 +20,15 @@
 package org.apache.james.jmap.pushsubscription
 
 import java.nio.charset.StandardCharsets
-import java.time.Duration
 import java.time.temporal.ChronoUnit
+import java.time.{Clock, Duration}
 
 import io.netty.buffer.Unpooled
 import io.netty.handler.codec.http.HttpResponseStatus
 import jakarta.inject.Inject
 import org.apache.james.jmap.api.model.PushSubscriptionServerURL
 import org.apache.james.jmap.pushsubscription.DefaultWebPushClient.{PUSH_SERVER_ERROR_RESPONSE_MAX_LENGTH, buildHttpClient}
-import org.apache.james.jmap.pushsubscription.WebPushClientHeader.{CONTENT_ENCODING, DEFAULT_TIMEOUT, MESSAGE_URGENCY, TIME_TO_LIVE, TOPIC}
+import org.apache.james.jmap.pushsubscription.WebPushClientHeader.{AUTHORIZATION, CONTENT_ENCODING, DEFAULT_TIMEOUT, MESSAGE_URGENCY, TIME_TO_LIVE, TOPIC}
 import org.reactivestreams.Publisher
 import reactor.core.publisher.Mono
 import reactor.core.scala.publisher.SMono
@@ -49,13 +49,15 @@ object PushClientConfiguration {
 
 case class PushClientConfiguration(maxTimeoutSeconds: Option[Int],
                                    maxConnections: Option[Int],
-                                   preventServerSideRequestForgery: Boolean = true)
+                                   preventServerSideRequestForgery: Boolean = true,
+                                   vapid: Option[VapidConfiguration] = None)
 
 object WebPushClientHeader {
   val TIME_TO_LIVE: String = "TTL"
   val CONTENT_ENCODING: String = "Content-Encoding"
   val MESSAGE_URGENCY: String = "Urgency"
   val TOPIC: String = "Topic"
+  val AUTHORIZATION: String = "Authorization"
   val DEFAULT_TIMEOUT: Duration = Duration.of(30, ChronoUnit.SECONDS)
 }
 
@@ -96,11 +98,17 @@ object DefaultWebPushClient {
   }
 }
 
-class DefaultWebPushClient(configuration: PushClientConfiguration, ssrfValidator: SSRFValidator) extends WebPushClient {
+class DefaultWebPushClient(configuration: PushClientConfiguration, ssrfValidator: SSRFValidator, clock: Clock) extends WebPushClient {
 
   @Inject
+  def this(configuration: PushClientConfiguration, clock: Clock) = this(configuration, new SSRFValidator(), clock)
+
+  def this(configuration: PushClientConfiguration, ssrfValidator: SSRFValidator) = this(configuration, ssrfValidator, Clock.systemUTC())
+
   def this(configuration: PushClientConfiguration) = this(configuration, new SSRFValidator())
 
+  private val vapidSigner: Option[VapidSigner] = configuration.vapid.map(new VapidSigner(_, clock))
+
   val httpClient: HttpClient = buildHttpClient(configuration, ssrfValidator)
 
   override def push(pushServerUrl: PushSubscriptionServerURL, request: PushRequest): Publisher[Unit] =
@@ -111,6 +119,7 @@ class DefaultWebPushClient(configuration: PushClientConfiguration, ssrfValidator
           builder.add(MESSAGE_URGENCY, request.urgency.getOrElse(PushUrgency.default).value)
           request.topic.foreach(t => builder.add(TOPIC, t.value))
           request.contentCoding.foreach(f => builder.add(CONTENT_ENCODING, f.value))
+          vapidSigner.foreach(signer => builder.add(AUTHORIZATION, signer.authorization(url.value)))
         })
         .post()
         .uri(url.value.toString)
diff --git a/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/pushsubscription/DefaultWebPushClientVapidTest.java b/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/pushsubscription/DefaultWebPushClientVapidTest.java
new file mode 100644
index 0000000000..9a3d80e0cc
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/pushsubscription/DefaultWebPushClientVapidTest.java
@@ -0,0 +1,65 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.pushsubscription;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockserver.model.HttpRequest.request;
+
+import org.apache.james.jmap.api.model.PushSubscriptionServerURL;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.HttpRequest;
+
+import reactor.core.publisher.Mono;
+
+class DefaultWebPushClientVapidTest {
+    @RegisterExtension
+    PushServerExtension pushServerExtension = new PushServerExtension();
+
+    private HttpRequest push(DefaultWebPushClient client, ClientAndServer pushServer) {
+        PushSubscriptionServerURL url = PushSubscriptionServerURL.from(pushServerExtension.getBaseUrl() + "/push").get();
+        Mono.from(client.push(url, WebPushClientTestFixture.PUSH_REQUEST_SAMPLE())).block();
+        return pushServer.retrieveRecordedRequests(request().withPath("/push"))[0];
+    }
+
+    @Test
+    void pushShouldBeSignedWhenVapidIsConfigured(ClientAndServer pushServer) {
+        String authorization = push(VapidTestFixture.SIGNING_PUSH_CLIENT(), pushServer).getFirstHeader("Authorization");
+
+        assertThat(authorization).endsWith(", k=" + VapidTestFixture.PUBLIC_KEY());
+        assertThat(VapidTestFixture.payload(authorization, VapidTestFixture.NOW())).contains("\"aud\":\"http://127.0.0.1:" + pushServer.getLocalPort() + "\"");
+        assertThat(VapidTestFixture.subject(authorization)).isEqualTo(VapidTestFixture.SUBJECT());
+    }
+
+    @Test
+    void pushShouldKeepItsOtherHeadersWhenSigned(ClientAndServer pushServer) {
+        HttpRequest pushed = push(VapidTestFixture.SIGNING_PUSH_CLIENT(), pushServer);
+
+        assertThat(pushed.getFirstHeader("TTL")).isEqualTo("15");
+        assertThat(pushed.getFirstHeader("Topic")).isEqualTo("topicabc");
+        assertThat(pushed.getFirstHeader("Urgency")).isEqualTo("high");
+    }
+
+    @Test
+    void pushShouldNotBeSignedWithoutVapid(ClientAndServer pushServer) {
+        assertThat(push(VapidTestFixture.UNSIGNED_PUSH_CLIENT(), pushServer).containsHeader("Authorization")).isFalse();
+    }
+}
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/core/JmapRfc8621ConfigurationTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/core/JmapRfc8621ConfigurationTest.scala
index f8e6aac321..5e43cd9935 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/core/JmapRfc8621ConfigurationTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/core/JmapRfc8621ConfigurationTest.scala
@@ -22,6 +22,7 @@ package org.apache.james.jmap.core
 import com.google.common.collect.ImmutableList
 import org.apache.commons.configuration2.PropertiesConfiguration
 import org.apache.commons.configuration2.convert.DefaultListDelimiterHandler
+import org.apache.james.jmap.pushsubscription.VapidTestFixture
 import org.apache.james.rrt.api.RecipientValidator
 import org.apache.james.rrt.api.RecipientValidator.RecipientRewriteTableCheck
 import org.assertj.core.api.Assertions.{assertThat, assertThatThrownBy}
@@ -62,6 +63,27 @@ class JmapRfc8621ConfigurationTest {
     assertThatThrownBy(() => configurationWith("send.validate.rcpt.recipientRewriteTableCheck" -> "invalid"))
       .hasMessageContaining("unsupported value 'invalid'")
 
+  @Test
+  def vapidShouldBeAbsentByDefault(): Unit =
+    assertThat(configurationWith().vapid.isDefined).isFalse
+
+  @Test
+  def vapidShouldBeReadWhenConfigured(): Unit = {
+    val configuration = configurationWith(
+      "webpush.vapid.public.key" -> VapidTestFixture.PUBLIC_KEY,
+      "webpush.vapid.private.key" -> VapidTestFixture.PRIVATE_KEY,
+      "webpush.vapid.subject" -> "mailto:postmaster@example.com")
+
+    assertThat(configuration.vapid.map(_.applicationServerKey).orNull).isEqualTo(VapidTestFixture.PUBLIC_KEY)
+    assertThat(configuration.webPushConfiguration.vapid.flatMap(_.subject).orNull).isEqualTo("mailto:postmaster@example.com")
+  }
+
+  @Test
+  def shouldThrowWhenOnlyOneVapidKeyIsConfigured(): Unit =
+    assertThatThrownBy(() => configurationWith("webpush.vapid.public.key" -> VapidTestFixture.PUBLIC_KEY))
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("both its public and its private key")
+
   @Test
   def extraValidationsShouldBeEmptyByDefault(): Unit =
     assertThat(configurationWith().extraEmailSubmissionValidations).isEmpty
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/VapidTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/VapidTest.scala
new file mode 100644
index 0000000000..485e8f3142
--- /dev/null
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/VapidTest.scala
@@ -0,0 +1,276 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.pushsubscription
+
+import java.math.BigInteger
+import java.net.URI
+import java.security.interfaces.{ECPrivateKey, ECPublicKey}
+import java.security.spec.ECGenParameterSpec
+import java.security.{KeyPair, KeyPairGenerator}
+import java.time.{Clock, Instant, ZoneId, ZoneOffset}
+import java.util.{Base64, Date}
+
+import io.jsonwebtoken.{Claims, Jws, Jwts}
+import org.apache.james.jmap.pushsubscription.VapidTestFixture.{CONFIGURATION, KEY_PAIR, NOW, PRIVATE_KEY, PUBLIC_KEY, parse}
+import org.assertj.core.api.Assertions.{assertThat, assertThatThrownBy}
+import org.junit.jupiter.api.Test
+
+object VapidTestFixture {
+  val NOW: Instant = Instant.parse("2026-01-01T10:00:00Z")
+  val CLOCK: Clock = Clock.fixed(NOW, ZoneOffset.UTC)
+
+  val KEY_PAIR: KeyPair = generateKeyPair()
+  // As push libraries and browsers handle them: the uncompressed point, and the private value, base64url encoded
+  val PUBLIC_KEY: String = encodePublicKey(KEY_PAIR)
+  val PRIVATE_KEY: String = encodePrivateKey(KEY_PAIR)
+  val SUBJECT: String = "mailto:postmaster@example.com"
+  val CONFIGURATION: VapidConfiguration = VapidConfiguration.from(Some(PUBLIC_KEY), Some(PRIVATE_KEY), Some(SUBJECT)).get
+
+  def generateKeyPair(): KeyPair = {
+    val generator: KeyPairGenerator = KeyPairGenerator.getInstance("EC")
+    generator.initialize(new ECGenParameterSpec("secp256r1"))
+    generator.generateKeyPair()
+  }
+
+  def encodePublicKey(keyPair: KeyPair): String = {
+    val point = keyPair.getPublic.asInstanceOf[ECPublicKey].getW
+    Base64.getUrlEncoder.withoutPadding()
+      .encodeToString(Array[Byte](0x04) ++ coordinate(point.getAffineX) ++ coordinate(point.getAffineY))
+  }
+
+  def encodePrivateKey(keyPair: KeyPair): String =
+    Base64.getUrlEncoder.withoutPadding()
+      .encodeToString(coordinate(keyPair.getPrivate.asInstanceOf[ECPrivateKey].getS))
+
+  private def coordinate(value: BigInteger): Array[Byte] = {
+    val bytes: Array[Byte] = value.toByteArray.dropWhile(_ == 0)
+    Array.fill[Byte](32 - bytes.length)(0) ++ bytes
+  }
+
+  def pushClient(vapid: Option[VapidConfiguration]): DefaultWebPushClient =
+    new DefaultWebPushClient(PushClientConfiguration(
+      maxTimeoutSeconds = Some(10),
+      maxConnections = Some(10),
+      preventServerSideRequestForgery = false,
+      vapid = vapid), new SSRFValidator(), CLOCK)
+
+  val SIGNING_PUSH_CLIENT: DefaultWebPushClient = pushClient(Some(CONFIGURATION))
+  val UNSIGNED_PUSH_CLIENT: DefaultWebPushClient = pushClient(None)
+
+  /**
+   * The claims of the token as the push service reads them: its JSON payload.
+   */
+  def payload(authorization: String, at: Instant = NOW): String = {
+    parse(authorization, at = at)
+    val token: String = authorization.stripPrefix("vapid t=").takeWhile(_ != ',')
+    new String(Base64.getUrlDecoder.decode(token.split('.')(1)), java.nio.charset.StandardCharsets.UTF_8)
+  }
+
+  def subject(authorization: String): String = parse(authorization).getPayload.getSubject
+
+  /**
+   * The token of a `vapid t=<token>, k=<key>` header, verified with the public key of the fixture at the time of the fixture.
+   */
+  def parse(authorization: String, keyPair: KeyPair = KEY_PAIR, at: Instant = NOW): Jws[Claims] = {
+    val token: String = authorization.stripPrefix("vapid t=").takeWhile(_ != ',')
+    Jwts.parser()
+      .verifyWith(keyPair.getPublic)
+      .clock(() => Date.from(at))
+      .build()
+      .parseSignedClaims(token)
+  }
+}
+
+class UpdatableClock(private var current: Instant) extends Clock {
+  def setInstant(value: Instant): Unit = current = value
+
+  override def getZone: ZoneId = ZoneOffset.UTC
+
+  override def withZone(zone: ZoneId): Clock = this
+
+  override def instant(): Instant = current
+}
+
+class VapidTest {
+  private val signer: VapidSigner = new VapidSigner(CONFIGURATION, VapidTestFixture.CLOCK)
+
+  @Test
+  def authorizationShouldFollowTheVapidScheme(): Unit =
+    assertThat(signer.authorization(new URI("https://push.example.com/send/abc").toURL))
+      .matches("vapid t=[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+, k=" + PUBLIC_KEY)
+
+  @Test
+  def tokenShouldBeSignedByThePrivateKey(): Unit = {
+    val token = parse(signer.authorization(new URI("https://push.example.com/send/abc").toURL))
+
+    assertThat(token.getHeader.getAlgorithm).isEqualTo("ES256")
+    assertThat(token.getHeader.getType).isEqualTo("JWT")
+  }
+
+  @Test
+  def tokenShouldNotBeVerifiedByAnotherKey(): Unit =
+    assertThatThrownBy(() => parse(signer.authorization(new URI("https://push.example.com/send/abc").toURL), VapidTestFixture.generateKeyPair()))
+      .isInstanceOf(classOf[io.jsonwebtoken.security.SignatureException])
+
+  @Test
+  def audienceShouldBeTheOriginOfThePushResource(): Unit = {
+    assertThat(VapidTestFixture.payload(signer.authorization(new URI("https://push.example.com/send/abc?d=1").toURL)))
+      .contains("\"aud\":\"https://push.example.com\"")
+  }
+
+  @Test
+  def audienceShouldKeepANonDefaultPort(): Unit = {
+    assertThat(VapidTestFixture.payload(signer.authorization(new URI("https://push.example.com:8443/send/abc").toURL)))
+      .contains("\"aud\":\"https://push.example.com:8443\"")
+  }
+
+  @Test
+  def audienceShouldOmitTheDefaultPort(): Unit = {
+    assertThat(VapidTestFixture.payload(signer.authorization(new URI("https://push.example.com:443/send/abc").toURL)))
+      .contains("\"aud\":\"https://push.example.com\"")
+  }
+
+  @Test
+  def audienceShouldOmitTheDefaultPortOfHttp(): Unit =
+    assertThat(VapidTestFixture.payload(signer.authorization(new URI("http://push.example.com:80/send/abc").toURL)))
+      .contains("\"aud\":\"http://push.example.com\"")
+
+  @Test
+  def audienceShouldBeInLowerCase(): Unit =
+    assertThat(VapidTestFixture.payload(signer.authorization(new URI("https://Push.Example.COM/send/abc").toURL)))
+      .contains("\"aud\":\"https://push.example.com\"")
+
+  @Test
+  def tokenShouldBeReusedForTheSameOrigin(): Unit = {
+    val clock = new UpdatableClock(NOW)
+    val signer = new VapidSigner(CONFIGURATION, clock)
+    val first = signer.authorization(new URI("https://push.example.com/send/abc").toURL)
+
+    clock.setInstant(NOW.plus(VapidSigner.TOKEN_VALIDITY.dividedBy(2)).minusSeconds(1))
+
+    assertThat(signer.authorization(new URI("https://push.example.com/send/def").toURL)).isEqualTo(first)
+    assertThat(signer.authorization(new URI("https://other.example.com/send/abc").toURL)).isNotEqualTo(first)
+  }
+
+  @Test
+  def tokenShouldBeSignedAgainHalfwayThroughItsValidity(): Unit = {
+    val clock = new UpdatableClock(NOW)
+    val signer = new VapidSigner(CONFIGURATION, clock)
+    val first = signer.authorization(new URI("https://push.example.com/send/abc").toURL)
+
+    clock.setInstant(NOW.plus(VapidSigner.TOKEN_VALIDITY.dividedBy(2)))
+    val second = signer.authorization(new URI("https://push.example.com/send/abc").toURL)
+
+    assertThat(second).isNotEqualTo(first)
+    assertThat(VapidTestFixture.payload(second, NOW.plus(VapidSigner.TOKEN_VALIDITY.dividedBy(2))))
+      .contains("\"aud\":\"https://push.example.com\"")
+  }
+
+  @Test
+  def tokenShouldExpireWithinADay(): Unit = {
+    val token = parse(signer.authorization(new URI("https://push.example.com/send/abc").toURL))
+
+    val expiration: Instant = token.getPayload.getExpiration.toInstant
+
+    assertThat(expiration.isAfter(NOW)).isTrue
+    assertThat(expiration.isAfter(NOW.plusSeconds(24 * 60 * 60))).isFalse
+  }
+
+  @Test
+  def tokenShouldCarryTheSubject(): Unit = {
+    val token = parse(signer.authorization(new URI("https://push.example.com/send/abc").toURL))
+
+    assertThat(token.getPayload.getSubject).isEqualTo(VapidTestFixture.SUBJECT)
+  }
+
+  @Test
+  def subjectShouldBeOptional(): Unit = {
+    val withoutSubject = VapidConfiguration.from(Some(PUBLIC_KEY), Some(PRIVATE_KEY), None).get
+    val token = parse(new VapidSigner(withoutSubject, VapidTestFixture.CLOCK).authorization(new URI("https://push.example.com/send/abc").toURL))
+
+    assertThat(token.getPayload.getSubject).isNull()
+  }
+
+  @Test
+  def configurationShouldBeAbsentWithoutKeys(): Unit =
+    assertThat(VapidConfiguration.from(None, None, Some(VapidTestFixture.SUBJECT)).isDefined).isFalse
+
+  @Test
+  def configurationShouldTreatBlankKeysAsAbsent(): Unit =
+    assertThat(VapidConfiguration.from(Some(" "), Some(""), None).isDefined).isFalse
+
+  @Test
+  def applicationServerKeyShouldBeUnpadded(): Unit =
+    assertThat(VapidConfiguration.from(Some(PUBLIC_KEY + "="), Some(PRIVATE_KEY), None).get.applicationServerKey)
+      .isEqualTo(PUBLIC_KEY)
+
+  @Test
+  def configurationShouldRejectASingleKey(): Unit = {
+    assertThatThrownBy(() => VapidConfiguration.from(Some(PUBLIC_KEY), None, None))
+      .isInstanceOf(classOf[IllegalArgumentException])
+    assertThatThrownBy(() => VapidConfiguration.from(None, Some(PRIVATE_KEY), None))
+      .isInstanceOf(classOf[IllegalArgumentException])
+  }
+
+  @Test
+  def configurationShouldRejectAPublicKeyOfAnotherPrivateKey(): Unit =
+    assertThatThrownBy(() => VapidConfiguration.from(Some(VapidTestFixture.encodePublicKey(VapidTestFixture.generateKeyPair())), Some(PRIVATE_KEY), None))
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("does not match")
+
+  @Test
+  def configurationShouldRejectAPublicKeyThatIsNotAnUncompressedPoint(): Unit =
+    assertThatThrownBy(() => VapidConfiguration.from(Some(PRIVATE_KEY), Some(PRIVATE_KEY), None))
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("uncompressed P-256 point")
+
+  @Test
+  def configurationShouldRejectAPublicKeyThatIsNotOnTheCurve(): Unit = {
+    val notAPoint: String = Base64.getUrlEncoder.withoutPadding().encodeToString(Array[Byte](0x04) ++ Array.fill[Byte](64)(1))
+
+    assertThatThrownBy(() => VapidConfiguration.from(Some(notAPoint), Some(PRIVATE_KEY), None))
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("not a point of the P-256 curve")
+  }
+
+  @Test
+  def configurationShouldRejectAPrivateKeyOfTheWrongLength(): Unit =
+    assertThatThrownBy(() => VapidConfiguration.from(Some(PUBLIC_KEY), Some(PUBLIC_KEY), None))
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("32 bytes")
+
+  @Test
+  def configurationShouldRejectKeysThatAreNotBase64Url(): Unit =
+    assertThatThrownBy(() => VapidConfiguration.from(Some("not base64 !"), Some(PRIVATE_KEY), None))
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("base64url")
+
+  @Test
+  def configurationShouldRejectASubjectThatIsNotAContactUri(): Unit =
+    assertThatThrownBy(() => VapidConfiguration.from(Some(PUBLIC_KEY), Some(PRIVATE_KEY), Some("postmaster@example.com")))
+      .isInstanceOf(classOf[IllegalArgumentException])
+      .hasMessageContaining("mailto:")
+
+  @Test
+  def toStringShouldNotLeakThePrivateKey(): Unit =
+    assertThat(CONFIGURATION.toString)
+      .doesNotContain(PRIVATE_KEY)
+      .doesNotContain(KEY_PAIR.getPrivate.asInstanceOf[ECPrivateKey].getS.toString)
+}
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/routes/SessionSupplierTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/routes/SessionSupplierTest.scala
index e59bc2ce44..41f04cd3bc 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/routes/SessionSupplierTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/routes/SessionSupplierTest.scala
@@ -20,7 +20,9 @@
 package org.apache.james.jmap.routes
 
 import org.apache.james.core.Username
-import org.apache.james.jmap.core.{DefaultCapabilities, JmapRfc8621Configuration}
+import org.apache.james.jmap.core.CapabilityIdentifier.JMAP_WEBPUSH_VAPID
+import org.apache.james.jmap.core.{DefaultCapabilities, JmapRfc8621Configuration, WebPushVapidCapabilityProperties}
+import org.apache.james.jmap.pushsubscription.VapidTestFixture
 import org.apache.james.jmap.routes.SessionSupplierTest.USERNAME
 import org.scalatest.matchers.should.Matchers
 import org.scalatest.wordspec.AnyWordSpec
@@ -37,6 +39,30 @@ class SessionSupplierTest extends AnyWordSpec with Matchers {
         .generate(USERNAME, Set(), JmapRfc8621Configuration.LOCALHOST_CONFIGURATION.urlPrefixes()).blockOption().get.username should equal(USERNAME)
     }
 
+    "not advertise VAPID when no key pair is configured" in {
+      new SessionSupplier(DefaultCapabilities.supported(JmapRfc8621Configuration.LOCALHOST_CONFIGURATION), JmapRfc8621Configuration.LOCALHOST_CONFIGURATION)
+        .generate(USERNAME, Set(), JmapRfc8621Configuration.LOCALHOST_CONFIGURATION.urlPrefixes()).blockOption().get
+        .capabilities.ids should not contain JMAP_WEBPUSH_VAPID
+    }
+
+    "advertise the VAPID application server key when a key pair is configured" in {
+      val configuration = JmapRfc8621Configuration.LOCALHOST_CONFIGURATION.copy(vapid = Some(VapidTestFixture.CONFIGURATION))
+
+      new SessionSupplier(DefaultCapabilities.supported(configuration), configuration)
+        .generate(USERNAME, Set(), configuration.urlPrefixes()).blockOption().get
+        .capabilities.capabilities
+        .filter(_.identifier() == JMAP_WEBPUSH_VAPID)
+        .map(_.properties()) should equal(Set(WebPushVapidCapabilityProperties(VapidTestFixture.PUBLIC_KEY)))
+    }
+
+    "not advertise VAPID when Web Push is disabled" in {
+      val configuration = JmapRfc8621Configuration.LOCALHOST_CONFIGURATION.copy(vapid = Some(VapidTestFixture.CONFIGURATION), webPushEnabled = false)
+
+      new SessionSupplier(DefaultCapabilities.supported(configuration), configuration)
+        .generate(USERNAME, Set(), configuration.urlPrefixes()).blockOption().get
+        .capabilities.ids should not contain JMAP_WEBPUSH_VAPID
+    }
+
     "return correct account" which {
       val accounts = new SessionSupplier(DefaultCapabilities.supported(JmapRfc8621Configuration.LOCALHOST_CONFIGURATION), JmapRfc8621Configuration.LOCALHOST_CONFIGURATION)
         .generate(USERNAME, Set(), JmapRfc8621Configuration.LOCALHOST_CONFIGURATION.urlPrefixes()).blockOption().get.accounts


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


(james-project) 06/08: fixup! [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)

Posted by bt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 5a7c2fd7aa6c53ff03f4fa9f2705712fa2f9a317
Author: Clement Guyon <cg...@linagora.com>
AuthorDate: Mon Oct 12 23:49:30 2026 +0200

    fixup! [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)
    
    Sign a token for each push instead of caching one per origin, and warn
    upon start when no subject is configured.
---
 .../apache/james/jmap/pushsubscription/Vapid.scala | 27 +++++----------
 .../james/jmap/pushsubscription/VapidTest.scala    | 38 +---------------------
 2 files changed, 9 insertions(+), 56 deletions(-)

diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/Vapid.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/Vapid.scala
index 9c6d485785..56ca4edbac 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/Vapid.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/Vapid.scala
@@ -25,14 +25,14 @@ import java.nio.charset.StandardCharsets
 import java.security.interfaces.{ECPrivateKey, ECPublicKey}
 import java.security.spec.ECPrivateKeySpec
 import java.security.{KeyFactory, Signature}
-import java.time.{Clock, Duration, Instant}
-import java.util.concurrent.ConcurrentHashMap
+import java.time.{Clock, Duration}
 import java.util.{Base64, Date, Locale}
 
 import com.google.common.base.Preconditions
 import com.google.crypto.tink.subtle.EllipticCurves
 import com.google.crypto.tink.subtle.EllipticCurves.{CurveType, PointFormatType}
 import io.jsonwebtoken.Jwts
+import org.slf4j.{Logger, LoggerFactory}
 
 import scala.util.Try
 
@@ -41,6 +41,7 @@ import scala.util.Try
  * as JMAP servers advertise it (RFC 9749).
  */
 object VapidConfiguration {
+  private val LOGGER: Logger = LoggerFactory.getLogger(classOf[VapidConfiguration])
   private val UNCOMPRESSED_POINT_LENGTH: Int = 65
   private val UNCOMPRESSED_POINT_TAG: Byte = 0x04
   private val COORDINATE_LENGTH: Int = 32
@@ -66,6 +67,9 @@ object VapidConfiguration {
       "The VAPID private key must be a P-256 private key (32 bytes), base64url encoded".asInstanceOf[Object])
     subject.foreach(value => Preconditions.checkArgument(value.startsWith("mailto:") || value.startsWith("https:"),
       "The VAPID subject must be a `mailto:` or an `https:` URI".asInstanceOf[Object]))
+    if (subject.isEmpty) {
+      LOGGER.warn("VAPID is configured without a subject (`webpush.vapid.subject`): some push services, as the one of Apple, refuse such requests")
+    }
 
     // Checks that the point is on the curve, which the JDK key factory does not
     val ecPublicKey: ECPublicKey = Try(EllipticCurves.getEcPublicKey(CurveType.NIST_P256, PointFormatType.UNCOMPRESSED, point))
@@ -110,33 +114,18 @@ case class VapidConfiguration(applicationServerKey: String,
 object VapidSigner {
   // RFC 8292 section 2: a token is valid for 24 hours at most
   val TOKEN_VALIDITY: Duration = Duration.ofHours(12)
-
-  private case class Token(authorization: String, renewAt: Instant)
 }
 
 class VapidSigner(configuration: VapidConfiguration, clock: Clock) {
-  // RFC 8292 section 2: a token can be reused for the requests to a same origin. Signed again halfway through its validity.
-  private val tokens: ConcurrentHashMap[String, VapidSigner.Token] = new ConcurrentHashMap()
-
   /**
    * The `Authorization` header of a request to a push resource: a token signed for the origin of the push service.
    */
   def authorization(pushResource: URL): String = {
-    val now: Instant = clock.instant()
-    tokens.compute(origin(pushResource), (audience, token) =>
-      if (token == null || !now.isBefore(token.renewAt)) {
-        VapidSigner.Token(sign(audience, now), now.plus(VapidSigner.TOKEN_VALIDITY.dividedBy(2)))
-      } else {
-        token
-      }).authorization
-  }
-
-  private def sign(audience: String, now: Instant): String = {
     val token: String = Jwts.builder()
       .header().`type`("JWT").and()
       // A single string, not an array: what push services expect
-      .audience().single(audience)
-      .expiration(Date.from(now.plus(VapidSigner.TOKEN_VALIDITY)))
+      .audience().single(origin(pushResource))
+      .expiration(Date.from(clock.instant().plus(VapidSigner.TOKEN_VALIDITY)))
       .subject(configuration.subject.orNull)
       .signWith(configuration.privateKey, Jwts.SIG.ES256)
       .compact()
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/VapidTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/VapidTest.scala
index 485e8f3142..62ff9fe6ea 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/VapidTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/VapidTest.scala
@@ -24,7 +24,7 @@ import java.net.URI
 import java.security.interfaces.{ECPrivateKey, ECPublicKey}
 import java.security.spec.ECGenParameterSpec
 import java.security.{KeyPair, KeyPairGenerator}
-import java.time.{Clock, Instant, ZoneId, ZoneOffset}
+import java.time.{Clock, Instant, ZoneOffset}
 import java.util.{Base64, Date}
 
 import io.jsonwebtoken.{Claims, Jws, Jwts}
@@ -98,16 +98,6 @@ object VapidTestFixture {
   }
 }
 
-class UpdatableClock(private var current: Instant) extends Clock {
-  def setInstant(value: Instant): Unit = current = value
-
-  override def getZone: ZoneId = ZoneOffset.UTC
-
-  override def withZone(zone: ZoneId): Clock = this
-
-  override def instant(): Instant = current
-}
-
 class VapidTest {
   private val signer: VapidSigner = new VapidSigner(CONFIGURATION, VapidTestFixture.CLOCK)
 
@@ -157,32 +147,6 @@ class VapidTest {
     assertThat(VapidTestFixture.payload(signer.authorization(new URI("https://Push.Example.COM/send/abc").toURL)))
       .contains("\"aud\":\"https://push.example.com\"")
 
-  @Test
-  def tokenShouldBeReusedForTheSameOrigin(): Unit = {
-    val clock = new UpdatableClock(NOW)
-    val signer = new VapidSigner(CONFIGURATION, clock)
-    val first = signer.authorization(new URI("https://push.example.com/send/abc").toURL)
-
-    clock.setInstant(NOW.plus(VapidSigner.TOKEN_VALIDITY.dividedBy(2)).minusSeconds(1))
-
-    assertThat(signer.authorization(new URI("https://push.example.com/send/def").toURL)).isEqualTo(first)
-    assertThat(signer.authorization(new URI("https://other.example.com/send/abc").toURL)).isNotEqualTo(first)
-  }
-
-  @Test
-  def tokenShouldBeSignedAgainHalfwayThroughItsValidity(): Unit = {
-    val clock = new UpdatableClock(NOW)
-    val signer = new VapidSigner(CONFIGURATION, clock)
-    val first = signer.authorization(new URI("https://push.example.com/send/abc").toURL)
-
-    clock.setInstant(NOW.plus(VapidSigner.TOKEN_VALIDITY.dividedBy(2)))
-    val second = signer.authorization(new URI("https://push.example.com/send/abc").toURL)
-
-    assertThat(second).isNotEqualTo(first)
-    assertThat(VapidTestFixture.payload(second, NOW.plus(VapidSigner.TOKEN_VALIDITY.dividedBy(2))))
-      .contains("\"aud\":\"https://push.example.com\"")
-  }
-
   @Test
   def tokenShouldExpireWithinADay(): Unit = {
     val token = parse(signer.authorization(new URI("https://push.example.com/send/abc").toURL))


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


(james-project) 08/08: fixup! [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)

Posted by bt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 0888cddf267fd26c05cbd24be9b99665138d059b
Author: Clement Guyon <cg...@linagora.com>
AuthorDate: Tue Oct 13 11:39:28 2026 +0200

    fixup! [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)
    
    Derive the session state from the VAPID key (RFC-9749 section 5), so that
    clients watching the session subscribe again after a change of key.
---
 docs/modules/servers/partials/configure/jmap.adoc  |  9 +++---
 .../rfc8621/contract/WebPushVapidContract.scala    | 32 +++++++++++++++++++++-
 .../org/apache/james/jmap/core/Capability.scala    |  3 +-
 .../james/jmap/core/JmapRfc8621Configuration.scala | 11 +++++++-
 .../org/apache/james/jmap/routes/JmapApi.scala     |  2 +-
 .../apache/james/jmap/routes/SessionSupplier.scala |  3 +-
 .../james/jmap/routes/SessionSupplierTest.scala    | 29 +++++++++++++++++++-
 7 files changed, 78 insertions(+), 11 deletions(-)

diff --git a/docs/modules/servers/partials/configure/jmap.adoc b/docs/modules/servers/partials/configure/jmap.adoc
index 03b0d1bd38..1fd51d8151 100644
--- a/docs/modules/servers/partials/configure/jmap.adoc
+++ b/docs/modules/servers/partials/configure/jmap.adoc
@@ -120,8 +120,8 @@ encoded, as Web Push tools generate it. When set, with `webpush.vapid.private.ke
 authenticated with it, and the JMAP session advertises it as the `applicationServerKey` of the
 `urn:ietf:params:jmap:webpush-vapid` capability (https://www.rfc-editor.org/rfc/rfc9749[RFC-9749]), which browsers need
 to subscribe. Defaults to no VAPID: requests are not authenticated and the capability is not advertised.
-The key pair is meant to stay: key rotation (RFC-9749 section 5) is not implemented. After a change, push services
-refuse the pushes of the subscriptions created with the former key, until clients subscribe again.
+The key pair is meant to stay. After a change the session state changes, which tells clients to subscribe again
+(RFC-9749 section 5), but push services refuse the pushes of the subscriptions created with the former key until they do.
 
 | webpush.vapid.private.key
 | Optional string. Private key matching `webpush.vapid.public.key`: a P-256 private key (32 bytes), base64url encoded.
@@ -297,8 +297,9 @@ copied. The {server-name} refuses to start when the two keys do not match.
 as `mailto:postmaster@domain.tld`, or the `https:` URL of a contact page. It is sent to the push services only, never
 to the users.
  - Use the same key pair on every node of a cluster, and keep it: subscriptions are bound to the key they were created
-with. Key rotation (RFC-9749 section 5) is not implemented, hence after a change push services refuse the pushes of
-existing subscriptions until clients subscribe again.
+with. After a change of key the session state changes, which tells clients to subscribe again (RFC-9749 section 5).
+There is no transitional period: the former key no longer signs, hence push services refuse the pushes of existing
+subscriptions until clients subscribe again, and these subscriptions are kept until they expire.
 
 == Annotated specification
 
diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushVapidContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushVapidContract.scala
index 69c1e53651..98f2d39663 100644
--- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushVapidContract.scala
+++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/WebPushVapidContract.scala
@@ -23,8 +23,8 @@ import java.math.BigInteger
 import java.nio.charset.StandardCharsets
 import java.security.KeyPair
 import java.security.interfaces.{ECPrivateKey, ECPublicKey}
-import java.util.Base64
 import java.util.concurrent.TimeUnit
+import java.util.{Base64, UUID}
 
 import com.google.crypto.tink.apps.webpush.WebPushHybridDecrypt
 import com.google.crypto.tink.subtle.EllipticCurves.{CurveType, EcdsaEncoding, PointFormatType}
@@ -158,6 +158,36 @@ trait WebPushVapidContract {
     assertThat(applicationServerKey).isEqualTo(APPLICATION_SERVER_KEY)
   }
 
+  @Test
+  def sessionStateShouldDependOnTheApplicationServerKey(): Unit = {
+    val sessionState: String = `given`()
+    .when()
+      .get("/session")
+    .`then`
+      .statusCode(SC_OK)
+      .extract()
+      .jsonPath()
+      .get("state")
+
+    val responseSessionState: String = `given`
+      .body(
+        """{
+          |  "using": ["urn:ietf:params:jmap:core"],
+          |  "methodCalls": [["Core/echo", {}, "c1"]]
+          |}""".stripMargin)
+    .when
+      .post
+    .`then`
+      .statusCode(SC_OK)
+      .extract()
+      .jsonPath()
+      .get("sessionState")
+
+    assertThat(sessionState)
+      .isEqualTo(UUID.nameUUIDFromBytes(APPLICATION_SERVER_KEY.getBytes(StandardCharsets.UTF_8)).toString)
+    assertThat(responseSessionState).isEqualTo(sessionState)
+  }
+
   @Test
   def pushVerificationShouldBeAuthenticated(pushServer: ClientAndServer): Unit = {
     createPushSubscription(pushServer)
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala
index 363a791a29..84c4331cb4 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/Capability.scala
@@ -151,8 +151,7 @@ case class WebPushVapidCapabilityFactory(configuration: JmapRfc8621Configuration
   override def createReactive(urlPrefixes: UrlPrefixes, username: Username): Publisher[Capability] =
     capability().map(SMono.just[Capability]).getOrElse(SMono.empty[Capability])
 
-  private def capability(): Option[Capability] = configuration.vapid
-    .filter(_ => configuration.webPushEnabled)
+  private def capability(): Option[Capability] = configuration.advertisedVapid
     .map(vapid => WebPushVapidCapability(WebPushVapidCapabilityProperties(vapid.applicationServerKey)))
 }
 
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
index a20ca85a25..886459fb8e 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/core/JmapRfc8621Configuration.scala
@@ -20,8 +20,9 @@
 package org.apache.james.jmap.core
 
 import java.net.URI
+import java.nio.charset.StandardCharsets
 import java.time.temporal.ChronoUnit
-import java.util.Optional
+import java.util.{Optional, UUID}
 
 import com.google.common.base.Preconditions
 import com.google.common.collect.ImmutableList
@@ -160,6 +161,14 @@ case class JmapRfc8621Configuration(urlPrefixString: String,
     preventServerSideRequestForgery = preventServerSideRequestForgery,
     vapid = vapid)
 
+  // The VAPID key clients subscribe with: none when Web Push is disabled
+  val advertisedVapid: Option[VapidConfiguration] = vapid.filter(_ => webPushEnabled)
+
+  // RFC 9749 section 5: the session state changes with the VAPID key, which tells clients to subscribe again
+  val sessionState: UuidState = advertisedVapid
+    .map(vapid => UuidState(UUID.nameUUIDFromBytes(vapid.applicationServerKey.getBytes(StandardCharsets.UTF_8))))
+    .getOrElse(UuidState.INSTANCE)
+
   def urlPrefixes(): UrlPrefixes = UrlPrefixes(new URI(urlPrefixString), new URI(websocketPrefixString))
 
   def getAuthenticationStrategiesAsJava(): Optional[java.util.List[String]] = authenticationStrategies.toJava
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JmapApi.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JmapApi.scala
index 54ce755f7a..f2251c7af0 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JmapApi.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/JmapApi.scala
@@ -53,7 +53,7 @@ class JMAPApi (methods: Set[Method], defaultCapabilities: Set[CapabilityIdentifi
       _ <- validateMaxCalls(requestObject)
     } yield {
       processSequentiallyAndUpdateContext(requestObject, mailboxSession, processingContext, capabilities)
-        .map(invocations => ResponseObject(ResponseObject.SESSION_STATE, invocations.map(_.invocation)))
+        .map(invocations => ResponseObject(configuration.sessionState, invocations.map(_.invocation)))
     }
     validatedRequestObject.fold(e => SMono.error(e), obj => obj)
   }
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/SessionSupplier.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/SessionSupplier.scala
index 0209004c79..df48fa5bda 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/SessionSupplier.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/SessionSupplier.scala
@@ -57,7 +57,8 @@ class SessionSupplier(capabilityFactories: Set[CapabilityFactory], configuration
         apiUrl = urlEndpointResolver.apiUrl,
         downloadUrl = urlEndpointResolver.downloadUrl,
         uploadUrl = urlEndpointResolver.uploadUrl,
-        eventSourceUrl = urlEndpointResolver.eventSourceUrl)
+        eventSourceUrl = urlEndpointResolver.eventSourceUrl,
+        state = configuration.sessionState)
     }
   }
 
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/routes/SessionSupplierTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/routes/SessionSupplierTest.scala
index 41f04cd3bc..46f66c1a1d 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/routes/SessionSupplierTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/routes/SessionSupplierTest.scala
@@ -21,7 +21,7 @@ package org.apache.james.jmap.routes
 
 import org.apache.james.core.Username
 import org.apache.james.jmap.core.CapabilityIdentifier.JMAP_WEBPUSH_VAPID
-import org.apache.james.jmap.core.{DefaultCapabilities, JmapRfc8621Configuration, WebPushVapidCapabilityProperties}
+import org.apache.james.jmap.core.{DefaultCapabilities, JmapRfc8621Configuration, UuidState, WebPushVapidCapabilityProperties}
 import org.apache.james.jmap.pushsubscription.VapidTestFixture
 import org.apache.james.jmap.routes.SessionSupplierTest.USERNAME
 import org.scalatest.matchers.should.Matchers
@@ -63,6 +63,33 @@ class SessionSupplierTest extends AnyWordSpec with Matchers {
         .capabilities.ids should not contain JMAP_WEBPUSH_VAPID
     }
 
+    "keep the session state when no key pair is configured" in {
+      new SessionSupplier(DefaultCapabilities.supported(JmapRfc8621Configuration.LOCALHOST_CONFIGURATION), JmapRfc8621Configuration.LOCALHOST_CONFIGURATION)
+        .generate(USERNAME, Set(), JmapRfc8621Configuration.LOCALHOST_CONFIGURATION.urlPrefixes()).blockOption().get
+        .state should equal(UuidState.INSTANCE)
+    }
+
+    "keep the session state when Web Push is disabled" in {
+      val configuration = JmapRfc8621Configuration.LOCALHOST_CONFIGURATION.copy(vapid = Some(VapidTestFixture.CONFIGURATION), webPushEnabled = false)
+
+      new SessionSupplier(DefaultCapabilities.supported(configuration), configuration)
+        .generate(USERNAME, Set(), configuration.urlPrefixes()).blockOption().get
+        .state should equal(UuidState.INSTANCE)
+    }
+
+    "change the session state with the VAPID key" in {
+      val configuration = JmapRfc8621Configuration.LOCALHOST_CONFIGURATION.copy(vapid = Some(VapidTestFixture.CONFIGURATION))
+      val rotated = configuration.copy(vapid = Some(VapidTestFixture.CONFIGURATION.copy(applicationServerKey = "another key")))
+
+      def state(configuration: JmapRfc8621Configuration): UuidState =
+        new SessionSupplier(DefaultCapabilities.supported(configuration), configuration)
+          .generate(USERNAME, Set(), configuration.urlPrefixes()).blockOption().get.state
+
+      state(configuration) should not equal UuidState.INSTANCE
+      state(configuration) should equal(state(configuration))
+      state(rotated) should not equal state(configuration)
+    }
+
     "return correct account" which {
       val accounts = new SessionSupplier(DefaultCapabilities.supported(JmapRfc8621Configuration.LOCALHOST_CONFIGURATION), JmapRfc8621Configuration.LOCALHOST_CONFIGURATION)
         .generate(USERNAME, Set(), JmapRfc8621Configuration.LOCALHOST_CONFIGURATION.urlPrefixes()).blockOption().get.accounts


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


(james-project) 07/08: fixup! [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)

Posted by bt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 1293a61230158aea665c152c4dd94cd4064f1e68
Author: Clement Guyon <cg...@linagora.com>
AuthorDate: Mon Oct 12 23:49:30 2026 +0200

    fixup! [ENHANCEMENT] JMAP Web Push: VAPID (RFC-9749)
    
    Document how to generate the VAPID key pair and what to use as subject.
---
 docs/modules/servers/partials/configure/jmap.adoc  | 38 +++++++++++++++++++++-
 .../sample-configuration/jmap.properties           |  6 ++--
 .../sample-configuration/jmap.properties           |  6 ++--
 .../sample-configuration/jmap.properties           |  6 ++--
 4 files changed, 49 insertions(+), 7 deletions(-)

diff --git a/docs/modules/servers/partials/configure/jmap.adoc b/docs/modules/servers/partials/configure/jmap.adoc
index 190305a2ef..03b0d1bd38 100644
--- a/docs/modules/servers/partials/configure/jmap.adoc
+++ b/docs/modules/servers/partials/configure/jmap.adoc
@@ -129,7 +129,9 @@ Required when the public key is set. Keep it secret.
 
 | webpush.vapid.subject
 | Optional string. Contact of the operator of this server, sent to the push services as the `sub` claim: a `mailto:`
-or an `https:` URI. Recommended when VAPID is configured, as some push services refuse requests without it.
+or an `https:` URI. Set it whenever VAPID is configured: the push service of Apple (`web.push.apple.com`) refuses
+requests without it, and the {server-name} logs a warning upon start when it is missing.
+See <<_generating_vapid_cryptographic_material,Generating VAPID cryptographic material>>.
 
 | cassandra.filter.projection.activated
 |Optional boolean. Defaults to false. Casandra backends only. Whether to use or not the Cassandra projection
@@ -264,6 +266,40 @@ This token can then be passed as `Bearer` of the `Authorization` header :
 
 The public key can be referenced as `jwt.publickeypem.url` of the `jmap.properties` configuration file.
 
+== Generating VAPID cryptographic material
+
+Browsers only subscribe to Web Push with the public key of the server, and their push services refuse the pushes that
+are not signed with the matching private key (VAPID, link:https://www.rfc-editor.org/rfc/rfc8292[RFC-8292]). The
+{server-name} needs one P-256 key pair for this, set in `jmap.properties`. It advertises the public key in the JMAP
+session, so that clients need no configuration (link:https://www.rfc-editor.org/rfc/rfc9749[RFC-9749]).
+
+One can use OpenSSL to generate the key pair, then print each key in the expected form, base64url without padding:
+
+    # key pair
+    openssl ecparam -name prime256v1 -genkey -noout -out vapid.pem
+    # webpush.vapid.private.key: the private value, 32 bytes
+    openssl ec -in vapid.pem -outform DER 2>/dev/null | tail -c +8 | head -c 32 | base64 | tr '+/' '-_' | tr -d '=\n'
+    # webpush.vapid.public.key: the public key as an uncompressed point, 65 bytes
+    openssl ec -in vapid.pem -pubout -outform DER 2>/dev/null | tail -c 65 | base64 | tr '+/' '-_' | tr -d '=\n'
+
+The keys are 43 and 87 characters long. Tools that generate VAPID keys for Web Push, as
+`npx web-push generate-vapid-keys`, print them in this same form.
+
+    webpush.vapid.public.key=<the public key>
+    webpush.vapid.private.key=<the private key>
+    webpush.vapid.subject=mailto:postmaster@domain.tld
+
+Regarding these values:
+
+ - The private key is a secret: restrict the access to `jmap.properties`, and delete `vapid.pem` once the keys are
+copied. The {server-name} refuses to start when the two keys do not match.
+ - The subject tells the push services whom to contact about the pushes of this server: use an address that is read,
+as `mailto:postmaster@domain.tld`, or the `https:` URL of a contact page. It is sent to the push services only, never
+to the users.
+ - Use the same key pair on every node of a cluster, and keep it: subscriptions are bound to the key they were created
+with. Key rotation (RFC-9749 section 5) is not implemented, hence after a change push services refuse the pushes of
+existing subscriptions until clients subscribe again.
+
 == Annotated specification
 
 The [annotated documentation](https://github.com/apache/james-project/tree/master/server/protocols/jmap-rfc-8621/doc/specs/spec)
diff --git a/server/apps/distributed-app/sample-configuration/jmap.properties b/server/apps/distributed-app/sample-configuration/jmap.properties
index a92f58ac46..4ecb40a262 100644
--- a/server/apps/distributed-app/sample-configuration/jmap.properties
+++ b/server/apps/distributed-app/sample-configuration/jmap.properties
@@ -59,8 +59,10 @@ tls.secret=james72laBalle
 
 # VAPID (RFC-8292, RFC-9749): identifies this server to the push services, which browsers require to subscribe.
 # A P-256 key pair, base64url encoded as Web Push tools generate it: the public key in its uncompressed form (65 bytes),
-# the private key (32 bytes). Defaults to no VAPID.
+# the private key (32 bytes). Defaults to no VAPID. See "Generating VAPID cryptographic material" in the JMAP
+# configuration documentation.
 # webpush.vapid.public.key=
 # webpush.vapid.private.key=
-# Contact of the operator, a mailto: or an https: URI. Recommended: some push services refuse requests without it.
+# Contact of the operator, a mailto: or an https: URI. Set it with the keys: the push service of Apple refuses
+# requests without it.
 # webpush.vapid.subject=mailto:postmaster@example.com
\ No newline at end of file
diff --git a/server/apps/memory-app/sample-configuration/jmap.properties b/server/apps/memory-app/sample-configuration/jmap.properties
index 720bdb80af..ac4f143c45 100644
--- a/server/apps/memory-app/sample-configuration/jmap.properties
+++ b/server/apps/memory-app/sample-configuration/jmap.properties
@@ -56,8 +56,10 @@ delay.sends.enabled=true
 
 # VAPID (RFC-8292, RFC-9749): identifies this server to the push services, which browsers require to subscribe.
 # A P-256 key pair, base64url encoded as Web Push tools generate it: the public key in its uncompressed form (65 bytes),
-# the private key (32 bytes). Defaults to no VAPID.
+# the private key (32 bytes). Defaults to no VAPID. See "Generating VAPID cryptographic material" in the JMAP
+# configuration documentation.
 # webpush.vapid.public.key=
 # webpush.vapid.private.key=
-# Contact of the operator, a mailto: or an https: URI. Recommended: some push services refuse requests without it.
+# Contact of the operator, a mailto: or an https: URI. Set it with the keys: the push service of Apple refuses
+# requests without it.
 # webpush.vapid.subject=mailto:postmaster@example.com
\ No newline at end of file
diff --git a/server/apps/postgres-app/sample-configuration/jmap.properties b/server/apps/postgres-app/sample-configuration/jmap.properties
index a92f58ac46..4ecb40a262 100644
--- a/server/apps/postgres-app/sample-configuration/jmap.properties
+++ b/server/apps/postgres-app/sample-configuration/jmap.properties
@@ -59,8 +59,10 @@ tls.secret=james72laBalle
 
 # VAPID (RFC-8292, RFC-9749): identifies this server to the push services, which browsers require to subscribe.
 # A P-256 key pair, base64url encoded as Web Push tools generate it: the public key in its uncompressed form (65 bytes),
-# the private key (32 bytes). Defaults to no VAPID.
+# the private key (32 bytes). Defaults to no VAPID. See "Generating VAPID cryptographic material" in the JMAP
+# configuration documentation.
 # webpush.vapid.public.key=
 # webpush.vapid.private.key=
-# Contact of the operator, a mailto: or an https: URI. Recommended: some push services refuse requests without it.
+# Contact of the operator, a mailto: or an https: URI. Set it with the keys: the push service of Apple refuses
+# requests without it.
 # webpush.vapid.subject=mailto:postmaster@example.com
\ No newline at end of file


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


(james-project) 02/08: [ENHANCEMENT] JMAP Web Push: a TTL of four weeks

Posted by bt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit a73f5e249027633c953b2f4f2feb9647247877a6
Author: Clement Guyon <cg...@linagora.com>
AuthorDate: Mon Oct 12 22:08:33 2026 +0200

    [ENHANCEMENT] JMAP Web Push: a TTL of four weeks
    
    Pushes asked push services to retain them 2^31 - 1 seconds, 68 years.
    Push services bound it, commonly to four weeks: ask for that.
---
 .../scala/org/apache/james/jmap/pushsubscription/PushListener.scala   | 2 +-
 .../scala/org/apache/james/jmap/pushsubscription/PushRequest.scala    | 4 ++++
 .../org/apache/james/jmap/pushsubscription/PushListenerTest.scala     | 4 ++--
 3 files changed, 7 insertions(+), 3 deletions(-)

diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushListener.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushListener.scala
index 3a88daaffe..4777ea451b 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushListener.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushListener.scala
@@ -90,7 +90,7 @@ class PushListener @Inject()(pushRepository: PushSubscriptionRepository,
       .fold(SMono.empty[Unit])(stateChange => SMono(webPushClient.push(pushSubscription.url, asPushRequest(stateChange, pushSubscription))))
 
   private def asPushRequest(stateChange: StateChange, pushSubscription: PushSubscription): PushRequest =
-    PushRequest(ttl = PushTTL.MAX,
+    PushRequest(ttl = PushTTL.FOUR_WEEKS,
       urgency = Some(urgency(stateChange)),
       topic = Some(extractTopic(stateChange)),
       contentCoding = pushSubscription.keys.map(_ => Aes128gcm),
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushRequest.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushRequest.scala
index 8d8ca2e5d5..85b4b4244a 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushRequest.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/pushsubscription/PushRequest.scala
@@ -19,6 +19,8 @@
 
 package org.apache.james.jmap.pushsubscription
 
+import java.time.Duration
+
 import com.google.common.base.CharMatcher
 import eu.timepit.refined
 import eu.timepit.refined.api.{Refined, Validate}
@@ -86,6 +88,8 @@ object PushTTL {
       .map(new IllegalArgumentException(_))
 
   val MAX: PushTTL = validate(MAX_INT).toOption.get
+  // The longest push services commonly retain a message: they cap a longer TTL
+  val FOUR_WEEKS: PushTTL = validate(Duration.ofDays(28).getSeconds).toOption.get
 }
 
 case class PushTopicConstraint()
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushListenerTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushListenerTest.scala
index d1c4f3b9e3..8c8fae737d 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushListenerTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushListenerTest.scala
@@ -126,7 +126,7 @@ class PushListenerTest {
     verify(webPushClient).push(ArgumentMatchers.eq(url), argumentCaptor.capture())
 
     SoftAssertions.assertSoftly(softly => {
-      softly.assertThat(argumentCaptor.getValue.ttl).isEqualTo(PushTTL.MAX)
+      softly.assertThat(argumentCaptor.getValue.ttl).isEqualTo(PushTTL.FOUR_WEEKS)
       softly.assertThat(new String(argumentCaptor.getValue.payload, StandardCharsets.UTF_8))
         .isEqualTo(s"""{"@type":"StateChange","changed":{"$bobAccountId":{"Email":"${state1.value.toString}"}}}""")
     })
@@ -217,7 +217,7 @@ class PushListenerTest {
     verify(webPushClient).push(ArgumentMatchers.eq(url), argumentCaptor.capture())
 
     SoftAssertions.assertSoftly(softly => {
-      softly.assertThat(argumentCaptor.getValue.ttl).isEqualTo(PushTTL.MAX)
+      softly.assertThat(argumentCaptor.getValue.ttl).isEqualTo(PushTTL.FOUR_WEEKS)
       softly.assertThat(new String(argumentCaptor.getValue.payload, StandardCharsets.UTF_8))
         .isEqualTo(s"""{"@type":"StateChange","changed":{"$bobAccountId":{"Email":"${state1.value.toString}","Mailbox":"${state2.value.toString}"}}}""")
     })


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


(james-project) 01/08: [FIX] JMAP Web Push: accept the p256dh key of browsers

Posted by bt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 4b3d138155f9164ac8413f2ebb01cbbb28236460
Author: Clement Guyon <cg...@linagora.com>
AuthorDate: Mon Oct 12 22:08:32 2026 +0200

    [FIX] JMAP Web Push: accept the p256dh key of browsers
    
    RFC-8291 (section 3.1) and RFC-8620 (section 7.2) define p256dh as the
    P-256 public key in its uncompressed form (65 bytes), which is what
    PushSubscription.getKey('p256dh') gives in a browser. Only the X.509
    encoding of the key was read, so the subscription of a browser was
    refused with invalidArguments.
    
    The X.509 encoding is still accepted.
---
 .../james/jmap/api/model/PushSubscription.scala    | 17 ++++-
 .../jmap/api/model/PushSubscriptionKeysTest.scala  | 76 ++++++++++++++++++++++
 2 files changed, 92 insertions(+), 1 deletion(-)

diff --git a/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala
index 9055beee4b..1ad5d268a8 100644
--- a/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala
+++ b/server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/model/PushSubscription.scala
@@ -28,6 +28,7 @@ import java.util.{Base64, UUID}
 import com.google.crypto.tink.HybridEncrypt
 import com.google.crypto.tink.apps.webpush.WebPushHybridEncrypt
 import com.google.crypto.tink.subtle.EllipticCurves
+import com.google.crypto.tink.subtle.EllipticCurves.{CurveType, PointFormatType}
 import org.apache.james.jmap.api.change.TypeStateFactory
 import org.apache.james.jmap.api.model.ExpireTimeInvalidException.TIME_FORMATTER
 
@@ -67,6 +68,11 @@ case class PushSubscriptionExpiredTime(value: ZonedDateTime) {
   def isBefore(date: ZonedDateTime): Boolean = value.isBefore(date)
 }
 
+object PushSubscriptionKeys {
+  private val UNCOMPRESSED_POINT_LENGTH: Int = 65
+  private val UNCOMPRESSED_POINT_TAG: Byte = 0x04
+}
+
 case class PushSubscriptionKeys(p256dh: String, auth: String) {
   def validate: Either[IllegalArgumentException, PushSubscriptionKeys] =
     Try(asHybridEncrypt()).map(_ => this)
@@ -86,7 +92,16 @@ case class PushSubscriptionKeys(p256dh: String, auth: String) {
     .withRecipientPublicKey(asECPublicKey())
     .build()
 
-  private def asECPublicKey(): ECPublicKey = EllipticCurves.getEcPublicKey(Base64.getUrlDecoder.decode(p256dh))
+  // RFC 8291 section 3.1: the key is an uncompressed point (65 bytes), which is what browsers hand out.
+  // Its X.509 encoding, the only form that used to be read, is still accepted.
+  private def asECPublicKey(): ECPublicKey = {
+    val key: Array[Byte] = Base64.getUrlDecoder.decode(p256dh)
+    if (key.length == PushSubscriptionKeys.UNCOMPRESSED_POINT_LENGTH && key(0) == PushSubscriptionKeys.UNCOMPRESSED_POINT_TAG) {
+      EllipticCurves.getEcPublicKey(CurveType.NIST_P256, PointFormatType.UNCOMPRESSED, key)
+    } else {
+      EllipticCurves.getEcPublicKey(key)
+    }
+  }
 }
 
 object PushSubscriptionCreationRequest {
diff --git a/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/model/PushSubscriptionKeysTest.scala b/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/model/PushSubscriptionKeysTest.scala
new file mode 100644
index 0000000000..ced5823be9
--- /dev/null
+++ b/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/model/PushSubscriptionKeysTest.scala
@@ -0,0 +1,76 @@
+/****************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one   *
+ * or more contributor license agreements.  See the NOTICE file *
+ * distributed with this work for additional information        *
+ * regarding copyright ownership.  The ASF licenses this file   *
+ * to you under the Apache License, Version 2.0 (the            *
+ * "License"); you may not use this file except in compliance   *
+ * with the License.  You may obtain a copy of the License at   *
+ *                                                              *
+ *   http://www.apache.org/licenses/LICENSE-2.0                 *
+ *                                                              *
+ * Unless required by applicable law or agreed to in writing,   *
+ * software distributed under the License is distributed on an  *
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY       *
+ * KIND, either express or implied.  See the License for the    *
+ * specific language governing permissions and limitations      *
+ * under the License.                                           *
+ ****************************************************************/
+
+package org.apache.james.jmap.api.model
+
+import java.nio.charset.StandardCharsets
+import java.security.KeyPair
+import java.security.interfaces.{ECPrivateKey, ECPublicKey}
+import java.util.Base64
+
+import com.google.crypto.tink.apps.webpush.WebPushHybridDecrypt
+import com.google.crypto.tink.subtle.EllipticCurves
+import com.google.crypto.tink.subtle.EllipticCurves.{CurveType, PointFormatType}
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+
+class PushSubscriptionKeysTest {
+  private val keyPair: KeyPair = EllipticCurves.generateKeyPair(CurveType.NIST_P256)
+  private val publicKey: ECPublicKey = keyPair.getPublic.asInstanceOf[ECPublicKey]
+  private val authSecret: Array[Byte] = "secret123secret1".getBytes(StandardCharsets.UTF_8)
+  private val auth: String = Base64.getUrlEncoder.withoutPadding().encodeToString(authSecret)
+  // RFC 8291: what `PushSubscription.getKey("p256dh")` gives in a browser
+  private val uncompressedPoint: Array[Byte] = EllipticCurves.pointEncode(CurveType.NIST_P256, PointFormatType.UNCOMPRESSED, publicKey.getW)
+
+  private def decrypt(encrypted: Array[Byte]): String =
+    new String(new WebPushHybridDecrypt.Builder()
+      .withAuthSecret(authSecret)
+      .withRecipientPublicKey(publicKey)
+      .withRecipientPrivateKey(keyPair.getPrivate.asInstanceOf[ECPrivateKey])
+      .build()
+      .decrypt(encrypted, null), StandardCharsets.UTF_8)
+
+  @Test
+  def shouldAcceptTheUncompressedPointOfBrowsers(): Unit = {
+    val keys = PushSubscriptionKeys(Base64.getUrlEncoder.withoutPadding().encodeToString(uncompressedPoint), auth)
+
+    assertThat(uncompressedPoint).hasSize(65)
+    assertThat(keys.validate.isRight).isTrue
+    assertThat(decrypt(keys.encrypt("payload".getBytes(StandardCharsets.UTF_8)))).isEqualTo("payload")
+  }
+
+  @Test
+  def shouldStillAcceptTheX509Encoding(): Unit = {
+    val keys = PushSubscriptionKeys(Base64.getUrlEncoder.encodeToString(publicKey.getEncoded), auth)
+
+    assertThat(keys.validate.isRight).isTrue
+    assertThat(decrypt(keys.encrypt("payload".getBytes(StandardCharsets.UTF_8)))).isEqualTo("payload")
+  }
+
+  @Test
+  def shouldRejectAKeyThatIsNotAPointOfTheCurve(): Unit = {
+    val notAPoint: Array[Byte] = Array[Byte](0x04) ++ Array.fill[Byte](64)(1)
+
+    assertThat(PushSubscriptionKeys(Base64.getUrlEncoder.encodeToString(notAPoint), auth).validate.isLeft).isTrue
+  }
+
+  @Test
+  def shouldRejectGarbage(): Unit =
+    assertThat(PushSubscriptionKeys("not a key", auth).validate.isLeft).isTrue
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org


(james-project) 03/08: [FIX] JMAP Web Push: tell that a PushVerification is encrypted

Posted by bt...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

chibenwa pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit edb263173f79d65cbdad3ce9b0f5562c6972f52a
Author: Clement Guyon <cg...@linagora.com>
AuthorDate: Mon Oct 12 22:08:33 2026 +0200

    [FIX] JMAP Web Push: tell that a PushVerification is encrypted
    
    A PushVerification sent to a subscription with keys was encrypted but
    carried no Content-Encoding header, unlike StateChange pushes: the push
    service delivers it, the client cannot read it, and the subscription is
    never verified.
    
    Its TTL follows the one of the other pushes.
---
 .../james/jmap/method/PushSubscriptionSetCreatePerformer.scala    | 8 ++++++--
 .../pushsubscription/PushSubscriptionSetCreateProcessorTest.scala | 3 +++
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala
index 3335602948..361d307f17 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/PushSubscriptionSetCreatePerformer.scala
@@ -10,7 +10,7 @@ import org.apache.james.jmap.core.SetError.SetErrorDescription
 import org.apache.james.jmap.core.{Properties, PushSubscriptionCreation, PushSubscriptionCreationId, PushSubscriptionCreationParseException, PushSubscriptionCreationResponse, PushSubscriptionSetRequest, SetError}
 import org.apache.james.jmap.json.{PushSerializer, PushSubscriptionSerializer}
 import org.apache.james.jmap.method.PushSubscriptionSetCreatePerformer.{CreationFailure, CreationResult, CreationResults, CreationSuccess}
-import org.apache.james.jmap.pushsubscription.{PushRequest, PushTTL, WebPushClient}
+import org.apache.james.jmap.pushsubscription.{Aes128gcm, PushRequest, PushTTL, WebPushClient}
 import org.apache.james.mailbox.MailboxSession
 import org.slf4j.LoggerFactory
 import play.api.libs.json.{JsObject, JsPath, Json, JsonValidationError}
@@ -110,7 +110,11 @@ class PushSubscriptionSetCreateProcessor @Inject()(webPushClient: WebPushClient)
 
     SMono.fromCallable(() => Json.stringify(PushSerializer.serializePushVerification(pushVerification)).getBytes(StandardCharsets.UTF_8))
       .map(clearPayload => keys.map(keysValue => keysValue.encrypt(clearPayload)).getOrElse(clearPayload))
-      .flatMap(payload => SMono.fromPublisher(webPushClient.push(pushSubscriptionServerURL, PushRequest(PushTTL.MAX, payload = payload))))
+      .flatMap(payload => SMono.fromPublisher(webPushClient.push(pushSubscriptionServerURL, PushRequest(
+        ttl = PushTTL.FOUR_WEEKS,
+        // Without it the client cannot tell that the payload is encrypted, nor read it
+        contentCoding = keys.map(_ => Aes128gcm),
+        payload = payload))))
 }
 
 case class PushVerification(pushSubscriptionId: PushSubscriptionId,
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushSubscriptionSetCreateProcessorTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushSubscriptionSetCreateProcessorTest.scala
index cda353b6df..333b670b39 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushSubscriptionSetCreateProcessorTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/pushsubscription/PushSubscriptionSetCreateProcessorTest.scala
@@ -76,6 +76,8 @@ class PushSubscriptionSetCreateProcessorTest {
         .withPath("/subscribe")).toSeq
 
     assertThat(requests.size).isEqualTo(1)
+    assertThat(requests.head.containsHeader("Content-Encoding")).isFalse
+    assertThat(requests.head.getFirstHeader("TTL")).isEqualTo("2419200")
     assertThatJson(requests.head.getBodyAsString)
       .isEqualTo(
         """{
@@ -101,6 +103,7 @@ class PushSubscriptionSetCreateProcessorTest {
         .withMethod("POST")
         .withPath("/subscribe")).toSeq
     assertThat(requests.size).isEqualTo(1)
+    assertThat(requests.head.getFirstHeader("Content-Encoding")).isEqualTo("aes128gcm")
 
     assertThatJson(requests.head.getBodyAsString)
       .isNotEqualTo(


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org