Bug 1265593 - Forward app server keys to Autopush on Android. r?nalexander draft
authorKit Cambridge <kcambridge@mozilla.com>
Tue, 22 Mar 2016 12:09:31 -0700
changeset 373529 55ca29cc714d996384414a8b0e66382548162e45
parent 373528 c5309430c5a497b885c7c820b76aebd7ef8de7f1
child 522416 65260f535057fd0a7ca6437fcb378b3abf0b93bd
push id19776
push userkcambridge@mozilla.com
push dateTue, 31 May 2016 21:19:50 +0000
reviewersnalexander
bugs1265593
milestone49.0a1
Bug 1265593 - Forward app server keys to Autopush on Android. r?nalexander MozReview-Commit-ID: 3J4mM1k0pcY
dom/push/PushServiceAndroidGCM.jsm
mobile/android/base/java/org/mozilla/gecko/push/PushClient.java
mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
mobile/android/base/java/org/mozilla/gecko/push/PushService.java
mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java
--- a/dom/push/PushServiceAndroidGCM.jsm
+++ b/dom/push/PushServiceAndroidGCM.jsm
@@ -203,35 +203,42 @@ this.PushServiceAndroidGCM = {
 
   disconnect: function() {
     console.debug("disconnect");
   },
 
   register: function(record) {
     console.debug("register:", record);
     let ctime = Date.now();
+    let appServerKey = record.appServerKey ?
+      ChromeUtils.base64URLEncode(record.appServerKey, {
+        // The Push server requires padding.
+        pad: true,
+      }) : null;
     // Caller handles errors.
     return Messaging.sendRequestForResult({
       type: "PushServiceAndroidGCM:SubscribeChannel",
+      appServerKey: appServerKey,
     }).then(data => {
       console.debug("Got data:", data);
       return PushCrypto.generateKeys()
         .then(exportedKeys =>
           new PushRecordAndroidGCM({
             // Straight from autopush.
             channelID: data.channelID,
             pushEndpoint: data.endpoint,
             // Common to all PushRecord implementations.
             scope: record.scope,
             originAttributes: record.originAttributes,
             ctime: ctime,
             // Cryptography!
             p256dhPublicKey: exportedKeys[0],
             p256dhPrivateKey: exportedKeys[1],
             authenticationSecret: PushCrypto.generateAuthenticationSecret(),
+            appServerKey: record.appServerKey,
           })
       );
     });
   },
 
   unregister: function(record) {
     console.debug("unregister: ", record);
     return Messaging.sendRequestForResult({
--- a/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushClient.java
@@ -1,16 +1,17 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.push;
 
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 
 import org.mozilla.gecko.push.RegisterUserAgentResponse;
 import org.mozilla.gecko.push.SubscribeChannelResponse;
 import org.mozilla.gecko.push.autopush.AutopushClient;
 import org.mozilla.gecko.push.autopush.AutopushClientException;
 import org.mozilla.gecko.sync.Utils;
 
 import java.util.concurrent.Executor;
@@ -90,19 +91,19 @@ public class PushClient {
     }
 
     public void unregisterUserAgent(@NonNull String uaid, @NonNull String secret) throws LocalException, AutopushClientException {
         final Delegate<Void> delegate = new Delegate<>();
         autopushClient.unregisterUserAgent(uaid, secret, delegate);
         delegate.responseOrThrow(); // For side-effects only.
     }
 
-    public SubscribeChannelResponse subscribeChannel(@NonNull String uaid, @NonNull String secret) throws LocalException, AutopushClientException {
+    public SubscribeChannelResponse subscribeChannel(@NonNull String uaid, @NonNull String secret, @Nullable String appServerKey) throws LocalException, AutopushClientException {
         final Delegate<SubscribeChannelResponse> delegate = new Delegate<>();
-        autopushClient.subscribeChannel(uaid, secret, delegate);
+        autopushClient.subscribeChannel(uaid, secret, appServerKey, delegate);
         return delegate.responseOrThrow();
     }
 
     public void unsubscribeChannel(@NonNull String uaid, @NonNull String secret, @NonNull String chid) throws LocalException, AutopushClientException {
         final Delegate<Void> delegate = new Delegate<>();
         autopushClient.unsubscribeChannel(uaid, secret, chid, delegate);
         delegate.responseOrThrow(); // For side-effects only.
     }
--- a/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushManager.java
@@ -1,16 +1,17 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.push;
 
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 import android.util.Log;
 
 import org.json.JSONObject;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.gcm.GcmTokenClient;
 import org.mozilla.gecko.push.autopush.AutopushClientException;
 import org.mozilla.gecko.util.ThreadUtils;
 
@@ -96,34 +97,34 @@ public class PushManager {
             Log.e(LOG_TAG, "Cannot unregisterUserAgent with null registration uaid or secret!");
             return null;
         }
 
         unregisterUserAgentOnBackgroundThread(registration);
         return registration;
     }
 
-    public PushSubscription subscribeChannel(final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
+    public PushSubscription subscribeChannel(final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws ProfileNeedsConfigurationException, AutopushClientException, PushClient.LocalException, GcmTokenClient.NeedsGooglePlayServicesException, IOException {
         Log.i(LOG_TAG, "Subscribing to channel for service: " + service + "; for profile named: " + profileName);
         final PushRegistration registration = advanceRegistration(profileName, now);
-        final PushSubscription subscription = subscribeChannel(registration, profileName, service, serviceData, System.currentTimeMillis());
+        final PushSubscription subscription = subscribeChannel(registration, profileName, service, serviceData, appServerKey, System.currentTimeMillis());
         return subscription;
     }
 
-    protected PushSubscription subscribeChannel(final @NonNull PushRegistration registration, final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, final long now) throws AutopushClientException, PushClient.LocalException {
+    protected PushSubscription subscribeChannel(final @NonNull PushRegistration registration, final @NonNull String profileName, final @NonNull String service, final @NonNull JSONObject serviceData, @Nullable String appServerKey, final long now) throws AutopushClientException, PushClient.LocalException {
         final String uaid = registration.uaid.value;
         final String secret = registration.secret;
         if (uaid == null || secret == null) {
             throw new IllegalStateException("Cannot subscribeChannel with null uaid or secret!");
         }
 
         // Verify endpoint is not null?
         final PushClient pushClient = pushClientFactory.getPushClient(registration.autopushEndpoint, registration.debug);
 
-        final SubscribeChannelResponse result = pushClient.subscribeChannel(uaid, secret);
+        final SubscribeChannelResponse result = pushClient.subscribeChannel(uaid, secret, appServerKey);
         if (registration.debug) {
             Log.i(LOG_TAG, "Got chid: " + result.channelID + " and endpoint: " + result.endpoint);
         } else {
             Log.i(LOG_TAG, "Got chid and endpoint.");
         }
 
         final PushSubscription subscription = new PushSubscription(result.channelID, profileName, result.endpoint, service, serviceData);
         registration.putSubscription(result.channelID, subscription);
--- a/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/push/PushService.java
@@ -317,29 +317,30 @@ public class PushService implements Bund
                 // subscription based; there's no concept of unregistering all subscriptions
                 // simultaneously.
                 callback.sendError("Not yet implemented!");
                 return;
             }
             if ("PushServiceAndroidGCM:SubscribeChannel".equals(event)) {
                 final String service = SERVICE_WEBPUSH;
                 final JSONObject serviceData;
+                final String appServerKey = message.getString("appServerKey");
                 try {
                     serviceData = new JSONObject();
                     serviceData.put("profileName", geckoProfile.getName());
                     serviceData.put("profilePath", geckoProfile.getDir().getAbsolutePath());
                 } catch (JSONException e) {
                     Log.e(LOG_TAG, "Got exception in " + event, e);
                     callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
                     return;
                 }
 
                 final PushSubscription subscription;
                 try {
-                    subscription = pushManager.subscribeChannel(geckoProfile.getName(), service, serviceData, System.currentTimeMillis());
+                    subscription = pushManager.subscribeChannel(geckoProfile.getName(), service, serviceData, appServerKey, System.currentTimeMillis());
                 } catch (PushManager.ProfileNeedsConfigurationException | AutopushClientException | PushClient.LocalException | IOException e) {
                     Log.e(LOG_TAG, "Got exception in " + event, e);
                     callback.sendError("Got exception handling message [" + event + "]: " + e.toString());
                     return;
                 }
 
                 final JSONObject json = new JSONObject();
                 try {
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/push/autopush/AutopushClient.java
@@ -335,17 +335,17 @@ public class AutopushClient {
         final ExtendedJSONObject body = new ExtendedJSONObject();
         body.put("type", TYPE);
         body.put("token", token);
 
         resource.put(body);
     }
 
 
-    public void subscribeChannel(final String uaid, final String secret, RequestDelegate<SubscribeChannelResponse> delegate) {
+    public void subscribeChannel(final String uaid, final String secret, final String appServerKey, RequestDelegate<SubscribeChannelResponse> delegate) {
         final BaseResource resource;
         try {
             resource = new BaseResource(new URI(serverURI + "registration/" + uaid + "/subscription"));
         } catch (Exception e) {
             invokeHandleError(delegate, e);
             return;
         }
 
@@ -361,16 +361,17 @@ public class AutopushClient {
                 } catch (Exception e) {
                     delegate.handleError(e);
                     return;
                 }
             }
         };
 
         final ExtendedJSONObject body = new ExtendedJSONObject();
+        body.put("key", appServerKey);
         resource.post(body);
     }
 
     public void unsubscribeChannel(final String uaid, final String secret, final String channelID, RequestDelegate<Void> delegate) {
         final BaseResource resource;
         try {
             resource = new BaseResource(new URI(serverURI + "registration/" + uaid + "/subscription/" + channelID));
         } catch (Exception e) {
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/TestPushManager.java
@@ -14,16 +14,17 @@ import org.mozilla.gecko.gcm.GcmTokenCli
 import org.robolectric.RuntimeEnvironment;
 
 import java.util.UUID;
 
 import static org.mockito.Matchers.anyBoolean;
 import static org.mockito.Matchers.anyString;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Matchers.isNull;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
 
 @RunWith(TestRunner.class)
 public class TestPushManager {
@@ -41,17 +42,17 @@ public class TestPushManager {
         // Configure a mock PushClient.
         pushClient = mock(PushClient.class);
         doReturn(new RegisterUserAgentResponse("opaque-uaid", "opaque-secret"))
                 .when(pushClient)
                 .registerUserAgent(anyString());
 
         doReturn(new SubscribeChannelResponse("opaque-chid", "https://localhost:8085/opaque-push-endpoint"))
                 .when(pushClient)
-                .subscribeChannel(anyString(), anyString());
+                .subscribeChannel(anyString(), anyString(), isNull(String.class));
 
         PushManager.PushClientFactory pushClientFactory = mock(PushManager.PushClientFactory.class);
         doReturn(pushClient).when(pushClientFactory).getPushClient(anyString(), anyBoolean());
 
         manager = new PushManager(state, gcmTokenClient, pushClientFactory);
     }
 
     private void assertOnlyConfigured(PushRegistration registration, String endpoint, boolean debug) {
@@ -135,42 +136,42 @@ public class TestPushManager {
     public void testSubscribeChannel() throws Exception {
         manager.configure("default", "http://localhost:8080", false, System.currentTimeMillis());
         PushRegistration registration = manager.registerUserAgent("default", System.currentTimeMillis());
         assertRegistered(registration, "http://localhost:8080", false);
 
         // We should be able to register with non-null serviceData.
         final JSONObject webpushData = new JSONObject();
         webpushData.put("version", 5);
-        PushSubscription subscription = manager.subscribeChannel("default", "webpush", webpushData, System.currentTimeMillis());
+        PushSubscription subscription = manager.subscribeChannel("default", "webpush", webpushData, null, System.currentTimeMillis());
         assertSubscribed(subscription);
 
         subscription = manager.registrationForSubscription(subscription.chid).getSubscription(subscription.chid);
         Assert.assertNotNull(subscription);
         Assert.assertEquals(5, subscription.serviceData.get("version"));
 
         // We should be able to register with null serviceData.
-        subscription = manager.subscribeChannel("default", "sync", null, System.currentTimeMillis());
+        subscription = manager.subscribeChannel("default", "sync", null, null, System.currentTimeMillis());
         assertSubscribed(subscription);
 
         subscription = manager.registrationForSubscription(subscription.chid).getSubscription(subscription.chid);
         Assert.assertNotNull(subscription);
         Assert.assertNull(subscription.serviceData);
     }
 
     @Test
     public void testUnsubscribeChannel() throws Exception {
         manager.configure("default", "http://localhost:8080", false, System.currentTimeMillis());
         PushRegistration registration = manager.registerUserAgent("default", System.currentTimeMillis());
         assertRegistered(registration, "http://localhost:8080", false);
 
         // We should be able to register with non-null serviceData.
         final JSONObject webpushData = new JSONObject();
         webpushData.put("version", 5);
-        PushSubscription subscription = manager.subscribeChannel("default", "webpush", webpushData, System.currentTimeMillis());
+        PushSubscription subscription = manager.subscribeChannel("default", "webpush", webpushData, null, System.currentTimeMillis());
         assertSubscribed(subscription);
 
         // No exception is success.
         manager.unsubscribeChannel(subscription.chid);
     }
 
     public void testUnsubscribeUnknownChannel() throws Exception {
         manager.configure("default", "http://localhost:8080", false, System.currentTimeMillis());
@@ -219,17 +220,17 @@ public class TestPushManager {
     @Test
     public void testStartupAfterSubscription() throws Exception {
         PushRegistration registration = manager.configure("default", "http://localhost:8080", true, System.currentTimeMillis());
         assertOnlyConfigured(registration, "http://localhost:8080", true);
 
         registration = manager.registerUserAgent("default", System.currentTimeMillis());
         assertRegistered(registration, "http://localhost:8080", true);
 
-        PushSubscription subscription = manager.subscribeChannel("default", "webpush", null, System.currentTimeMillis());
+        PushSubscription subscription = manager.subscribeChannel("default", "webpush", null, null, System.currentTimeMillis());
         assertSubscribed(subscription);
 
         manager.startup(System.currentTimeMillis());
 
         // Rather tautological.
         registration = manager.registrationForSubscription(subscription.chid);
         PushSubscription updatedSubscription = registration.getSubscription(subscription.chid);
         Assert.assertEquals(subscription.chid, updatedSubscription.chid);
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/push/autopush/test/TestLiveAutopushClient.java
@@ -1,30 +1,36 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 package org.mozilla.gecko.push.autopush.test;
 
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
+import org.mozilla.apache.commons.codec.binary.Base64;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 import org.mozilla.gecko.background.testhelpers.WaitHelper;
 import org.mozilla.gecko.push.RegisterUserAgentResponse;
 import org.mozilla.gecko.push.SubscribeChannelResponse;
 import org.mozilla.gecko.push.autopush.AutopushClient;
 import org.mozilla.gecko.push.autopush.AutopushClient.RequestDelegate;
 import org.mozilla.gecko.push.autopush.AutopushClientException;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.net.BaseResource;
 
+import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.CoreMatchers.instanceOf;
 import static org.hamcrest.CoreMatchers.startsWith;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 
 /**
@@ -103,30 +109,54 @@ public class TestLiveAutopushClient {
 
         final RegisterUserAgentResponse registerResponse = assertSuccess(registerDelegate, RegisterUserAgentResponse.class);
         Assert.assertNotNull(registerResponse);
         Assert.assertNotNull(registerResponse.uaid);
         Assert.assertNotNull(registerResponse.secret);
 
         // We should be able to subscribe to a channel.
         final RequestDelegate<SubscribeChannelResponse> subscribeDelegate = mock(RequestDelegate.class);
-        client.subscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeDelegate);
+        client.subscribeChannel(registerResponse.uaid, registerResponse.secret, null, subscribeDelegate);
 
         final SubscribeChannelResponse subscribeResponse = assertSuccess(subscribeDelegate, SubscribeChannelResponse.class);
         Assert.assertNotNull(subscribeResponse);
         Assert.assertNotNull(subscribeResponse.channelID);
         Assert.assertNotNull(subscribeResponse.endpoint);
         Assert.assertThat(subscribeResponse.endpoint, startsWith(FxAccountUtils.getAudienceForURL(serverURL)));
+        Assert.assertThat(subscribeResponse.endpoint, containsString("/v1/"));
 
         // And we should be able to unsubscribe.
         final RequestDelegate<Void> unsubscribeDelegate = mock(RequestDelegate.class);
         client.unsubscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeResponse.channelID, unsubscribeDelegate);
 
         Assert.assertNull(assertSuccess(unsubscribeDelegate, Void.class));
 
+        // We should be able to create a restricted subscription by specifying
+        // an ECDSA public key using the P-256 curve.
+        final RequestDelegate<SubscribeChannelResponse> subscribeWithKeyDelegate = mock(RequestDelegate.class);
+        final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ECDSA");
+        keyPairGenerator.initialize(256);
+        final KeyPair keyPair = keyPairGenerator.generateKeyPair();
+        final PublicKey publicKey = keyPair.getPublic();
+        String appServerKey = Base64.encodeBase64URLSafeString(publicKey.getEncoded());
+        client.subscribeChannel(registerResponse.uaid, registerResponse.secret, appServerKey, subscribeWithKeyDelegate);
+
+        final SubscribeChannelResponse subscribeWithKeyResponse = assertSuccess(subscribeWithKeyDelegate, SubscribeChannelResponse.class);
+        Assert.assertNotNull(subscribeWithKeyResponse);
+        Assert.assertNotNull(subscribeWithKeyResponse.channelID);
+        Assert.assertNotNull(subscribeWithKeyResponse.endpoint);
+        Assert.assertThat(subscribeWithKeyResponse.endpoint, startsWith(FxAccountUtils.getAudienceForURL(serverURL)));
+        Assert.assertThat(subscribeWithKeyResponse.endpoint, containsString("/v2/"));
+
+        // And we should be able to drop the restricted subscription.
+        final RequestDelegate<Void> unsubscribeWithKeyDelegate = mock(RequestDelegate.class);
+        client.unsubscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeWithKeyResponse.channelID, unsubscribeWithKeyDelegate);
+
+        Assert.assertNull(assertSuccess(unsubscribeWithKeyDelegate, Void.class));
+
         // Trying to unsubscribe a second time should give a 410.
         final RequestDelegate<Void> reunsubscribeDelegate = mock(RequestDelegate.class);
         client.unsubscribeChannel(registerResponse.uaid, registerResponse.secret, subscribeResponse.channelID, reunsubscribeDelegate);
 
         final AutopushClientException reunsubscribeFailureException = assertFailure(reunsubscribeDelegate, Void.class);
         Assert.assertThat(reunsubscribeFailureException, instanceOf(AutopushClientException.AutopushClientRemoteException.class));
         Assert.assertTrue(((AutopushClientException.AutopushClientRemoteException) reunsubscribeFailureException).isGone());