Skip to content

Commit

Permalink
Fix VAPID
Browse files Browse the repository at this point in the history
  • Loading branch information
martijndwars committed Sep 11, 2016
1 parent 64baebc commit 4737ed8
Show file tree
Hide file tree
Showing 8 changed files with 286 additions and 125 deletions.
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,57 @@ For Maven, add the following dependency to `pom.xml`:
</dependency>
```

### VAPID

Usage of VAPID is optional, but when used you should:

1. Generate a private key. This can be done using `openssl ecparam -name prime256v1 -genkey -noout -out vapid_private.pem`
2. Generate a public key. This can be done using `openssl ec -in vapid_private.pem -pubout -out vapid_public.pem`

You can view the keys using `openssl ec -in vapid_private.pem -text -noout -conv_form uncompressed`. This outputs something similar to:

```
read EC key
Private-Key: (256 bit)
priv:
4d:19:58:ff:bc:90:ce:fa:9c:2e:98:07:41:3c:62:
53:97:d5:cc:00:2f:03:0f:dc:75:28:79:90:b1:4b:
36:a8
pub:
04:e1:fc:9d:34:00:e6:26:61:97:6d:fe:34:2c:c6:
1b:da:6b:bc:e6:79:04:4d:0c:25:70:56:f8:65:24:
40:8b:d1:55:35:41:df:62:71:99:7d:15:d6:3e:b3:
d2:be:eb:9d:3e:fe:6e:08:ba:7f:68:39:7c:c3:e9:
02:1e:5b:ae:a3
ASN1 OID: prime256v1
```

You need to use the public key in the `applicationServerKey` option when calling the `subscribe` method. For the above output, this can be done using:

```javascript
const publicKey = new Uint8Array([0x04,0xe1,0xfc,0x9d,0x34,0x00,0xe6,0x26,0x61,0x97,0x6d,0xfe,0x34,0x2c,0xc6,0x1b,0xda,0x6b,0xbc,0xe6,0x79,0x04,0x4d,0x0c,0x25,0x70,0x56,0xf8,0x65,0x24,0x40,0x8b,0xd1,0x55,0x35,0x41,0xdf,0x62,0x71,0x99,0x7d,0x15,0xd6,0x3e,0xb3,0xd2,0xbe,0xeb,0x9d,0x3e,0xfe,0x6e,0x08,0xba,0x7f,0x68,0x39,0x7c,0xc3,0xe9,0x02,0x1e,0x5b,0xae,0xa3]);

navigator.serviceWorker.ready.then(function (serviceWorkerRegistration) {
serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey
})
.then(function (subscription) {
return sendSubscriptionToServer(subscription);
});
});
```

You should also specify the private and public key in the Java library as base64 encoded strings. Again, for the preceding keys, you can use node to compute them:

```javascript
var publicKey = new Buffer([0x04,0xe1,0xfc,0x9d,0x34,0x00,0xe6,0x26,0x61,0x97,0x6d,0xfe,0x34,0x2c,0xc6,0x1b,0xda,0x6b,0xbc,0xe6,0x79,0x04,0x4d,0x0c,0x25,0x70,0x56,0xf8,0x65,0x24,0x40,0x8b,0xd1,0x55,0x35,0x41,0xdf,0x62,0x71,0x99,0x7d,0x15,0xd6,0x3e,0xb3,0xd2,0xbe,0xeb,0x9d,0x3e,0xfe,0x6e,0x08,0xba,0x7f,0x68,0x39,0x7c,0xc3,0xe9,0x02,0x1e,0x5b,0xae,0xa3]);
publicKey.toString('base64');

var privateKey = new Buffer([0x4d,0x19,0x58,0xff,0xbc,0x90,0xce,0xfa,0x9c,0x2e,0x98,0x07,0x41,0x3c,0x62,0x53,0x97,0xd5,0xcc,0x00,0x2f,0x03,0x0f,0xdc,0x75,0x28,0x79,0x90,0xb1,0x4b,0x36,0xa8]);
privateKey.toString('base64');
```

## Usage

See [doc/UsageExample.md](https://github.com/MartijnDwars/web-push/blob/master/doc/UsageExample.md)
Expand Down
22 changes: 17 additions & 5 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
group 'nl.martijndwars'
version '1.0.0'
version '2.0.0'

apply plugin: 'java'
apply plugin: 'maven'
apply plugin: 'signing'

sourceCompatibility = 1.8
targetCompatibility = 1.8
sourceCompatibility = 1.7
targetCompatibility = 1.7

jar {
baseName = 'web-push'
version = '1.0.0'
version = '2.0.0'
}

task javadocJar(type: Jar) {
Expand Down Expand Up @@ -84,11 +84,23 @@ repositories {
}

dependencies {
// For making HTTP requests
compile group: 'org.apache.httpcomponents', name: 'fluent-hc', version: '4.5.2'

// For turning InputStream to String
compile group: 'commons-io', name: 'commons-io', version: '2.5'

// Not sure what for..
compile group: 'com.google.guava', name: 'guava', version: '19.0'

// For cryptographic operations
compile group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.54'

// For encoding JSON
compile group: 'org.json', name: 'json', version: '20160212'
compile group: 'io.jsonwebtoken', name: 'jjwt', version:'0.6.0'

// For creating and signing JWT
compile group: 'org.bitbucket.b_c', name: 'jose4j', version: '0.5.2'

testCompile group: 'junit', name: 'junit', version: '4.11'
}
28 changes: 0 additions & 28 deletions src/main/java/nl/martijndwars/webpush/GcmNotification.java

This file was deleted.

27 changes: 16 additions & 11 deletions src/main/java/nl/martijndwars/webpush/HttpEce.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,8 @@ public byte[][] deriveKey(byte[] salt, byte[] key, String keyId, PublicKey dh, b
secret = hkdfExpand(secret, authSecret, buildInfo("auth", new byte[0]), 32);
}

if (padSize == 2) {
keyinfo = buildInfo("aesgcm", context);
nonceinfo = buildInfo("nonce", context);
} else if (padSize == 1) {
keyinfo = "Content-Encoding: aesgcm128".getBytes();
nonceinfo = "Content-Encoding: nonce".getBytes();
} else {
throw new IllegalArgumentException("Unable to set context for padSize " + padSize);
}
keyinfo = buildInfo("aesgcm", context);
nonceinfo = buildInfo("nonce", context);

byte[] hkdf_key = hkdfExpand(secret, salt, keyinfo, 16);
byte[] hkdf_nonce = hkdfExpand(secret, salt, nonceinfo, 12);
Expand Down Expand Up @@ -148,10 +141,9 @@ private byte[] intToBytes(int x) throws IOException {
* Utility to concat byte arrays
*/
private byte[] concat(byte[]... arrays) {
int combinedLength = Arrays.stream(arrays).mapToInt(array -> array.length).sum();
int lastPos = 0;

byte[] combined = new byte[combinedLength];
byte[] combined = new byte[combinedLength(arrays)];

for (byte[] array : arrays) {
System.arraycopy(array, 0, combined, lastPos, array.length);
Expand All @@ -162,6 +154,19 @@ private byte[] concat(byte[]... arrays) {
return combined;
}

/**
* Compute combined array length
*/
private int combinedLength(byte[]... arrays) {
int combinedLength = 0;

for (byte[] array : arrays) {
combinedLength += array.length;
}

return combinedLength;
}

public byte[] encrypt(byte[] buffer, byte[] salt, byte[] key, String keyid, PublicKey dh, byte[] authSecret, int padSize) throws NoSuchPaddingException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException, NoSuchProviderException, IOException {
byte[][] derivedKey = deriveKey(salt, key, keyid, dh, authSecret, padSize);
byte[] key_ = derivedKey[0];
Expand Down
11 changes: 10 additions & 1 deletion src/main/java/nl/martijndwars/webpush/Notification.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,21 @@ public boolean hasPayload() {
return getPayload().length > 0;
}

/**
* Detect if the notification is for a GCM-based subscription
*
* @return
*/
public boolean isGcm() {
return getEndpoint().indexOf("https://android.googleapis.com/gcm/send") == 0;
}

public int getTTL() {
return ttl;
}

public int getPadSize() {
return 1;
return 2;
}

public String getOrigin() throws MalformedURLException {
Expand Down
85 changes: 39 additions & 46 deletions src/main/java/nl/martijndwars/webpush/PushService.java
Original file line number Diff line number Diff line change
@@ -1,36 +1,29 @@
package nl.martijndwars.webpush;

import com.google.common.io.BaseEncoding;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.JwsHeader;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.impl.DefaultJwsHeader;
import org.apache.http.client.fluent.Async;
import org.apache.http.client.fluent.Content;
import org.apache.http.client.fluent.Request;
import org.apache.http.entity.ContentType;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicHeader;
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.interfaces.ECPublicKey;
import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec;
import org.json.JSONObject;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.lang.JoseException;

import javax.crypto.BadPaddingException;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import java.io.IOException;
import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class PushService {
private ExecutorService threadpool = Executors.newFixedThreadPool(1);

/**
* The Google Cloud Messaging API key (for pre-VAPID in Chrome)
*/
Expand All @@ -44,12 +37,12 @@ public class PushService {
/**
* The public key (for VAPID)
*/
private byte[] publicKey;
private PublicKey publicKey;

/**
* The private key (for VAPID)
*/
private byte[] privateKey;
private Key privateKey;

/**
* Encrypt the payload using the user's public key using Elliptic Curve
Expand Down Expand Up @@ -87,7 +80,7 @@ public static Encrypted encrypt(byte[] buffer, PublicKey userPublicKey, byte[] u
/**
* Send a notification
*/
public Future<Content> send(Notification notification) throws NoSuchPaddingException, InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, NoSuchProviderException, InvalidAlgorithmParameterException, IOException, InvalidKeySpecException {
public HttpResponse send(Notification notification) throws NoSuchPaddingException, InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, NoSuchProviderException, InvalidAlgorithmParameterException, IOException, InvalidKeySpecException, JoseException {
BaseEncoding base64url = BaseEncoding.base64Url();

Encrypted encrypted = encrypt(
Expand All @@ -100,59 +93,59 @@ public Future<Content> send(Notification notification) throws NoSuchPaddingExcep
byte[] dh = Utils.savePublicKey((ECPublicKey) encrypted.getPublicKey());
byte[] salt = encrypted.getSalt();

Request request = Request
.Post(notification.getEndpoint())
.addHeader("TTL", String.valueOf(notification.getTTL()));
HttpClient httpClient = HttpClients.createDefault();

HttpPost httpPost = new HttpPost(notification.getEndpoint());
httpPost.addHeader("TTL", String.valueOf(notification.getTTL()));

Map<String, String> headers = new HashMap<>();

if (notification.hasPayload()) {
headers.put("Content-Type", "application/octet-stream");
headers.put("Content-Encoding", "aesgcm128");
headers.put("Content-Encoding", "aesgcm");
headers.put("Encryption", "keyid=p256dh;salt=" + base64url.omitPadding().encode(salt));
headers.put("Crypto-Key", "keyid=p256dh;dh=" + base64url.encode(dh));

request.bodyByteArray(encrypted.getCiphertext());
httpPost.setEntity(new ByteArrayEntity(encrypted.getCiphertext()));
}

if (notification instanceof GcmNotification) {
if (notification.isGcm()) {
if (gcmApiKey == null) {
throw new IllegalStateException("An GCM API key is needed to send a push notification to a GCM endpint.");
}

headers.put("Authorization", "key=" + gcmApiKey);
}

if (vapidEnabled() && !(notification instanceof GcmNotification)) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(new Date());
calendar.add(Calendar.HOUR, 12);
if (vapidEnabled() && !notification.isGcm()) {
JwtClaims claims = new JwtClaims();
claims.setAudience(notification.getOrigin());
claims.setExpirationTimeMinutesInTheFuture(12*60);
claims.setSubject(subject);

String compactJws = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setHeaderParam("alg", "ES256")
.setAudience(notification.getOrigin())
.setExpiration(calendar.getTime())
.setSubject(subject)
.signWith(SignatureAlgorithm.ES256, privateKey)
.compact();
JsonWebSignature jws = new JsonWebSignature();
jws.setHeader("typ", "JWT");
jws.setHeader("alg", "ES256");
jws.setPayload(claims.toJson());
jws.setKey(privateKey);
jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256);

headers.put("Authorization", "Bearer " + compactJws);
headers.put("Authorization", "Bearer " + jws.getCompactSerialization());

byte[] pk = Utils.savePublicKey((ECPublicKey) publicKey);

if (headers.containsKey("Crypto-Key")) {
headers.put("Crypto-Key", headers.get("Crypto-Key") + ";p256ecdsa=" + base64url.encode(publicKey));
headers.put("Crypto-Key", headers.get("Crypto-Key") + ";p256ecdsa=" + base64url.omitPadding().encode(pk));
} else {
headers.put("Crypto-Key", "p256ecdsa=" + base64url.encode(publicKey));
headers.put("Crypto-Key", "p256ecdsa=" + base64url.encode(pk));
}
}

for (Map.Entry<String, String> entry : headers.entrySet()) {
request.addHeader(new BasicHeader(entry.getKey(), entry.getValue()));
httpPost.addHeader(new BasicHeader(entry.getKey(), entry.getValue()));
}

Async async = Async.newInstance().use(threadpool);

return async.execute(request);
return httpClient.execute(httpPost);
}

/**
Expand Down Expand Up @@ -185,7 +178,7 @@ public PushService setSubject(String subject) {
* @param publicKey
* @return
*/
public PushService setPublicKey(byte[] publicKey) {
public PushService setPublicKey(PublicKey publicKey) {
this.publicKey = publicKey;

return this;
Expand All @@ -197,7 +190,7 @@ public PushService setPublicKey(byte[] publicKey) {
* @param privateKey
* @return
*/
public PushService setPrivateKey(byte[] privateKey) {
public PushService setPrivateKey(PrivateKey privateKey) {
this.privateKey = privateKey;

return this;
Expand Down
Loading

0 comments on commit 4737ed8

Please sign in to comment.