Skip to content

Commit c796be6

Browse files
author
Christophe Maillard
committed
PushAsyncService
1 parent fb6d87f commit c796be6

File tree

5 files changed

+469
-283
lines changed

5 files changed

+469
-283
lines changed

build.gradle

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@ dependencies {
2323
// For CLI
2424
compile group: 'com.beust', name: 'jcommander', version: '1.78'
2525

26-
// For making async HTTP requests
26+
// For making HTTP requests
2727
compile group: 'org.apache.httpcomponents', name: 'httpasyncclient', version: '4.1.4'
2828

29+
// For making async HTTP requests
30+
compile group: 'org.asynchttpclient', name: 'async-http-client', version: '2.10.4'
31+
2932
// For cryptographic operations
3033
shadow group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.64'
3134

@@ -59,8 +62,8 @@ wrapper {
5962
}
6063

6164
compileJava {
62-
sourceCompatibility = 1.7
63-
targetCompatibility = 1.7
65+
sourceCompatibility = 1.8
66+
targetCompatibility = 1.8
6467
}
6568

6669
compileTestJava {
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
package nl.martijndwars.webpush;
2+
3+
import org.bouncycastle.jce.ECNamedCurveTable;
4+
import org.bouncycastle.jce.interfaces.ECPublicKey;
5+
import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec;
6+
import org.jose4j.jws.AlgorithmIdentifiers;
7+
import org.jose4j.jws.JsonWebSignature;
8+
import org.jose4j.jwt.JwtClaims;
9+
import org.jose4j.lang.JoseException;
10+
11+
import java.io.IOException;
12+
import java.security.GeneralSecurityException;
13+
import java.security.InvalidAlgorithmParameterException;
14+
import java.security.KeyPair;
15+
import java.security.KeyPairGenerator;
16+
import java.security.NoSuchAlgorithmException;
17+
import java.security.NoSuchProviderException;
18+
import java.security.PrivateKey;
19+
import java.security.PublicKey;
20+
import java.security.SecureRandom;
21+
import java.security.spec.InvalidKeySpecException;
22+
import java.util.HashMap;
23+
import java.util.Map;
24+
25+
public abstract class AbstractPushService<T extends AbstractPushService<T>> {
26+
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
27+
public static final String SERVER_KEY_ID = "server-key-id";
28+
public static final String SERVER_KEY_CURVE = "P-256";
29+
30+
/**
31+
* The Google Cloud Messaging API key (for pre-VAPID in Chrome)
32+
*/
33+
private String gcmApiKey;
34+
35+
/**
36+
* Subject used in the JWT payload (for VAPID). When left as null, then no subject will be used
37+
* (RFC-8292 2.1 says that it is optional)
38+
*/
39+
private String subject;
40+
41+
/**
42+
* The public key (for VAPID)
43+
*/
44+
private PublicKey publicKey;
45+
46+
/**
47+
* The private key (for VAPID)
48+
*/
49+
private PrivateKey privateKey;
50+
51+
public AbstractPushService() {
52+
}
53+
54+
public AbstractPushService(String gcmApiKey) {
55+
this.gcmApiKey = gcmApiKey;
56+
}
57+
58+
public AbstractPushService(KeyPair keyPair) {
59+
this.publicKey = keyPair.getPublic();
60+
this.privateKey = keyPair.getPrivate();
61+
}
62+
63+
public AbstractPushService(KeyPair keyPair, String subject) {
64+
this(keyPair);
65+
this.subject = subject;
66+
}
67+
68+
public AbstractPushService(String publicKey, String privateKey) throws GeneralSecurityException {
69+
this.publicKey = Utils.loadPublicKey(publicKey);
70+
this.privateKey = Utils.loadPrivateKey(privateKey);
71+
}
72+
73+
public AbstractPushService(String publicKey, String privateKey, String subject) throws GeneralSecurityException {
74+
this(publicKey, privateKey);
75+
this.subject = subject;
76+
}
77+
78+
/**
79+
* Encrypt the payload.
80+
*
81+
* Encryption uses Elliptic curve Diffie-Hellman (ECDH) cryptography over the prime256v1 curve.
82+
*
83+
* @param payload Payload to encrypt.
84+
* @param userPublicKey The user agent's public key (keys.p256dh).
85+
* @param userAuth The user agent's authentication secret (keys.auth).
86+
* @param encoding
87+
* @return An Encrypted object containing the public key, salt, and ciphertext.
88+
* @throws GeneralSecurityException
89+
*/
90+
public static Encrypted encrypt(byte[] payload, ECPublicKey userPublicKey, byte[] userAuth, Encoding encoding) throws GeneralSecurityException {
91+
KeyPair localKeyPair = generateLocalKeyPair();
92+
93+
Map<String, KeyPair> keys = new HashMap<>();
94+
keys.put(SERVER_KEY_ID, localKeyPair);
95+
96+
Map<String, String> labels = new HashMap<>();
97+
labels.put(SERVER_KEY_ID, SERVER_KEY_CURVE);
98+
99+
byte[] salt = new byte[16];
100+
SECURE_RANDOM.nextBytes(salt);
101+
102+
HttpEce httpEce = new HttpEce(keys, labels);
103+
byte[] ciphertext = httpEce.encrypt(payload, salt, null, SERVER_KEY_ID, userPublicKey, userAuth, encoding);
104+
105+
return new Encrypted.Builder()
106+
.withSalt(salt)
107+
.withPublicKey(localKeyPair.getPublic())
108+
.withCiphertext(ciphertext)
109+
.build();
110+
}
111+
112+
/**
113+
* Generate the local (ephemeral) keys.
114+
*
115+
* @return
116+
* @throws NoSuchAlgorithmException
117+
* @throws NoSuchProviderException
118+
* @throws InvalidAlgorithmParameterException
119+
*/
120+
private static KeyPair generateLocalKeyPair() throws NoSuchAlgorithmException, NoSuchProviderException, InvalidAlgorithmParameterException {
121+
ECNamedCurveParameterSpec parameterSpec = ECNamedCurveTable.getParameterSpec("prime256v1");
122+
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ECDH", "BC");
123+
keyPairGenerator.initialize(parameterSpec);
124+
125+
return keyPairGenerator.generateKeyPair();
126+
}
127+
128+
protected final HttpRequest prepareRequest(Notification notification, Encoding encoding) throws GeneralSecurityException, IOException, JoseException {
129+
if (getPrivateKey() != null && getPublicKey() != null) {
130+
if (!Utils.verifyKeyPair(getPrivateKey(), getPublicKey())) {
131+
throw new IllegalStateException("Public key and private key do not match.");
132+
}
133+
}
134+
135+
Encrypted encrypted = encrypt(
136+
notification.getPayload(),
137+
notification.getUserPublicKey(),
138+
notification.getUserAuth(),
139+
encoding
140+
);
141+
142+
byte[] dh = Utils.encode((ECPublicKey) encrypted.getPublicKey());
143+
byte[] salt = encrypted.getSalt();
144+
145+
String url = notification.getEndpoint();
146+
Map<String, String> headers = new HashMap<>();
147+
byte[] body = null;
148+
149+
headers.put("TTL", String.valueOf(notification.getTTL()));
150+
151+
if (notification.hasUrgency()) {
152+
headers.put("Urgency", notification.getUrgency().getHeaderValue());
153+
}
154+
155+
if (notification.hasTopic()) {
156+
headers.put("Topic", notification.getTopic());
157+
}
158+
159+
160+
if (notification.hasPayload()) {
161+
headers.put("Content-Type", "application/octet-stream");
162+
163+
if (encoding == Encoding.AES128GCM) {
164+
headers.put("Content-Encoding", "aes128gcm");
165+
} else if (encoding == Encoding.AESGCM) {
166+
headers.put("Content-Encoding", "aesgcm");
167+
headers.put("Encryption", "salt=" + Base64Encoder.encodeUrlWithoutPadding(salt));
168+
headers.put("Crypto-Key", "dh=" + Base64Encoder.encodeUrl(dh));
169+
}
170+
171+
body = encrypted.getCiphertext();
172+
}
173+
174+
if (notification.isGcm()) {
175+
if (getGcmApiKey() == null) {
176+
throw new IllegalStateException("An GCM API key is needed to send a push notification to a GCM endpoint.");
177+
}
178+
179+
headers.put("Authorization", "key=" + getGcmApiKey());
180+
} else if (vapidEnabled()) {
181+
if (encoding == Encoding.AES128GCM) {
182+
if (notification.getEndpoint().startsWith("https://fcm.googleapis.com")) {
183+
url = notification.getEndpoint().replace("fcm/send", "wp");
184+
}
185+
}
186+
187+
JwtClaims claims = new JwtClaims();
188+
claims.setAudience(notification.getOrigin());
189+
claims.setExpirationTimeMinutesInTheFuture(12 * 60);
190+
if (getSubject() != null) {
191+
claims.setSubject(getSubject());
192+
}
193+
194+
JsonWebSignature jws = new JsonWebSignature();
195+
jws.setHeader("typ", "JWT");
196+
jws.setHeader("alg", "ES256");
197+
jws.setPayload(claims.toJson());
198+
jws.setKey(getPrivateKey());
199+
jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256);
200+
201+
byte[] pk = Utils.encode((ECPublicKey) getPublicKey());
202+
203+
if (encoding == Encoding.AES128GCM) {
204+
headers.put("Authorization", "vapid t=" + jws.getCompactSerialization() + ", k=" + Base64Encoder.encodeUrlWithoutPadding(pk));
205+
} else if (encoding == Encoding.AESGCM) {
206+
headers.put("Authorization", "WebPush " + jws.getCompactSerialization());
207+
}
208+
209+
if (headers.containsKey("Crypto-Key")) {
210+
headers.put("Crypto-Key", headers.get("Crypto-Key") + ";p256ecdsa=" + Base64Encoder.encodeUrlWithoutPadding(pk));
211+
} else {
212+
headers.put("Crypto-Key", "p256ecdsa=" + Base64Encoder.encodeUrl(pk));
213+
}
214+
} else if (notification.isFcm() && getGcmApiKey() != null) {
215+
headers.put("Authorization", "key=" + getGcmApiKey());
216+
}
217+
218+
return new HttpRequest(url, headers, body);
219+
}
220+
221+
/**
222+
* Set the Google Cloud Messaging (GCM) API key
223+
*
224+
* @param gcmApiKey
225+
* @return
226+
*/
227+
public T setGcmApiKey(String gcmApiKey) {
228+
this.gcmApiKey = gcmApiKey;
229+
230+
return (T) this;
231+
}
232+
233+
public String getGcmApiKey() {
234+
return gcmApiKey;
235+
}
236+
237+
public String getSubject() {
238+
return subject;
239+
}
240+
241+
/**
242+
* Set the JWT subject (for VAPID)
243+
*
244+
* @param subject
245+
* @return
246+
*/
247+
public T setSubject(String subject) {
248+
this.subject = subject;
249+
250+
return (T) this;
251+
}
252+
253+
/**
254+
* Set the public and private key (for VAPID).
255+
*
256+
* @param keyPair
257+
* @return
258+
*/
259+
public T setKeyPair(KeyPair keyPair) {
260+
setPublicKey(keyPair.getPublic());
261+
setPrivateKey(keyPair.getPrivate());
262+
263+
return (T) this;
264+
}
265+
266+
public PublicKey getPublicKey() {
267+
return publicKey;
268+
}
269+
270+
/**
271+
* Set the public key using a base64url-encoded string.
272+
*
273+
* @param publicKey
274+
* @return
275+
*/
276+
public T setPublicKey(String publicKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException {
277+
setPublicKey(Utils.loadPublicKey(publicKey));
278+
279+
return (T) this;
280+
}
281+
282+
public PrivateKey getPrivateKey() {
283+
return privateKey;
284+
}
285+
286+
public KeyPair getKeyPair() {
287+
return new KeyPair(publicKey, privateKey);
288+
}
289+
290+
/**
291+
* Set the public key (for VAPID)
292+
*
293+
* @param publicKey
294+
* @return
295+
*/
296+
public T setPublicKey(PublicKey publicKey) {
297+
this.publicKey = publicKey;
298+
299+
return (T) this;
300+
}
301+
302+
/**
303+
* Set the public key using a base64url-encoded string.
304+
*
305+
* @param privateKey
306+
* @return
307+
*/
308+
public T setPrivateKey(String privateKey) throws NoSuchAlgorithmException, NoSuchProviderException, InvalidKeySpecException {
309+
setPrivateKey(Utils.loadPrivateKey(privateKey));
310+
311+
return (T) this;
312+
}
313+
314+
/**
315+
* Set the private key (for VAPID)
316+
*
317+
* @param privateKey
318+
* @return
319+
*/
320+
public T setPrivateKey(PrivateKey privateKey) {
321+
this.privateKey = privateKey;
322+
323+
return (T) this;
324+
}
325+
326+
/**
327+
* Check if VAPID is enabled
328+
*
329+
* @return
330+
*/
331+
protected boolean vapidEnabled() {
332+
return publicKey != null && privateKey != null;
333+
}
334+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package nl.martijndwars.webpush;
2+
3+
import java.util.Map;
4+
5+
public class HttpRequest {
6+
7+
private final String url;
8+
9+
private final Map<String, String> headers;
10+
11+
private final byte[] body;
12+
13+
public HttpRequest(String url, Map<String, String> headers, byte[] body) {
14+
this.url = url;
15+
this.headers = headers;
16+
this.body = body;
17+
}
18+
19+
public String getUrl() {
20+
return url;
21+
}
22+
23+
public Map<String, String> getHeaders() {
24+
return headers;
25+
}
26+
27+
public byte[] getBody() {
28+
return body;
29+
}
30+
31+
}

0 commit comments

Comments
 (0)