2016年10月2日 星期日

Google Chrome Web Push (客製化訊息 - 需加密)

Google Chrome Web Push是Google的一項服務,可以讓管理者向User主動推播訊息,
跟之前介紹的對Android GCM推播很像(如何向GCM Server傳送資料如何接收GCM Server發送的Registration ID訊息,以php、Java及JSP為例,以php、Java及JSP為例),事實上Google Chrome Web Push目前也是利用GCM服務來實現的。

Google Chrome Web Push可以寫死彈跳訊息的code在client端(註冊時會記錄),也可以動態訊息給client,不過因為安全的問題,必須使用Google規定的方式來加密訊息。

在這邊就來一步步展示如何做一個簡單的Google Chrome Web Push實現:


  1. 申請建立GCM服務
    在這裡有兩個方式,
    一個是透過由Google最近在推的FCM(Firebase Cloud Messaging)建立專案及服務。
    一個是透過Google Developer Console來建立專案及服務。
    1. 首先先講透過FCM的方式:
      1. 先到Firebase官網登入,如果你有Google帳戶且登入的話,進去就是登入狀態了。
      2. 建立一個專案(Project),或匯入(import)一個你已經建立的Google專案(它可以import自己的Google專案)。
      3. 到專案的設定頁(請按左上角的齒輪圖案),在上方的Tab中選擇"Cloud Messaging",就可以在"專案金鑰"中看到自己的 "伺服器金鑰 (Server Key)" 及 "寄件者ID (Sender ID)",這兩個值之後會用到。
    2. 再來講透過GCM的方式:
      1. 到Google Developer Console建立一個專案,在專案的設定頁可以看到此專案的"專案編號 (Project Number)",對應到FCM的寄件者ID
      2. 到API管理員啟用Google Cloud Messaging的Google API
      3. 然後到"API管理員"那頁去選擇左邊列出來的"憑證"選項,建立一個API金鑰(API Key),對應到FCM的伺服器金鑰
  2. 建立可以註冊及取消註冊的網頁
    1. 我們建立一個index.html,裡面放一個被Disbale的按鈕(寫著Subscribe),當檢查可以註冊(subscribe)推播時,按鈕會變成可按,按了可以註冊推播。
    2. 當註冊推播成功後,在畫面上顯示註冊相關訊息,是一個JSON格式的字串,其中Registration_IDendpoint是一個網址,最後面的字串是此User的Registration_ID (在https://android.googleapis.com/gcm/send//後面)
      p256dh: 加密訊息要用的其中之一key
      auth加密訊息要用的其中之一key
      以上三個值都要記住或傳給server紀錄起來。
    3. index.html的內容如下:
      <!DOCTYPE html>
      <html>
      <head>
       <title>推送通知 codelab</title>
       <link rel="manifest" href="manifest.json">
      </head>
      <body>
       <h1>推送通知 codelab</h1>
       <p>這網頁必須使用HTTPS或通過localhost。</p>
       <p id="pushsubscription">None</p>
       <button disabled>Subscribe</button>
       
       <script src="js/main.js"></script>
      </body>
      </html>
    4. 其中index.html有引入的manifest.json內容如下:
      {
        "name": 隨傳取",
        "gcm_sender_id": "就是FCM的SenderID或GCM的Project Number"
      }
    5. 其中index.html有引入的main.js (因為沒有做document的ready事件監聽,請放到<body>的最下面)內容如下:
      var reg;
      var sub;
      var isSubscribed = false;
      //取得button DOM
      var subscribeButton = document.querySelector('button');
      if ('serviceWorker' in navigator) {
        //如果有serviceWorker可以使用
        console.log('Service Worker is supported');
        //註冊sw.js,其中有實作與推播相關的行為
        navigator.serviceWorker.register('sw.js').then(function() {
          return navigator.serviceWorker.ready;
        }).then(function(serviceWorkerRegistration) {
       //可以開始註冊推播,將讓冊按鈕變成able狀態
          reg = serviceWorkerRegistration;
          subscribeButton.disabled = false;
          console.log('Service Worker is ready :^)', reg);
        }).catch(function(error) {
          console.log('Service Worker Error :^(', error);
        });
      }
      //設定註夕按鈕的click行為
      subscribeButton.addEventListener('click', function() {
        if (isSubscribed) {
          unsubscribe();
        } else {
          subscribe();
        }
      });
      function subscribe() {
        reg.pushManager.subscribe({userVisibleOnly: true}).
        then(function(pushSubscription){
       //註冊推播成功,印出推播相關訊息,改變註冊按鈕狀態
          sub = pushSubscription;
          console.log('Subscribed! Endpoint:', sub.endpoint);
          subscribeButton.textContent = 'Unsubscribe';
          isSubscribed = true;
          document.getElementById("pushsubscription").innerHTML = JSON.stringify(pushSubscription);
        });
      }
      function unsubscribe() {
        sub.unsubscribe().then(function(event) {
       //取消註冊推播成功,印出推播相關訊息,改變註冊按鈕狀態
          subscribeButton.textContent = 'Subscribe';
          console.log('Unsubscribed!', event);
          isSubscribed = false;
        }).catch(function(error) {
          console.log('Error unsubscribing', error);
          subscribeButton.textContent = 'Subscribe';
        });
      }
    6. main.js中有引用的sw.js內容如下:
      //設定推播相關設定
      console.log('Started', self);
      //設推播的install事件要做的行為A
      self.addEventListener('install', function(event) {
        self.skipWaiting();
        console.log('Installed', event);
      });
      //設推播的activate事件要做的行為A
      self.addEventListener('activate', function(event) {
        console.log('Activated', event);
      });
      //設推播的push事件要做的行為A
      self.addEventListener('push', function(event) {
        console.log('Push message', event);
        var title = 'Push message';
        event.waitUntil(
       //產生Pop up的彈跳訊息事窗,桌機會出現在右下角
       //其中,event.data.json()可以得到傳送的json字串(解密後)
       //myText是要傳的json中我設的一個key
          self.registration.showNotification(title, {
            body: event.data.json().myText,
            icon: 'images/icon.png',
            tag: 'my-tag'
          }));
      });
  3. 對想要傳送的客製訊息進行加密
    Google規定如果要傳送自訂訊息時,必須對訊息內容加密,相關資料在這頁
    加密需要三個東西:
    1. 要加密的內容:一個JSON格式的字串
    2. p256dh : 只中一個Key
    3. auth : 另一個Key
    要輸出的有三個值:
    1. cipherText :加密後的訊息,要放在傳送的raw_data中
    2. encryptionHeader: : 要放在傳送的Encryption Header中,格式是 salt=XXXX
    3. cryptoKeyHeader: 要放在傳送的Crypto-KeyHeader中,格式是 dh=XXXX
    Google有寫了一個給node.js使用的加密用lib,可以參考另一篇Chrome WebPush - node.js (客製化訊息 - 需加密),還沒有Java版本的,但我在網路上找到了有人已經寫了一個lib,經修改後發現可以順利使用,特別在這裡做一個紀錄,也感謝這位akarve。
    這裡有幾點要注意的步驟:
    1. 這裡我使用了以下lib
    2. 下載並引入httpcomponents-client-4.3.4
    3. bouncycastle官網取得並下載bouncycastle的加密相關jar,並依照此頁說明的方式將jar檔放到 $JAVA_HOME$\jre\lib\ext 資料夾中,修改配置文件 jre\lib\security\java.security,在
      security.provider.1=sun.security.provider.Sun
      security.provider.2=sun.security.rsa.SunRsaSign
      security.provider.3=com.sun.net.ssl.internal.ssl.Provider
      security.provider.4=com.sun.crypto.provider.SunJCE
      security.provider.5=sun.security.jgss.SunProvider
      security.provider.6=com.sun.security.sasl.Provider
      下面多加一條:
      security.provider.請自己填入下一個數字=org.bouncycastle.jce.provider.BouncyCastleProvider
    4. 稍微修改了lib裡面的程式碼,利用它們來取得加密的資料:
      Constants.java :
      package webPush;
      
      import java.nio.charset.StandardCharsets;
      
      /**
       * Created by akarve on 4/26/16.
       */
      
      public final class Constants {
      
       private Constants() {
        // no op
       }
      
       public static final byte[] CONTENT_ENCODING = "Content-Encoding: ".getBytes(StandardCharsets.UTF_8);
       public static final byte[] AESGCM128 = "aesgcm".getBytes(StandardCharsets.UTF_8);
       public static final byte[] NONCE = "nonce".getBytes(StandardCharsets.UTF_8);
       public static final byte[] P256 = "P-256".getBytes(StandardCharsets.UTF_8);
       public static final int GCM_TAG_LENGTH = 16; // in bytes
       public static final String SECP256R1 = "secp256r1";
       public static final String HMAC_SHA256 = "HmacSHA256";
       public static final byte NULL_BYTE = (byte) 0;
       public static final byte KEY_LENGTH_BYTE = (byte) 65; // This is always 65
                     // for our curve
       public static final int MAX_PAYLOAD_LENGTH = 4078;
      }
      EllipticCurveKeyUtil.java :
      package webPush;
      
      import java.math.BigInteger;
      import java.security.InvalidAlgorithmParameterException;
      import java.security.InvalidKeyException;
      import java.security.KeyFactory;
      import java.security.KeyPair;
      import java.security.KeyPairGenerator;
      import java.security.NoSuchAlgorithmException;
      import java.security.NoSuchProviderException;
      import java.security.PublicKey;
      import java.security.SecureRandom;
      import java.security.Security;
      import java.security.interfaces.ECPrivateKey;
      import java.security.interfaces.ECPublicKey;
      import java.security.spec.ECPoint;
      import java.security.spec.ECPublicKeySpec;
      import java.security.spec.InvalidKeySpecException;
      import java.util.Base64;
      import javax.crypto.KeyAgreement;
      import org.apache.commons.codec.DecoderException;
      import org.bouncycastle.jce.ECNamedCurveTable;
      import org.bouncycastle.jce.ECPointUtil;
      import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec;
      import org.bouncycastle.jce.spec.ECNamedCurveSpec;
      import org.bouncycastle.jce.spec.ECParameterSpec;
      import org.bouncycastle.jce.spec.ECPrivateKeySpec;
      
      import static org.apache.commons.codec.binary.Hex.decodeHex;
      
      /**
       * Created by akarve on 4/26/16.
       */
      public class EllipticCurveKeyUtil {
      
       public static final String CRYPTO_TYPE_ECDH = "ECDH";
       public static final String PROVIDER_BOUNCY_CASTLE = "BC";
       private final KeyFactory _keyFactory;
       private final ECNamedCurveParameterSpec _ecNamedCurveParameterSpec;
       private final ECNamedCurveSpec _ecNamedCurveSpec;
       private final ECParameterSpec _ecParameterSpec;
       private final KeyPairGenerator _keyPairGenerator;
      
       public EllipticCurveKeyUtil() throws NoSuchProviderException, NoSuchAlgorithmException, InvalidAlgorithmParameterException {
        Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
      
        _keyFactory = KeyFactory.getInstance(CRYPTO_TYPE_ECDH, PROVIDER_BOUNCY_CASTLE);
        _ecNamedCurveParameterSpec = ECNamedCurveTable.getParameterSpec(Constants.SECP256R1); // P256
                              // curve
        _ecNamedCurveSpec = new ECNamedCurveSpec(Constants.SECP256R1, _ecNamedCurveParameterSpec.getCurve(), _ecNamedCurveParameterSpec.getG(), _ecNamedCurveParameterSpec.getN());
      
        _ecParameterSpec = ECNamedCurveTable.getParameterSpec(Constants.SECP256R1); // P256
                           // curve
        _keyPairGenerator = KeyPairGenerator.getInstance(CRYPTO_TYPE_ECDH, PROVIDER_BOUNCY_CASTLE);
        _keyPairGenerator.initialize(_ecParameterSpec, new SecureRandom());
       }
      
       /**
        * Creates a ECDH keypair from the curve parameters for the p256 curve
        * 
        * @param x
        *            Affine X for the public key point
        * @param y
        *            Affine Y for the public key point
        * @param s
        *            S parameter for the private key
        * @return ECDH keypair
        * @throws NoSuchProviderException
        * @throws NoSuchAlgorithmException
        * @throws InvalidKeySpecException
        */
       public KeyPair loadECKeyPair(BigInteger x, BigInteger y, BigInteger s) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException {
        ECPoint ecPoint = new ECPoint(x, y);
        ECPublicKeySpec pubKeySpec = new ECPublicKeySpec(ecPoint, _ecNamedCurveSpec);
        ECPrivateKeySpec privateKeySpec = new ECPrivateKeySpec(s, _ecNamedCurveParameterSpec);
        ECPublicKey publicKey = (ECPublicKey) _keyFactory.generatePublic(pubKeySpec);
        ECPrivateKey privateKey = (ECPrivateKey) _keyFactory.generatePrivate(privateKeySpec);
        return new KeyPair(publicKey, privateKey);
       }
      
       /**
        * Generates a new keypair for ECDH using the p256 curve. Creating a new
        * keypair per request is expensive, but preferred
        * 
        * @return ECDH Keypair
        * @throws NoSuchAlgorithmException
        * @throws InvalidAlgorithmParameterException
        * @throws NoSuchProviderException
        */
       public KeyPair generateServerKeyPair() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException, NoSuchProviderException {
      
        return _keyPairGenerator.generateKeyPair();
       }
      
       /**
        * Converts the ECDH public key to bytes (has to be 65 bytes in length)
        * 
        * @param publicKey
        *            Public key for ECDH p256 curve
        * @return bytearray representation of key
        */
       public byte[] publicKeyToBytes(final ECPublicKey publicKey) throws DecoderException {
        ECPoint point = publicKey.getW();
        String x = point.getAffineX().toString(16);
        String y = point.getAffineY().toString(16);
      
        /*
         * Format is 04 followed by 32 bytes (64 hex) each for the X,Y
         * coordinates
         */
        StringBuilder sb = new StringBuilder();
        sb.append("04");
      
        for (int i = 0; i < 64 - x.length(); i++) {
         sb.append(0);
        }
        sb.append(x);
      
        for (int i = 0; i < 64 - y.length(); i++) {
         sb.append(0);
        }
        sb.append(y);
        return decodeHex(sb.toString().toCharArray());
       }
      
       /**
        * Converts the ECDH private key to bytes (has to be 32 bytes in length)
        * 
        * @param privateKey
        *            Private key for ECDH p256 curve
        * @return bytearray representation of private key
        */
       public byte[] privateKeyToBytes(final ECPrivateKey privateKey) throws DecoderException {
        /*
         * Format is 32 bytes (64 hex) of S
         */
        StringBuilder sb = new StringBuilder();
        String s = privateKey.getS().toString(16);
        for (int i = 0; i < 64 - s.length(); i++) {
         sb.append(0);
        }
        sb.append(s);
        return decodeHex(sb.toString().toCharArray());
       }
      
       /**
        * Creates a public key from the p256dh encoded using URL-safe Base64
        * 
        * @param p256dh
        *            p256dh string
        * @return Public Key
        * @throws NoSuchProviderException
        * @throws NoSuchAlgorithmException
        * @throws InvalidKeySpecException
        */
       public ECPublicKey loadP256Dh(final String p256dh) throws NoSuchProviderException, NoSuchAlgorithmException, InvalidKeySpecException {
        final byte[] p256dhBytes = Base64.getUrlDecoder().decode(p256dh);
        final ECPoint point = ECPointUtil.decodePoint(_ecNamedCurveSpec.getCurve(), p256dhBytes);
        ECPublicKeySpec pubKeySpec = new ECPublicKeySpec(point, _ecNamedCurveSpec);
        return (ECPublicKey) _keyFactory.generatePublic(pubKeySpec);
       }
      
       /**
        * Computes the shared secret for ECDH using the server keys and the client
        * public key
        * 
        * @param serverKeys
        *            Server keypair
        * @param clientPublicKey
        *            Client public key
        * @return p256dh shared secret
        * @throws NoSuchAlgorithmException
        * @throws InvalidKeyException
        * @throws NoSuchProviderException
        */
       public byte[] generateSharedSecret(final KeyPair serverKeys, final PublicKey clientPublicKey) throws NoSuchAlgorithmException, InvalidKeyException, NoSuchProviderException {
        KeyAgreement keyAgreement = KeyAgreement.getInstance(CRYPTO_TYPE_ECDH, PROVIDER_BOUNCY_CASTLE);
        keyAgreement.init(serverKeys.getPrivate());
        keyAgreement.doPhase(clientPublicKey, true);
        return keyAgreement.generateSecret();
       }
      }
      PushApiUtil.java :
      package webPush;
      
      import java.io.ByteArrayOutputStream;
      import java.io.IOException;
      import java.nio.charset.StandardCharsets;
      import java.security.InvalidAlgorithmParameterException;
      import java.security.InvalidKeyException;
      import java.security.NoSuchAlgorithmException;
      import java.security.NoSuchProviderException;
      import java.security.SecureRandom;
      import java.util.Arrays;
      import java.util.Base64;
      
      import javax.crypto.BadPaddingException;
      import javax.crypto.Cipher;
      import javax.crypto.IllegalBlockSizeException;
      import javax.crypto.Mac;
      import javax.crypto.NoSuchPaddingException;
      import javax.crypto.SecretKey;
      import javax.crypto.spec.GCMParameterSpec;
      import javax.crypto.spec.SecretKeySpec;
      
      
      /**
       * Created by akarve on 5/4/16.
       */
      
      public class PushApiUtil {
       private static final SecureRandom RANDOM_BYTE_GENERATOR = new SecureRandom();
      
       private PushApiUtil() {
        // no op
       }
      
       /**
        * Returns an info record. See sections 3.2 and 3.3 of
        * {https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00}
        * 
        * @param serverPublicKey
        *            server public key
        * @param clientPublicKey
        *            client public key
        * @param type
        *            info type
        * @return
        * @throws IOException
        */
       public static byte[] generateInfo(final byte[] serverPublicKey, final byte[] clientPublicKey, final byte[] type) throws IOException {
      
        // The start index for each element within the buffer is:
        // value | length | start |
        // ---------------------------------------
        // 'Content-Encoding: '| 18 | 0 |
        // type | l | 18 |
        // null byte | 1 | 18 + l |
        // 'P-256' | 5 | 19 + l |
        // info | 135 | 24 + l |
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        outputStream.write(Constants.CONTENT_ENCODING); // Append the string
                    // ��?�Content-Encoding:
                    // ��??
        outputStream.write(type); // Append the |type|
        outputStream.write(Constants.NULL_BYTE); // Append a NULL-byte
        outputStream.write(Constants.P256); // Append the string ��?�P-256��??
      
        // The context format is:
        // 0x00 || length(clientPublicKey) || clientPublicKey ||
        // length(serverPublicKey) || serverPublicKey
        // The lengths are 16-bit, Big Endian, unsigned integers so take 2 bytes
        // each.
      
        // The keys should always be 65 bytes each. The format of the keys is
        // described in section 4.3.6 of the (sadly not freely linkable) ANSI
        // X9.62
        // specification.
        outputStream.write(Constants.NULL_BYTE); // Append a NULL-byte
      
        outputStream.write(Constants.NULL_BYTE); // Append the length of the
                   // recipient��?��s public key
                   // (here |client_public|)
        outputStream.write(Constants.KEY_LENGTH_BYTE); // as a two-byte integer
                    // in network byte
                    // order.
      
        outputStream.write(clientPublicKey); // Append the raw bytes (65) of the
                  // recipient��?��s
        // public key.
      
        outputStream.write(Constants.NULL_BYTE); // Append the length of the
                   // sender��?��s public key
                   // (here |server_public|)
        outputStream.write(Constants.KEY_LENGTH_BYTE); // as a two-byte integer
                    // in network byte
                    // order.
      
        outputStream.write(serverPublicKey); // Append the raw bytes (65) of the
        // sender��?��s
        // public key.
      
        return outputStream.toByteArray();
       }
      
       public static String createEncryptionHeader(final byte[] salt) {
        // Encode |salt| using the URL-safe base64 encoding, store it in
        // |encoded_salt|.
        // Return the result of concatenating (��?�salt=��??, |encoded_salt|).
        return "salt=" + Base64.getUrlEncoder().encodeToString(salt);
       }
      
       public static String createCryptoKeyHeader(final byte[] serverPublic) {
        // Encode |server_public| using the URL-safe base64 encoding, store it
        // in |encoded_server_public|.
        // Return the result of concatenating (��?�dh=��??,
        // |encoded_server_public|).
        return "dh=" + Base64.getUrlEncoder().encodeToString(serverPublic);
       }
      
       /**
        * Performs an hkdf extract on the message and trims to (lengthToExtract) *
        * HMAC-based Extract-and-Expand Key Derivation Function (HKDF)
        *
        * This is used to derive a secure encryption key from a mostly-secure
        * shared secret.
        *
        * This is a partial implementation of HKDF tailored to our specific
        * purposes. In particular, for us the value of N will always be 1, and thus
        * T always equals HMAC-Hash(PRK, info | 0x01).
        *
        * See {https://www.rfc-editor.org/rfc/rfc5869.txt}
        * 
        * @param secretKey
        *            key to perform the HMAC with
        * @param salt
        *            random salt bytes
        * @param messageToExtract
        *            message to perform hkdf extract on
        * @param lengthToExtract
        *            how much to trim the output by
        * @return hkdf extract
        * @throws NoSuchAlgorithmException
        * @throws InvalidKeyException
        */
       public static byte[] hkdfExtract(final byte[] secretKey, final byte[] salt, final byte[] messageToExtract, final int lengthToExtract) throws NoSuchAlgorithmException, InvalidKeyException {
        Mac outerMac = Mac.getInstance(Constants.HMAC_SHA256);
        outerMac.init(new SecretKeySpec(salt, Constants.HMAC_SHA256));
        byte[] outerResult = outerMac.doFinal(secretKey);
        Mac innerMac = Mac.getInstance(Constants.HMAC_SHA256);
        innerMac.init(new SecretKeySpec(outerResult, Constants.HMAC_SHA256));
        byte[] message = new byte[messageToExtract.length + 1];
        System.arraycopy(messageToExtract, 0, message, 0, messageToExtract.length);
        message[messageToExtract.length] = (byte) 1;
        byte[] innerResult = innerMac.doFinal(message);
        return Arrays.copyOf(innerResult, lengthToExtract);
       }
      
       /**
        * Encrypts a message such that it can be sent using the Web Push protocol.
        * You can find out more about the various pieces: -
        * {https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding} -
        * {https://en.wikipedia.org/wiki/Elliptic_curve_Diffie%E2%80%93Hellman} -
        * {https://tools.ietf.org/html/draft-ietf-webpush-encryption}
        * 
        * @param message
        *            Message to encrypt
        * @param sharedSecret
        *            Shared secret computed using the server keys and the client
        *            public key
        * @param salt
        *            16 random bytes
        * @param contentEncryptionKeyInfo
        * @param nonceInfo
        * @param clientAuth
        *            Client's auth (generated on the browser)
        * @return
        * @throws InvalidKeyException
        * @throws NoSuchAlgorithmException
        * @throws IllegalBlockSizeException
        * @throws InvalidAlgorithmParameterException
        * @throws BadPaddingException
        * @throws NoSuchProviderException
        * @throws NoSuchPaddingException
        */
       public static String encryptPayload(final String message, final byte[] sharedSecret, final byte[] salt, final byte[] contentEncryptionKeyInfo, final byte[] nonceInfo, final byte[] clientAuth) throws InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, InvalidAlgorithmParameterException, BadPaddingException, NoSuchProviderException, NoSuchPaddingException {
      
        // Derive a Pseudo-Random Key (prk) that can be used to further derive
        // our
        // other encryption parameters. These derivations are described in
        // https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-00
        final byte[] prk = hkdfExtract(sharedSecret, clientAuth, "Content-Encoding: auth\0".getBytes(StandardCharsets.UTF_8), 32);
      
        // Derive the Content Encryption Key
        final byte[] contentEncryptionKey = hkdfExtract(prk, salt, contentEncryptionKeyInfo, 16);
      
        // Derive the Nonce / iv
        final byte[] nonce = hkdfExtract(prk, salt, nonceInfo, 12);
      
        // Not adding any padding for now. First two bytes are reserved for
        // number of padding bytes (0)
        final byte[] record = ("\0\0" + message).getBytes(StandardCharsets.UTF_8);
      
        // Set |ciphertext| to the result of encrypting |record| with
        // AEAD_AES_128_GCM, using
        // the |content_encryption_key| as the key, the |nonce| as the nonce/IV,
        // and an authentication tag of 16 bytes.
      
        final byte[] ciphertext = encryptWithAESGCM128(nonce, contentEncryptionKey, record);
        // Encode the |ciphertext| using the URL-safe base64 encoding, store it
        // in |encoded_ciphertext|.  
        return Base64.getEncoder().encodeToString(ciphertext);
       }
      
       /**
        * Encrypt the plaintext message using AES128/GCM
        * 
        * @param nonce
        *            The iv
        * @param contentEncryptionKey
        *            The private key to use
        * @param record
        *            The message to be encrypted
        * @return the encrypted payload
        * @throws NoSuchPaddingException
        * @throws NoSuchAlgorithmException
        * @throws NoSuchProviderException
        * @throws InvalidAlgorithmParameterException
        * @throws InvalidKeyException
        * @throws BadPaddingException
        * @throws IllegalBlockSizeException
        */
       public static byte[] encryptWithAESGCM128(final byte[] nonce, final byte[] contentEncryptionKey, final byte[] record) throws NoSuchPaddingException, NoSuchAlgorithmException, NoSuchProviderException, IllegalArgumentException, InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException {
        if (record.length > Constants.MAX_PAYLOAD_LENGTH) {
         throw new IllegalArgumentException("Record is too big, dropping notification");
        }
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding", "SunJCE");
        SecretKey key = new SecretKeySpec(contentEncryptionKey, "AES");
        GCMParameterSpec spec = new GCMParameterSpec(Constants.GCM_TAG_LENGTH * 8, nonce);
        cipher.init(Cipher.ENCRYPT_MODE, key, spec);
        return cipher.doFinal(record);
       }
      
       /**
        * Generates 16 cryptographically secure random bytes
        * 
        * @return 16 byte salt
        */
       public static byte[] generateSalt() {
        byte[] salt = new byte[16];
        RANDOM_BYTE_GENERATOR.nextBytes(salt);
        return salt;
       }
      }
    5. .然後我參考使用範例寫了一個EncryptWebPushHelper.java,可以輸出上述加密後的三個結果並傳送加密的推播訊息給GCM伺服器:
      EncryptWebPushHelper.java :
      package webPush;
      
      import java.io.IOException;
      import java.security.InvalidAlgorithmParameterException;
      import java.security.InvalidKeyException;
      import java.security.KeyPair;
      import java.security.NoSuchAlgorithmException;
      import java.security.NoSuchProviderException;
      import java.security.interfaces.ECPublicKey;
      import java.security.spec.InvalidKeySpecException;
      import java.util.Base64;
      
      import javax.crypto.BadPaddingException;
      import javax.crypto.IllegalBlockSizeException;
      import javax.crypto.NoSuchPaddingException;
      
      import org.apache.http.HttpResponse;
      import org.apache.http.client.methods.HttpPost;
      import org.apache.http.entity.StringEntity;
      import org.apache.http.impl.client.CloseableHttpClient;
      import org.apache.http.impl.client.HttpClientBuilder;
      import org.bouncycastle.util.encoders.DecoderException;
      
      public class EncryptWebPushHelper {
      
       static String cipherText;
       static String encryptionHeader;
       static String cryptoKeyHeader;
      
       public static void main(String[] args) {
        String p256dh = "就是p256dh 
      ";
                  String auth = "就是auth 
      ";
                  String apiKey = "就是api key 
       
      ";
                  String regId = "User的registration id";
                  
        try {
         //加密後得到cipherText, encryptionHeader, cryptoKeyHeader
         JSONObject message_JSONObject = new JSONObject();
                              message_JSONObject.put("myText", "Hugo ,^o^ test");
         generateEncryptedPayload(message_JSONObject.toString(), p256dh  
       , auth );
      
         System.out.println("cipherText: " + cipherText);
         System.out.println("encryptionHeader: " + encryptionHeader);
         System.out.println("cryptoKeyHeader: " + cryptoKeyHeader);
      
         //向GCM Server送推播資料
         HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
         CloseableHttpClient closeableHttpClient = httpClientBuilder.build();
      
         HttpPost httpRequest = new HttpPost("https://gcm-http.googleapis.com/gcm/send");
      
         httpRequest.addHeader("Authorization", "key=" + apiKey);
         httpRequest.addHeader("Content-Type", "application/json");
         httpRequest.addHeader("Encryption", encryptionHeader);
         httpRequest.addHeader("Crypto-Key", cryptoKeyHeader);
         httpRequest.addHeader("Content-Encoding", "aesgcm");
         httpRequest.addHeader("Cache-Control", "no-cache");
         
         //要送的JSON內容(加密後)
         JSONObject sendInfo_jsonObject = new JSONObject();
                              sendInfo_jsonObject.put("registration_ids", new JSONArray().put(regId));
         sendInfo_jsonObject.put("raw_data", cipherText);
         //要送的JSON內容(加密後)
         httpRequest.setEntity(new StringEntity(sendInfo_jsonObject.toString()));
         
         HttpResponse httpResponse = closeableHttpClient.execute(httpRequest);
         closeableHttpClient.close();
         
         /* 也可用curl來送資料測試
           curl  --header "Authorization: key=就是API Key" 
             --header "Content-Type:application/json" 
             --header "Encryption: 就是encryptionHeader 
             --header "Crypto-Key: 就是cryptoKeyHeader" 
             --header "Content-Encoding:aesgcm" 
             --header "Cache-Control: no-cache" 
             https://gcm-http.googleapis.com/gcm/send 
             -d "{   \"registration_ids\": [\"客戶端的registration id\"],
                \"raw_data\": \"就是cipherText" }"
          */
        } catch (Exception e) {
         // TODO Auto-generated catch block
         e.printStackTrace();
        }
       }
       
       public static void generateEncryptedPayload(final String payload, String P256dh, String AuthKey)
            throws InvalidAlgorithmParameterException, NoSuchAlgorithmException, NoSuchProviderException,
                   InvalidKeySpecException, InvalidKeyException, IOException, NoSuchPaddingException, BadPaddingException,
                   IllegalBlockSizeException, DecoderException, org.apache.commons.codec.DecoderException {
          
          EllipticCurveKeyUtil _ellipticCurveKeyUtil = new EllipticCurveKeyUtil();
          
          KeyPair serverKeys = _ellipticCurveKeyUtil.generateServerKeyPair();
      
          final ECPublicKey clientPublicKey = _ellipticCurveKeyUtil.loadP256Dh(P256dh);
          final byte[] clientAuth = Base64.getUrlDecoder().decode(AuthKey);
          final byte[] salt = PushApiUtil.generateSalt();
          final byte[] sharedSecret = _ellipticCurveKeyUtil.generateSharedSecret(serverKeys, clientPublicKey);
          final byte[] serverPublicKeyBytes = _ellipticCurveKeyUtil.publicKeyToBytes((ECPublicKey) serverKeys.getPublic());
          final byte[] clientPublicKeyBytes = _ellipticCurveKeyUtil.publicKeyToBytes(clientPublicKey);
          final byte[] nonceInfo = PushApiUtil.generateInfo(serverPublicKeyBytes, clientPublicKeyBytes, Constants.NONCE);
          final byte[] contentEncryptionKeyInfo = PushApiUtil.generateInfo(serverPublicKeyBytes, clientPublicKeyBytes, Constants.AESGCM128);
      
          cipherText = PushApiUtil.encryptPayload(payload, sharedSecret, salt, contentEncryptionKeyInfo, nonceInfo, clientAuth);
          encryptionHeader = PushApiUtil.createEncryptionHeader(salt);
          cryptoKeyHeader = PushApiUtil.createCryptoKeyHeader(serverPublicKeyBytes);
        }
      
      }
      
  4. 如果有裝curl的話也可以向上述程式碼裡的注解寫的那樣,用命令列視窗來送推播訊息給GCM伺服器做測試:
    curl --header "Authorization: key=就是FCM的ServerKey或GCM的API_Key"
         --header "Content-Type:application/json"
         --header "Encryption: salt=XXX"
         --header "Crypto-Key: dh=XXX"
         --header "Content-Encoding: aesgcm"
         --header "Cache-Control: no-cache"
         https://gcm-http.googleapis.com/gcm/send
         -d "{
             \"registration_ids\": [\"User的Registration_id\"],
             \"raw_data\": \"加密後的cipherText\"
         }"
  5. 如果成功的話,應該就會在User的視窗看到跳出的彈跳視窗了。
再來是測試:

  1. 先到index.html
    會先看到如下的畫面,

    如果沒有問題(有問題請按F12開啟開發者視窗看錯誤訊息),等一下Subscribe按鈕會變成可按,按下後第一次會跳出詢問的視窗如下:
    按"允許"後,就會註冊並出現註冊資訊(如下圖),其中有User的Registration Idp256dhauth,把它們三個抄下來後,等下要用它們來得到加密的訊息(實做時可設計送至自己Server存起來)。
  2. API Key, Registration Idp256dhauth,還有想要送的文字放到EncryptWebPushHelper.java中,並直接執行EncryptWebPushHelper.java,應該就可以看到電腦的右下角出現彈跳訊息視窗了,如下圖。
原始碼下載:
GoogleWebPushTest.7z

所需的jar檔:

  1. httpcomponents-client-4.3.4
  2. JSON
  3. bcprov-ext-jdk15on-155.jar

7 則留言 :

  1. Hi,你好!
    我收到你的e-mail回覆,挺有幫助,
    所以之後有問題請教以blog留言為主是嗎?
    謝謝

    回覆刪除
    回覆
    1. 很高與幫到你,
      Email也可以,只是我blog留言比較會注意到而已。

      刪除
  2. Hi,我有問題想請教,
    我是參考這個網站用VB.NET去寫的:
    http://stackoverflow.com/questions/11261718/gcm-push-notification-with-asp-net
    其中 ContentType = "application/x-www-form-urlencoded",
    可是你的Content-Type:application/json,
    那會影響sw.js中event.data.json()接收的type嗎?
    我推測連結裡的payload就是客製化訊息,
    但我不知道怎麼在sw.js接收payload的訊息,
    還是因為是沒加密的關係呢?謝謝!

    回覆刪除
    回覆
    1. 抱歉我不太熟VB.NET。
      Content-Type:application/json是用在傳Webpush請求給Webpush Server時設置的Header,它告訴Webpush Server我們傳送的是JSON格式的資料,以利對方辨認解析,建議使用這個Content-Type。
      sw.js的event.data是在客戶端接收到Webpush傳來的訊息,其中帶有的json()方法可以得到解密後的訊息,為一個JSON物件,建議可以把event.data或event.data.json()印出來看看有無接收成功。
      要客製化訊息的話需要將加密的資料(HTTP請求內容、Header)傳給Webpush Server才行。

      刪除
    2. Hi,謝謝你的回覆,
      後來研究確實是加密的問題,
      連結裡沒加密所以event.data都是null,
      我後來用libs加密成功接收到值,
      謝謝你!
      不過我另外有個疑問,
      不知道VapidHeader的功用是什麼?

      刪除
    3. 你指的是Encryption和Crypto-Key Header嗎?
      就是官方說明加密訊息時要附上的Header,可參考
      https://developers.google.com/web/updates/2016/07/web-push-interop-wins
      似乎是用來讓Server端辨識的,可能就不用在manifest.json上打gcm_sender_id了吧?
      不確定,可Google關鍵字
      VAPID(Voluntary Application Server Identification)自主應用伺服器標識

      刪除