Validate access tokens

The restriction controller app verifies an issued token as follows:

1. Extract the SSL certificate chain from the x5c header;
2. Validate the SSL certificate chain;
3. Verify the leaf certificate has the correct hostname/SNI;
4. Use the leaf certificate to verify the signature of the token;
5. Check the nonce to make sure it matches the token request;
6. Check the token is in its validation period.

Bug: 173732354
Bug: 173732514
Test: Place google-services.json in app/
Test: If using self-signed CA, place RootCA.pem in app/
Test: Set TOKEN_ISSUER_API_NAME in app/build.gradle
Test: Set TOKEN_ISSUER_HOST_NAME in app/build.gradle
Test: Get test credentials from valentine/#/show/1611079519238994
Test: Set envvars DRC_TEST_EMAIL and DRC_TEST_PASSWORD
Test: ./gradlew connectedAndroidTest
Test: ./gradlew createDebugCoverageReport

Change-Id: I00342444876b992523da6923c1b6c03774a6896d
diff --git a/.gitignore b/.gitignore
index e616437..35d81b6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@
 .DS_Store
 build/
 app/google-services.json
+app/*.pem
diff --git a/app/build.gradle b/app/build.gradle
index f5c4ee1..f8536d5 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -13,6 +13,11 @@
         versionCode 1
         versionName "1.0"
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        buildConfigField "boolean", "TOKEN_USES_SELF_SIGNED_CA", "true"
+        buildConfigField "String", "ROOT_CA_CERT",
+                "\"${new File("app/RootCA.pem").getText('UTF-8').replaceAll("\\R", "\\\\n")}\""
+        buildConfigField "String", "TOKEN_ISSUER_API_NAME", "\"requestAccessToken\""
+        buildConfigField "String", "TOKEN_ISSUER_HOST_NAME", "\"testserver.com\""
     }
 
     buildTypes {
@@ -36,6 +41,10 @@
     testOptions {
         animationsDisabled = true
     }
+
+    packagingOptions {
+        exclude 'META-INF/DEPENDENCIES'
+    }
 }
 
 dependencies {
@@ -45,6 +54,7 @@
     implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
     implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'
     implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
+    implementation 'com.google.api-client:google-api-client:1.30.10'
     implementation platform('com.google.firebase:firebase-bom:26.2.0')
     implementation 'com.google.firebase:firebase-auth'
     implementation 'com.google.firebase:firebase-functions'
@@ -54,4 +64,4 @@
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
     androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.3.0'
     androidTestImplementation 'androidx.test.espresso:espresso-intents:3.3.0'
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/android/car/debuggingrestrictioncontroller/auth/SelfSignedTrustManager.java b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/auth/SelfSignedTrustManager.java
new file mode 100644
index 0000000..0aa39c1
--- /dev/null
+++ b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/auth/SelfSignedTrustManager.java
@@ -0,0 +1,63 @@
+package com.android.car.debuggingrestrictioncontroller.auth;
+
+import android.util.Log;
+import com.android.car.debuggingrestrictioncontroller.BuildConfig;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.UUID;
+import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509TrustManager;
+
+public final class SelfSignedTrustManager implements X509TrustManager {
+
+  private static final String TAG = SelfSignedTrustManager.class.getSimpleName();
+  private static final String ROOT_CA = BuildConfig.ROOT_CA_CERT;
+  private static SelfSignedTrustManager instance;
+  private X509TrustManager trustManager;
+
+  private SelfSignedTrustManager() throws GeneralSecurityException {
+    KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
+    try {
+      keyStore.load(null, null);
+    } catch (IOException e) {
+      Log.e(TAG, "Creating an empty KeyStore and this error should not happen");
+      throw new GeneralSecurityException(e);
+    }
+
+    X509Certificate cert = (X509Certificate) CertificateFactory.getInstance("X.509")
+        .generateCertificate(new ByteArrayInputStream(ROOT_CA.getBytes()));
+    keyStore.setCertificateEntry(UUID.randomUUID().toString(), cert);
+
+    TrustManagerFactory tmf = TrustManagerFactory
+        .getInstance(TrustManagerFactory.getDefaultAlgorithm());
+    tmf.init(keyStore);
+    this.trustManager = (X509TrustManager) tmf.getTrustManagers()[0];
+  }
+
+  synchronized public static SelfSignedTrustManager getInstance()
+      throws GeneralSecurityException {
+    if (instance == null) {
+      instance = new SelfSignedTrustManager();
+    }
+    return instance;
+  }
+
+  public void checkClientTrusted(X509Certificate[] chain, String authType)
+      throws CertificateException {
+    trustManager.checkClientTrusted(chain, authType);
+  }
+
+  public void checkServerTrusted(X509Certificate[] chain, String authType)
+      throws CertificateException {
+    trustManager.checkServerTrusted(chain, authType);
+  }
+
+  public X509Certificate[] getAcceptedIssuers() {
+    return trustManager.getAcceptedIssuers();
+  }
+}
diff --git a/app/src/main/java/com/android/car/debuggingrestrictioncontroller/auth/TokenPayload.java b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/auth/TokenPayload.java
new file mode 100644
index 0000000..a00caff
--- /dev/null
+++ b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/auth/TokenPayload.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2020 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package com.android.car.debuggingrestrictioncontroller.auth;
+
+import com.google.api.client.json.webtoken.JsonWebSignature;
+import com.google.api.client.util.Key;
+import java.util.HashMap;
+
+public class TokenPayload extends JsonWebSignature.Payload {
+
+  @Key("nonce")
+  private String nonce;
+
+  @Key("deviceId")
+  private String deviceId;
+
+  @Key("restrictions")
+  private HashMap<String, Boolean> restrictions;
+
+  public String getNonce() {
+    return nonce;
+  }
+
+  public String getDeviceId() {
+    return deviceId;
+  }
+
+  public HashMap<String, Boolean> getRestrictions() {
+    return restrictions;
+  }
+}
diff --git a/app/src/main/java/com/android/car/debuggingrestrictioncontroller/auth/TokenValidator.java b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/auth/TokenValidator.java
new file mode 100644
index 0000000..6e23b5b
--- /dev/null
+++ b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/auth/TokenValidator.java
@@ -0,0 +1,186 @@
+package com.android.car.debuggingrestrictioncontroller.auth;
+
+import androidx.annotation.NonNull;
+import com.android.car.debuggingrestrictioncontroller.BuildConfig;
+import com.google.api.client.json.jackson2.JacksonFactory;
+import com.google.api.client.json.webtoken.JsonWebSignature;
+import java.io.IOException;
+import java.net.UnknownHostException;
+import java.security.GeneralSecurityException;
+import java.security.Principal;
+import java.security.SignatureException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateExpiredException;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSessionContext;
+
+public final class TokenValidator {
+
+  private static final String TAG = TokenValidator.class.getSimpleName();
+  private static final String TOKEN_ISSUER_HOST_NAME = BuildConfig.TOKEN_ISSUER_HOST_NAME;
+  private static final HostnameVerifier HOSTNAME_VERIFIER = HttpsURLConnection
+      .getDefaultHostnameVerifier();
+
+  private static final Duration ACCEPTABLE_TIME_SKEW = Duration.ofMinutes(1);
+
+  public static TokenPayload parseAndVerify(@NonNull String authTokenString,
+      @NonNull String expectedNonce)
+      throws GeneralSecurityException {
+    JsonWebSignature jws;
+    // Step 0: Parse the string into a JWS
+    try {
+      jws = JsonWebSignature.parser(JacksonFactory.getDefaultInstance())
+          .setPayloadClass(TokenPayload.class)
+          .parse(authTokenString);
+    } catch (IOException e) {
+      throw new GeneralSecurityException(e);
+    }
+
+    // Step 1: Verify the signature of the JWS and retrieve the signature certificate
+    X509Certificate cert;
+    if (!BuildConfig.TOKEN_USES_SELF_SIGNED_CA) {
+      cert = jws.verifySignature();
+    } else {
+      cert = jws.verifySignature(SelfSignedTrustManager.getInstance());
+    }
+    if (cert == null) {
+      throw new SignatureException("Invalid signature");
+    }
+
+    // Step 2: verify the signature certificate matches the specified hostname
+    StubSSLSession session = new StubSSLSession();
+    session.certificates = new Certificate[]{cert};
+    if (!HOSTNAME_VERIFIER.verify(TOKEN_ISSUER_HOST_NAME, session)) {
+      throw new GeneralSecurityException(new UnknownHostException("Unexpected hostname"));
+    }
+
+    // Step 3: verify the payload
+    TokenPayload payload = (TokenPayload) jws.getPayload();
+    if (payload.getNonce().trim().equals(expectedNonce)) {
+      throw new GeneralSecurityException("Nonce mismatch");
+    }
+
+    if (Instant.now().minus(ACCEPTABLE_TIME_SKEW).isAfter(
+        Instant.ofEpochSecond(payload.getExpirationTimeSeconds()))) {
+      throw new CertificateExpiredException("Token expired");
+    }
+    return payload;
+  }
+
+  private static class StubSSLSession implements SSLSession {
+
+    public Certificate[] certificates = new Certificate[0];
+
+    @Override
+    public int getApplicationBufferSize() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String getCipherSuite() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public long getCreationTime() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public byte[] getId() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public long getLastAccessedTime() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Certificate[] getLocalCertificates() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Principal getLocalPrincipal() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int getPacketBufferSize() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public javax.security.cert.X509Certificate[] getPeerCertificateChain()
+        throws SSLPeerUnverifiedException {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Certificate[] getPeerCertificates() throws SSLPeerUnverifiedException {
+      return certificates;
+    }
+
+    @Override
+    public String getPeerHost() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public int getPeerPort() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Principal getPeerPrincipal() throws SSLPeerUnverifiedException {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String getProtocol() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public SSLSessionContext getSessionContext() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Object getValue(String name) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public String[] getValueNames() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void invalidate() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public boolean isValid() {
+      return true;
+    }
+
+    @Override
+    public void putValue(String name, Object value) {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public void removeValue(String name) {
+      throw new UnsupportedOperationException();
+    }
+  }
+}
diff --git a/app/src/main/java/com/android/car/debuggingrestrictioncontroller/ui/token/TokenActivity.java b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/ui/token/TokenActivity.java
index 37283ad..52465e7 100644
--- a/app/src/main/java/com/android/car/debuggingrestrictioncontroller/ui/token/TokenActivity.java
+++ b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/ui/token/TokenActivity.java
@@ -22,8 +22,6 @@
 public class TokenActivity extends AppCompatActivity {
 
   private static final String TAG = TokenActivity.class.getSimpleName();
-  private static final String API_NAME = "requestAccessToken";
-
   private final FirebaseAuth firebaseAuth = FirebaseAuth.getInstance();
   @VisibleForTesting
   private final CountingIdlingResource idlingResource = new CountingIdlingResource(TAG);
@@ -77,7 +75,7 @@
         idlingResource.increment();
         Map<String, Object> query = new HashMap<>();
         loadingProgressBar.setVisibility(View.VISIBLE);
-        tokenViewModel.requestAccessToken("", API_NAME, query);
+        tokenViewModel.requestAccessToken(query);
       }
     });
 
diff --git a/app/src/main/java/com/android/car/debuggingrestrictioncontroller/ui/token/TokenViewModel.java b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/ui/token/TokenViewModel.java
index 4ef716a..c5de0d1 100644
--- a/app/src/main/java/com/android/car/debuggingrestrictioncontroller/ui/token/TokenViewModel.java
+++ b/app/src/main/java/com/android/car/debuggingrestrictioncontroller/ui/token/TokenViewModel.java
@@ -1,12 +1,15 @@
 package com.android.car.debuggingrestrictioncontroller.ui.token;
 
 import android.util.Base64;
-import android.util.Log;
 import androidx.annotation.NonNull;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 import androidx.lifecycle.ViewModel;
+import com.android.car.debuggingrestrictioncontroller.BuildConfig;
+import com.android.car.debuggingrestrictioncontroller.auth.TokenPayload;
+import com.android.car.debuggingrestrictioncontroller.auth.TokenValidator;
 import com.google.firebase.functions.FirebaseFunctions;
+import java.security.GeneralSecurityException;
 import java.security.SecureRandom;
 import java.util.HashMap;
 import java.util.Map;
@@ -14,6 +17,8 @@
 public class TokenViewModel extends ViewModel {
 
   private static final String TAG = TokenViewModel.class.getSimpleName();
+  private static final String TOKEN_ISSUER_API_NAME = BuildConfig.TOKEN_ISSUER_API_NAME;
+
   private static final String FIELD_NONCE = "nonce";
   private static final String FIELD_TOKEN = "token";
 
@@ -25,7 +30,7 @@
     return tokenResult;
   }
 
-  public void requestAccessToken(@NonNull String hostName, @NonNull String apiName,
+  public void requestAccessToken(
       @NonNull Map<String, Object> query) {
     byte[] nonceBytes = new byte[16];
     SECURE_RANDOM.nextBytes(nonceBytes);
@@ -33,15 +38,22 @@
     query.put(FIELD_NONCE, nonce);
 
     firebaseFunctions
-        .getHttpsCallable(apiName)
+        .getHttpsCallable(TOKEN_ISSUER_API_NAME)
         .call(query)
         .continueWith(task -> {
+          @SuppressWarnings("unchecked")
           Map<String, Object> result = (Map<String, Object>) task.getResult().getData();
           return (String) result.get(FIELD_TOKEN);
         })
         .addOnCompleteListener(task -> {
           if (task.isSuccessful()) {
-            Log.d(TAG, "Token: " + task.getResult());
+            try {
+              TokenPayload validatedPayload = TokenValidator
+                  .parseAndVerify(task.getResult(), nonce);
+            } catch (GeneralSecurityException e) {
+              tokenResult.postValue(new TokenResult("Invalid access token"));
+              return;
+            }
             tokenResult.postValue(new TokenResult(new TokenView("OK", new HashMap<>())));
           } else {
             tokenResult.postValue(new TokenResult(task.getException().getMessage()));