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()));