[WebPush][VAPID] Request fails with 400 Unauthoriz

2019-07-05 06:57发布

问题:

I'm working on a pure java implementation for WebPush with VAPID and payload encryption (I've already made implementations for GCM and FCM). However the documentation is still marginal and also the code samples are still not substantial. At this moment i'm trying to get it to work in Chrome. allthough i get succesful subscriptions using VAPID, when i send either a Tickle or a Payload push message i get a 400 UnauthorizedRegistration. My guess is that it has something to do with the authorization header or the Crypto-Key header. This is what i'm sending so far for a Tickle (A push notification without payload):

URL: https://fcm.googleapis.com/fcm/send/xxxxx:xxxxxxxxxxx...
Action: POST/PUT (Both give same result)
With headers:
    Authorization: Bearer URLBase64(JWT_HEAD).URLBase64(JWT_Payload).SIGN
    Crypto-Key: p265ecdsa=X9.62(PublicKey)
    Content-Type: "text/plain;charset=utf8"
    Content-Length: 0
    TTL: 120

JWT_HEAD="{\"typ\":\"JWT\",\"alg\":\"ES256\"}"
JWT_Payload={
    aud: "https://fcm.googleapis.com",
    exp: (System.currentTimeMillis() / 1000) + (60 * 60 * 12)),
    sub: "mailto:webpush@mydomain.com"
}
SIGN = the "SHA256withECDSA" signature algorithm over: "URLBase64(JWT_HEAD).URLBase64(JWT_Payload)"

I've stripped the whitespaces from both JSON's in the JWT since the spec is not very clear about whitespace usage that seemed the safest thing to do. The signature validates after decoding the x9.62 to ECPoint again, so the publicKey seems validly encoded. However i keep getting the response:

<HTML><HEAD><TITLE>UnauthorizedRegistration</TITLE></HEAD><BODY BGCOLOR="#FFFFFF" TEXT="#000000"><H1>UnauthorizedRegistration</H1><H2>Error 400</H2></BODY></HTML>

According to the FCM documentation this only happends when a JSON error occurs, however i feel the specification does not cover WebPush at all. For now i've both tried the build in Java Crypto providers and BC both produce the same results.

Some code Snippets for clarification:

KeyGeneration:

KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC", "BC");
ECGenParameterSpec spec = new ECGenParameterSpec("secp256r1");
keyGen.initialize(spec, secureRandom);
KeyPair vapidPair = keyGen.generateKeyPair();

ECPublicKey to x9.62:

public byte[] toUncompressedPoint(ECPublicKey publicKey){
    final ECPoint publicPoint = publicKey.getW();

    final int keySizeBytes = (publicKey.getParams().getOrder().bitLength() + Byte.SIZE - 1) / Byte.SIZE;
    final byte[] x = publicPoint.getAffineX().toByteArray();
    final byte[] y = publicPoint.getAffineY().toByteArray();
    final byte[] res = new byte[1 + 2 * keySizeBytes];
    int offset = 0;
    res[offset++] = 0x04; //Indicating no key compression is used
    if(x.length <= keySizeBytes)
        System.arraycopy(x, 0, res, offset + keySizeBytes - x.length, x.length);
    else if(x.length == keySizeBytes + 1) System.arraycopy(x, 1, res, offset, keySizeBytes);
    else throw new IllegalArgumentException("X value is too large!");

    offset += keySizeBytes;
    if(y.length <= keySizeBytes)
        System.arraycopy(y, 0, res, offset + keySizeBytes - y.length, y.length);
    else if(y.length == keySizeBytes + 1 && y[0] == 0) System.arraycopy(y, 1, res, offset, keySizeBytes);
    else throw new IllegalArgumentException("Y value is too large!");

    return res;
}

Signing the JWT claim:

    ObjectNode claim = om.createObjectNode();
    claim.put("aud", host);
    claim.put("exp", (System.currentTimeMillis() / 1000) + (60 * 60 * 12));
    claim.put("sub", "mailto:webpush_ops@mydomain.com");
    String claimString = claim.toString();
    String encHeader = URLBase64.encodeString(VAPID_HEADER, false);
    String encPayload =  URLBase64.encodeString(claimString, false);
    String vapid = null;
    ECPublicKey pubKey = (ECPublicKey) vapidPair.getPublic();
    byte[] point = toUncompressedPoint(pubKey);
    String vapidKey = URLBase64.encodeToString(point, false);
    try{
        Signature dsa = Signature.getInstance("SHA256withECDSA", "BC");
        dsa.initSign(vapidPair.getPrivate());
        dsa.update((encHeader + "." + encPayload).getBytes(StandardCharsets.US_ASCII));
        byte[] signature = dsa.sign();
        vapid = encHeader + "." + encPayload + "." + URLBase64.encodeToString(signature, false);

Some questions that reside in my mind:

  • what is the auth field for in the registration reply JSON? Since to my knowledge for encryption only the p256dh is used for generating the encryption keys together with a server based KeyPair.

    Further research of the ietf draft 03 gave me the answer in section: 2.3 Link: https://tools.ietf.org/html/draft-ietf-webpush-encryption-03 Also the link in Vincent Cheung's answer gives a good explanation

  • The documentation speaks of different header usage for VAPID using Bearer/WebPush and using the Crypto-Key header or the Encryption-Key header. Wat is the correct thing to do?

  • Any ideas why the FCM server keeps returning a: 400 UnauthorizedRegistration ?

Can somebody add the VAPID tag to this question? It does not yet seem to exist.

回答1:

what is the auth field for in the registration reply JSON? Since to my knowledge for encryption only the p256dh is used for generating the encryption keys together with a server based KeyPair.

The auth field is used for encryption if you're sending a push notification that contains data. I'm not an expert in crypto, but here's a blog post from Mozilla that explains it. https://blog.mozilla.org/services/2016/08/23/sending-vapid-identified-webpush-notifications-via-mozillas-push-service/

The documentation speaks of different header usage for VAPID using Bearer/WebPush and using the Crypto-Key header or the Encryption-Key header. Wat is the correct thing to do?

Use Bearer with your JWT.

Any ideas why the FCM server keeps returning a: 400 UnauthorizedRegistration ?

This is the frustrating part: the UnauthorizedRegistration from FCM doesn't really tell you much. For me, the issue was with the marshalling of the JWT header. I was writing mine in Go and I was marshalling a struct that contained the "typ" and "alg" fields. I don't think the JWT spec says anything about the ordering of the fields, but FCM clearly wanted a specific header. I only realized this when I saw an implementation that used a constant header.

I resolved the 400 issue by replacing the header I was creating via marshalling with the header above.

There are some other small things you should look out for:

  1. Chrome has a bug with the Crypto-Key header: If the header has more than one entry (ie: encrypting a payload will also require the use of the crypto-key header), you will need to use a semicolon instead of a comma as your separator

  2. Base64 of your JWT needs to be URLEncoded without padding. There's apparently another Chrome bug with base64 encoding so you will need to take care of that. Here's an example from a library that takes this bug into consideration.

Edit: I apparently I need 10 reputations to post more than 2 links. Find "push-encryption-go" on Github and in the webpush/encrypt.go file, lines 118-130 takes care of the base64 bug from chrome.



回答2:

The main problem in the failed push request to FCM was in the Signature encoding. I allways thought of a signature the same as a hash, just an unencoded byte stream. However an ECDSA Signature contains an R and S portion, in java these are represented in ASN.1 DER and for JWT they need to be concatenated without further encoding.

Technically this solves my question. I'm still working on completing the library and will post the full solution here (and maybe on GitHub) when it is finished.



回答3:

I had the same problem. Solved by removing the "gcm_sender_id" from the JSON manifest.