summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--core/res/AndroidManifest.xml4
-rw-r--r--services/core/java/com/android/server/security/AttestationVerificationPeerDeviceVerifier.java110
-rw-r--r--services/core/java/com/android/server/security/CertificateRevocationStatusManager.java366
-rw-r--r--services/core/java/com/android/server/security/UpdateCertificateRevocationStatusJobService.java81
-rw-r--r--tests/AttestationVerificationTest/AndroidManifest.xml2
-rw-r--r--tests/AttestationVerificationTest/assets/test_revocation_list_no_test_certs.json12
-rw-r--r--tests/AttestationVerificationTest/assets/test_revocation_list_with_test_certs.json16
-rw-r--r--tests/AttestationVerificationTest/src/com/android/server/security/CertificateRevocationStatusManagerTest.java303
8 files changed, 791 insertions, 103 deletions
diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml
index dc954718d623..89f877dab99e 100644
--- a/core/res/AndroidManifest.xml
+++ b/core/res/AndroidManifest.xml
@@ -9406,6 +9406,10 @@
android:permission="android.permission.BIND_JOB_SERVICE">
</service>
+ <service android:name="com.android.server.security.UpdateCertificateRevocationStatusJobService"
+ android:permission="android.permission.BIND_JOB_SERVICE">
+ </service>
+
<service android:name="com.android.server.pm.PackageManagerShellCommandDataLoader"
android:exported="false">
<intent-filter>
diff --git a/services/core/java/com/android/server/security/AttestationVerificationPeerDeviceVerifier.java b/services/core/java/com/android/server/security/AttestationVerificationPeerDeviceVerifier.java
index dc1f93664f79..f060e4d11e82 100644
--- a/services/core/java/com/android/server/security/AttestationVerificationPeerDeviceVerifier.java
+++ b/services/core/java/com/android/server/security/AttestationVerificationPeerDeviceVerifier.java
@@ -17,8 +17,8 @@
package com.android.server.security;
import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_BOOT_STATE;
-import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_KEYSTORE_REQUIREMENTS;
import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_CERTS;
+import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_KEYSTORE_REQUIREMENTS;
import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_LOCAL_BINDING_REQUIREMENTS;
import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_PATCH_LEVEL_DIFF;
import static android.security.attestationverification.AttestationVerificationManager.FLAG_FAILURE_UNKNOWN;
@@ -47,12 +47,8 @@ import com.android.internal.R;
import com.android.internal.annotations.VisibleForTesting;
import com.android.server.security.AttestationVerificationManagerService.DumpLogger;
-import org.json.JSONObject;
-
import java.io.ByteArrayInputStream;
import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
import java.security.InvalidAlgorithmParameterException;
import java.security.cert.CertPath;
import java.security.cert.CertPathValidator;
@@ -60,7 +56,6 @@ import java.security.cert.CertPathValidatorException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
-import java.security.cert.PKIXCertPathChecker;
import java.security.cert.PKIXParameters;
import java.security.cert.TrustAnchor;
import java.security.cert.X509Certificate;
@@ -69,7 +64,6 @@ import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
@@ -126,6 +120,7 @@ class AttestationVerificationPeerDeviceVerifier {
private final LocalDate mTestLocalPatchDate;
private final CertificateFactory mCertificateFactory;
private final CertPathValidator mCertPathValidator;
+ private final CertificateRevocationStatusManager mCertificateRevocationStatusManager;
private final DumpLogger mDumpLogger;
AttestationVerificationPeerDeviceVerifier(@NonNull Context context,
@@ -135,6 +130,7 @@ class AttestationVerificationPeerDeviceVerifier {
mCertificateFactory = CertificateFactory.getInstance("X.509");
mCertPathValidator = CertPathValidator.getInstance("PKIX");
mTrustAnchors = getTrustAnchors();
+ mCertificateRevocationStatusManager = new CertificateRevocationStatusManager(mContext);
mRevocationEnabled = true;
mTestSystemDate = null;
mTestLocalPatchDate = null;
@@ -150,6 +146,7 @@ class AttestationVerificationPeerDeviceVerifier {
mCertificateFactory = CertificateFactory.getInstance("X.509");
mCertPathValidator = CertPathValidator.getInstance("PKIX");
mTrustAnchors = trustAnchors;
+ mCertificateRevocationStatusManager = new CertificateRevocationStatusManager(mContext);
mRevocationEnabled = revocationEnabled;
mTestSystemDate = systemDate;
mTestLocalPatchDate = localPatchDate;
@@ -300,15 +297,14 @@ class AttestationVerificationPeerDeviceVerifier {
CertPath certificatePath = mCertificateFactory.generateCertPath(certificates);
PKIXParameters validationParams = new PKIXParameters(mTrustAnchors);
+ // Do not use built-in revocation status checker.
+ validationParams.setRevocationEnabled(false);
+ mCertPathValidator.validate(certificatePath, validationParams);
if (mRevocationEnabled) {
// Checks Revocation Status List based on
// https://developer.android.com/training/articles/security-key-attestation#certificate_status
- PKIXCertPathChecker checker = new AndroidRevocationStatusListChecker();
- validationParams.addCertPathChecker(checker);
+ mCertificateRevocationStatusManager.checkRevocationStatus(certificates);
}
- // Do not use built-in revocation status checker.
- validationParams.setRevocationEnabled(false);
- mCertPathValidator.validate(certificatePath, validationParams);
}
private Set<TrustAnchor> getTrustAnchors() throws CertPathValidatorException {
@@ -574,96 +570,6 @@ class AttestationVerificationPeerDeviceVerifier {
<= maxPatchLevelDiffMonths;
}
- /**
- * Checks certificate revocation status.
- *
- * Queries status list from android.googleapis.com/attestation/status and checks for
- * the existence of certificate's serial number. If serial number exists in map, then fail.
- */
- private final class AndroidRevocationStatusListChecker extends PKIXCertPathChecker {
- private static final String TOP_LEVEL_JSON_PROPERTY_KEY = "entries";
- private static final String STATUS_PROPERTY_KEY = "status";
- private static final String REASON_PROPERTY_KEY = "reason";
- private String mStatusUrl;
- private JSONObject mJsonStatusMap;
-
- @Override
- public void init(boolean forward) throws CertPathValidatorException {
- mStatusUrl = getRevocationListUrl();
- if (mStatusUrl == null || mStatusUrl.isEmpty()) {
- throw new CertPathValidatorException(
- "R.string.vendor_required_attestation_revocation_list_url is empty.");
- }
- // TODO(b/221067843): Update to only pull status map on non critical path and if
- // out of date (24hrs).
- mJsonStatusMap = getStatusMap(mStatusUrl);
- }
-
- @Override
- public boolean isForwardCheckingSupported() {
- return false;
- }
-
- @Override
- public Set<String> getSupportedExtensions() {
- return null;
- }
-
- @Override
- public void check(Certificate cert, Collection<String> unresolvedCritExts)
- throws CertPathValidatorException {
- X509Certificate x509Certificate = (X509Certificate) cert;
- // The json key is the certificate's serial number converted to lowercase hex.
- String serialNumber = x509Certificate.getSerialNumber().toString(16);
-
- if (serialNumber == null) {
- throw new CertPathValidatorException("Certificate serial number can not be null.");
- }
-
- if (mJsonStatusMap.has(serialNumber)) {
- JSONObject revocationStatus;
- String status;
- String reason;
- try {
- revocationStatus = mJsonStatusMap.getJSONObject(serialNumber);
- status = revocationStatus.getString(STATUS_PROPERTY_KEY);
- reason = revocationStatus.getString(REASON_PROPERTY_KEY);
- } catch (Throwable t) {
- throw new CertPathValidatorException("Unable get properties for certificate "
- + "with serial number " + serialNumber);
- }
- throw new CertPathValidatorException(
- "Invalid certificate with serial number " + serialNumber
- + " has status " + status
- + " because reason " + reason);
- }
- }
-
- private JSONObject getStatusMap(String stringUrl) throws CertPathValidatorException {
- URL url;
- try {
- url = new URL(stringUrl);
- } catch (Throwable t) {
- throw new CertPathValidatorException(
- "Unable to get revocation status from " + mStatusUrl, t);
- }
-
- try (InputStream inputStream = url.openStream()) {
- JSONObject statusListJson = new JSONObject(
- new String(inputStream.readAllBytes(), UTF_8));
- return statusListJson.getJSONObject(TOP_LEVEL_JSON_PROPERTY_KEY);
- } catch (Throwable t) {
- throw new CertPathValidatorException(
- "Unable to parse revocation status from " + mStatusUrl, t);
- }
- }
-
- private String getRevocationListUrl() {
- return mContext.getResources().getString(
- R.string.vendor_required_attestation_revocation_list_url);
- }
- }
-
/* Mutable data class for tracking dump data from verifications. */
private static class MyDumpData extends AttestationVerificationManagerService.DumpData {
diff --git a/services/core/java/com/android/server/security/CertificateRevocationStatusManager.java b/services/core/java/com/android/server/security/CertificateRevocationStatusManager.java
new file mode 100644
index 000000000000..d36d9f5f6636
--- /dev/null
+++ b/services/core/java/com/android/server/security/CertificateRevocationStatusManager.java
@@ -0,0 +1,366 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * 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.server.security;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.app.job.JobInfo;
+import android.app.job.JobScheduler;
+import android.content.ComponentName;
+import android.content.Context;
+import android.net.NetworkCapabilities;
+import android.net.NetworkRequest;
+import android.os.Environment;
+import android.os.PersistableBundle;
+import android.util.Slog;
+
+import com.android.internal.R;
+import com.android.internal.annotations.VisibleForTesting;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.security.cert.CertPathValidatorException;
+import java.security.cert.X509Certificate;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Manages the revocation status of certificates used in remote attestation. */
+class CertificateRevocationStatusManager {
+ private static final String TAG = "AVF_CRL";
+ // Must be unique within system server
+ private static final int JOB_ID = 1737671340;
+ private static final String REVOCATION_STATUS_FILE_NAME = "certificate_revocation_status.txt";
+ private static final String REVOCATION_STATUS_FILE_FIELD_DELIMITER = ",";
+
+ /**
+ * The number of days since last update for which a stored revocation status can be accepted.
+ */
+ @VisibleForTesting static final int MAX_DAYS_SINCE_LAST_CHECK = 30;
+
+ /**
+ * The number of days since issue date for an intermediary certificate to be considered fresh
+ * and not require a revocation list check.
+ */
+ private static final int FRESH_INTERMEDIARY_CERT_DAYS = 70;
+
+ /**
+ * The expected number of days between a certificate's issue date and notBefore date. Used to
+ * infer a certificate's issue date from its notBefore date.
+ */
+ private static final int DAYS_BETWEEN_ISSUE_AND_NOT_BEFORE_DATES = 2;
+
+ private static final String TOP_LEVEL_JSON_PROPERTY_KEY = "entries";
+ private static final Object sFileLock = new Object();
+
+ private final Context mContext;
+ private final String mTestRemoteRevocationListUrl;
+ private final File mTestRevocationStatusFile;
+ private final boolean mShouldScheduleJob;
+
+ CertificateRevocationStatusManager(Context context) {
+ this(context, null, null, true);
+ }
+
+ @VisibleForTesting
+ CertificateRevocationStatusManager(
+ Context context,
+ String testRemoteRevocationListUrl,
+ File testRevocationStatusFile,
+ boolean shouldScheduleJob) {
+ mContext = context;
+ mTestRemoteRevocationListUrl = testRemoteRevocationListUrl;
+ mTestRevocationStatusFile = testRevocationStatusFile;
+ mShouldScheduleJob = shouldScheduleJob;
+ }
+
+ /**
+ * Check the revocation status of the provided {@link X509Certificate}s.
+ *
+ * <p>The provided certificates should have been validated and ordered from leaf to a
+ * certificate issued by the trust anchor, per the convention specified in the javadoc of {@link
+ * java.security.cert.CertPath}.
+ *
+ * @param certificates List of certificates to be checked
+ * @throws CertPathValidatorException if the check failed
+ */
+ void checkRevocationStatus(List<X509Certificate> certificates)
+ throws CertPathValidatorException {
+ if (!needToCheckRevocationStatus(certificates)) {
+ return;
+ }
+ List<String> serialNumbers = new ArrayList<>();
+ for (X509Certificate certificate : certificates) {
+ String serialNumber = certificate.getSerialNumber().toString(16);
+ if (serialNumber == null) {
+ throw new CertPathValidatorException("Certificate serial number cannot be null.");
+ }
+ serialNumbers.add(serialNumber);
+ }
+ try {
+ JSONObject revocationList = fetchRemoteRevocationList();
+ Map<String, Boolean> areCertificatesRevoked = new HashMap<>();
+ for (String serialNumber : serialNumbers) {
+ areCertificatesRevoked.put(serialNumber, revocationList.has(serialNumber));
+ }
+ updateLastRevocationCheckData(areCertificatesRevoked);
+ for (Map.Entry<String, Boolean> entry : areCertificatesRevoked.entrySet()) {
+ if (entry.getValue()) {
+ throw new CertPathValidatorException(
+ "Certificate " + entry.getKey() + " has been revoked.");
+ }
+ }
+ } catch (IOException | JSONException ex) {
+ Slog.d(TAG, "Fallback to check stored revocation status", ex);
+ if (ex instanceof IOException && mShouldScheduleJob) {
+ scheduleJobToUpdateStoredDataWithRemoteRevocationList(serialNumbers);
+ }
+ for (X509Certificate certificate : certificates) {
+ // Assume recently issued certificates are not revoked.
+ if (isIssuedWithinDays(certificate, MAX_DAYS_SINCE_LAST_CHECK)) {
+ String serialNumber = certificate.getSerialNumber().toString(16);
+ serialNumbers.remove(serialNumber);
+ }
+ }
+ Map<String, LocalDateTime> lastRevocationCheckData;
+ try {
+ lastRevocationCheckData = getLastRevocationCheckData();
+ } catch (IOException ex2) {
+ throw new CertPathValidatorException(
+ "Unable to load stored revocation status", ex2);
+ }
+ for (String serialNumber : serialNumbers) {
+ if (!lastRevocationCheckData.containsKey(serialNumber)
+ || lastRevocationCheckData
+ .get(serialNumber)
+ .isBefore(
+ LocalDateTime.now().minusDays(MAX_DAYS_SINCE_LAST_CHECK))) {
+ throw new CertPathValidatorException(
+ "Unable to verify the revocation status of certificate "
+ + serialNumber);
+ }
+ }
+ }
+ }
+
+ private static boolean needToCheckRevocationStatus(
+ List<X509Certificate> certificatesOrderedLeafFirst) {
+ if (certificatesOrderedLeafFirst.isEmpty()) {
+ return false;
+ }
+ // A certificate isn't revoked when it is first issued, so we treat it as checked on its
+ // issue date.
+ if (!isIssuedWithinDays(certificatesOrderedLeafFirst.get(0), MAX_DAYS_SINCE_LAST_CHECK)) {
+ return true;
+ }
+ for (int i = 1; i < certificatesOrderedLeafFirst.size(); i++) {
+ if (!isIssuedWithinDays(
+ certificatesOrderedLeafFirst.get(i), FRESH_INTERMEDIARY_CERT_DAYS)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isIssuedWithinDays(X509Certificate certificate, int days) {
+ LocalDate notBeforeDate =
+ LocalDate.ofInstant(certificate.getNotBefore().toInstant(), ZoneId.systemDefault());
+ LocalDate expectedIssueData =
+ notBeforeDate.plusDays(DAYS_BETWEEN_ISSUE_AND_NOT_BEFORE_DATES);
+ return LocalDate.now().minusDays(days + 1).isBefore(expectedIssueData);
+ }
+
+ void updateLastRevocationCheckDataForAllPreviouslySeenCertificates(
+ JSONObject revocationList, Collection<String> otherCertificatesToCheck) {
+ Set<String> allCertificatesToCheck = new HashSet<>(otherCertificatesToCheck);
+ try {
+ allCertificatesToCheck.addAll(getLastRevocationCheckData().keySet());
+ } catch (IOException ex) {
+ Slog.e(TAG, "Unable to update last check date of stored data.", ex);
+ }
+ Map<String, Boolean> areCertificatesRevoked = new HashMap<>();
+ for (String serialNumber : allCertificatesToCheck) {
+ areCertificatesRevoked.put(serialNumber, revocationList.has(serialNumber));
+ }
+ updateLastRevocationCheckData(areCertificatesRevoked);
+ }
+
+ /**
+ * Update the last revocation check data stored on this device.
+ *
+ * @param areCertificatesRevoked A Map whose keys are certificate serial numbers and values are
+ * whether that certificate has been revoked
+ */
+ void updateLastRevocationCheckData(Map<String, Boolean> areCertificatesRevoked) {
+ LocalDateTime now = LocalDateTime.now();
+ synchronized (sFileLock) {
+ Map<String, LocalDateTime> lastRevocationCheckData;
+ try {
+ lastRevocationCheckData = getLastRevocationCheckData();
+ } catch (IOException ex) {
+ Slog.e(TAG, "Unable to updateLastRevocationCheckData", ex);
+ return;
+ }
+ for (Map.Entry<String, Boolean> entry : areCertificatesRevoked.entrySet()) {
+ if (entry.getValue()) {
+ lastRevocationCheckData.remove(entry.getKey());
+ } else {
+ lastRevocationCheckData.put(entry.getKey(), now);
+ }
+ }
+ storeLastRevocationCheckData(lastRevocationCheckData);
+ }
+ }
+
+ Map<String, LocalDateTime> getLastRevocationCheckData() throws IOException {
+ Map<String, LocalDateTime> data = new HashMap<>();
+ File dataFile = getLastRevocationCheckDataFile();
+ synchronized (sFileLock) {
+ if (!dataFile.exists()) {
+ return data;
+ }
+ String dataString;
+ try (FileInputStream in = new FileInputStream(dataFile)) {
+ dataString = new String(in.readAllBytes(), UTF_8);
+ }
+ for (String line : dataString.split(System.lineSeparator())) {
+ String[] elements = line.split(REVOCATION_STATUS_FILE_FIELD_DELIMITER);
+ if (elements.length != 2) {
+ continue;
+ }
+ try {
+ data.put(elements[0], LocalDateTime.parse(elements[1]));
+ } catch (DateTimeParseException ex) {
+ Slog.e(
+ TAG,
+ "Unable to parse last checked LocalDateTime from file. Deleting the"
+ + " potentially corrupted file.",
+ ex);
+ dataFile.delete();
+ return data;
+ }
+ }
+ }
+ return data;
+ }
+
+ @VisibleForTesting
+ void storeLastRevocationCheckData(Map<String, LocalDateTime> lastRevocationCheckData) {
+ StringBuilder dataStringBuilder = new StringBuilder();
+ for (Map.Entry<String, LocalDateTime> entry : lastRevocationCheckData.entrySet()) {
+ dataStringBuilder
+ .append(entry.getKey())
+ .append(REVOCATION_STATUS_FILE_FIELD_DELIMITER)
+ .append(entry.getValue())
+ .append(System.lineSeparator());
+ }
+ synchronized (sFileLock) {
+ try (FileOutputStream fileOutputStream =
+ new FileOutputStream(getLastRevocationCheckDataFile())) {
+ fileOutputStream.write(dataStringBuilder.toString().getBytes(UTF_8));
+ Slog.d(TAG, "Successfully stored revocation status data.");
+ } catch (IOException ex) {
+ Slog.e(TAG, "Failed to store revocation status data.", ex);
+ }
+ }
+ }
+
+ private File getLastRevocationCheckDataFile() {
+ if (mTestRevocationStatusFile != null) {
+ return mTestRevocationStatusFile;
+ }
+ return new File(Environment.getDataSystemDirectory(), REVOCATION_STATUS_FILE_NAME);
+ }
+
+ private void scheduleJobToUpdateStoredDataWithRemoteRevocationList(List<String> serialNumbers) {
+ JobScheduler jobScheduler = mContext.getSystemService(JobScheduler.class);
+ if (jobScheduler == null) {
+ Slog.e(TAG, "Unable to get job scheduler.");
+ return;
+ }
+ Slog.d(TAG, "Scheduling job to fetch remote CRL.");
+ PersistableBundle extras = new PersistableBundle();
+ extras.putStringArray(
+ UpdateCertificateRevocationStatusJobService.EXTRA_KEY_CERTIFICATES_TO_CHECK,
+ serialNumbers.toArray(new String[0]));
+ jobScheduler.schedule(
+ new JobInfo.Builder(
+ JOB_ID,
+ new ComponentName(
+ mContext,
+ UpdateCertificateRevocationStatusJobService.class))
+ .setExtras(extras)
+ .setRequiredNetwork(
+ new NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build())
+ .build());
+ }
+
+ /**
+ * Fetches the revocation list from the URL specified in
+ * R.string.vendor_required_attestation_revocation_list_url
+ *
+ * @return The remote revocation list entries in a JSONObject
+ * @throws CertPathValidatorException if the URL is not defined or is malformed.
+ * @throws IOException if the URL is valid but the fetch failed.
+ * @throws JSONException if the revocation list content cannot be parsed
+ */
+ JSONObject fetchRemoteRevocationList()
+ throws CertPathValidatorException, IOException, JSONException {
+ String urlString = getRemoteRevocationListUrl();
+ if (urlString == null || urlString.isEmpty()) {
+ throw new CertPathValidatorException(
+ "R.string.vendor_required_attestation_revocation_list_url is empty.");
+ }
+ URL url;
+ try {
+ url = new URL(urlString);
+ } catch (MalformedURLException ex) {
+ throw new CertPathValidatorException("Unable to parse the URL " + urlString, ex);
+ }
+ byte[] revocationListBytes;
+ try (InputStream inputStream = url.openStream()) {
+ revocationListBytes = inputStream.readAllBytes();
+ }
+ JSONObject revocationListJson = new JSONObject(new String(revocationListBytes, UTF_8));
+ return revocationListJson.getJSONObject(TOP_LEVEL_JSON_PROPERTY_KEY);
+ }
+
+ private String getRemoteRevocationListUrl() {
+ if (mTestRemoteRevocationListUrl != null) {
+ return mTestRemoteRevocationListUrl;
+ }
+ return mContext.getResources()
+ .getString(R.string.vendor_required_attestation_revocation_list_url);
+ }
+}
diff --git a/services/core/java/com/android/server/security/UpdateCertificateRevocationStatusJobService.java b/services/core/java/com/android/server/security/UpdateCertificateRevocationStatusJobService.java
new file mode 100644
index 000000000000..768c812f47a3
--- /dev/null
+++ b/services/core/java/com/android/server/security/UpdateCertificateRevocationStatusJobService.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * 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.server.security;
+
+import android.app.job.JobParameters;
+import android.app.job.JobService;
+import android.util.Slog;
+
+import org.json.JSONObject;
+
+import java.util.Arrays;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** A {@link JobService} that fetches the certificate revocation list from a remote location. */
+public class UpdateCertificateRevocationStatusJobService extends JobService {
+
+ static final String EXTRA_KEY_CERTIFICATES_TO_CHECK =
+ "com.android.server.security.extra.CERTIFICATES_TO_CHECK";
+ private static final String TAG = "AVF_CRL";
+ private ExecutorService mExecutorService;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ mExecutorService = Executors.newSingleThreadExecutor();
+ }
+
+ @Override
+ public boolean onStartJob(JobParameters params) {
+ mExecutorService.execute(
+ () -> {
+ try {
+ CertificateRevocationStatusManager certificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(this);
+ Slog.d(TAG, "Starting to fetch remote CRL from job service.");
+ JSONObject revocationList =
+ certificateRevocationStatusManager.fetchRemoteRevocationList();
+ String[] certificatesToCheckFromJobParams =
+ params.getExtras().getStringArray(EXTRA_KEY_CERTIFICATES_TO_CHECK);
+ if (certificatesToCheckFromJobParams == null) {
+ Slog.e(TAG, "Extras not found: " + EXTRA_KEY_CERTIFICATES_TO_CHECK);
+ return;
+ }
+ certificateRevocationStatusManager
+ .updateLastRevocationCheckDataForAllPreviouslySeenCertificates(
+ revocationList,
+ Arrays.asList(certificatesToCheckFromJobParams));
+ } catch (Throwable t) {
+ Slog.e(TAG, "Unable to update the stored revocation status.", t);
+ }
+ jobFinished(params, false);
+ });
+ return true;
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters params) {
+ return false;
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ mExecutorService.shutdown();
+ }
+}
diff --git a/tests/AttestationVerificationTest/AndroidManifest.xml b/tests/AttestationVerificationTest/AndroidManifest.xml
index 37321ad80b0f..758852bb1074 100644
--- a/tests/AttestationVerificationTest/AndroidManifest.xml
+++ b/tests/AttestationVerificationTest/AndroidManifest.xml
@@ -18,7 +18,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="android.security.attestationverification">
- <uses-sdk android:minSdkVersion="30" android:targetSdkVersion="30" />
+ <uses-sdk android:minSdkVersion="30" android:targetSdkVersion="34" />
<uses-permission android:name="android.permission.USE_ATTESTATION_VERIFICATION_SERVICE" />
<application>
diff --git a/tests/AttestationVerificationTest/assets/test_revocation_list_no_test_certs.json b/tests/AttestationVerificationTest/assets/test_revocation_list_no_test_certs.json
new file mode 100644
index 000000000000..2a3ba5ebde7d
--- /dev/null
+++ b/tests/AttestationVerificationTest/assets/test_revocation_list_no_test_certs.json
@@ -0,0 +1,12 @@
+{
+ "entries": {
+ "6681152659205225093" : {
+ "status": "REVOKED",
+ "reason": "KEY_COMPROMISE"
+ },
+ "8350192447815228107" : {
+ "status": "REVOKED",
+ "reason": "KEY_COMPROMISE"
+ }
+ }
+} \ No newline at end of file
diff --git a/tests/AttestationVerificationTest/assets/test_revocation_list_with_test_certs.json b/tests/AttestationVerificationTest/assets/test_revocation_list_with_test_certs.json
new file mode 100644
index 000000000000..e22a834a92bf
--- /dev/null
+++ b/tests/AttestationVerificationTest/assets/test_revocation_list_with_test_certs.json
@@ -0,0 +1,16 @@
+{
+ "entries": {
+ "6681152659205225093" : {
+ "status": "REVOKED",
+ "reason": "KEY_COMPROMISE"
+ },
+ "353017e73dc205a73a9c3de142230370" : {
+ "status": "REVOKED",
+ "reason": "KEY_COMPROMISE"
+ },
+ "8350192447815228107" : {
+ "status": "REVOKED",
+ "reason": "KEY_COMPROMISE"
+ }
+ }
+} \ No newline at end of file
diff --git a/tests/AttestationVerificationTest/src/com/android/server/security/CertificateRevocationStatusManagerTest.java b/tests/AttestationVerificationTest/src/com/android/server/security/CertificateRevocationStatusManagerTest.java
new file mode 100644
index 000000000000..c38517ace5e6
--- /dev/null
+++ b/tests/AttestationVerificationTest/src/com/android/server/security/CertificateRevocationStatusManagerTest.java
@@ -0,0 +1,303 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * 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.server.security;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
+import android.content.Context;
+import android.os.SystemClock;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.json.JSONObject;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.security.cert.CertPathValidatorException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RunWith(AndroidJUnit4.class)
+public class CertificateRevocationStatusManagerTest {
+
+ private static final String TEST_CERTIFICATE_FILE_1 = "test_attestation_with_root_certs.pem";
+ private static final String TEST_CERTIFICATE_FILE_2 = "test_attestation_wrong_root_certs.pem";
+ private static final String TEST_REVOCATION_LIST_FILE_NAME = "test_revocation_list.json";
+ private static final String REVOCATION_LIST_WITHOUT_CERTIFICATES_USED_IN_THIS_TEST =
+ "test_revocation_list_no_test_certs.json";
+ private static final String REVOCATION_LIST_WITH_CERTIFICATES_USED_IN_THIS_TEST =
+ "test_revocation_list_with_test_certs.json";
+ private static final String TEST_REVOCATION_STATUS_FILE_NAME = "test_revocation_status.txt";
+ private static final String FILE_URL_PREFIX = "file://";
+ private final Context mContext = InstrumentationRegistry.getInstrumentation().getContext();
+
+ private CertificateFactory mFactory;
+ private List<X509Certificate> mCertificates1;
+ private List<X509Certificate> mCertificates2;
+ private File mRevocationListFile;
+ private String mRevocationListUrl;
+ private String mNonExistentRevocationListUrl;
+ private File mRevocationStatusFile;
+ private CertificateRevocationStatusManager mCertificateRevocationStatusManager;
+
+ @Before
+ public void setUp() throws Exception {
+ mFactory = CertificateFactory.getInstance("X.509");
+ mCertificates1 = getCertificateChain(TEST_CERTIFICATE_FILE_1);
+ mCertificates2 = getCertificateChain(TEST_CERTIFICATE_FILE_2);
+ mRevocationListFile = new File(mContext.getFilesDir(), TEST_REVOCATION_LIST_FILE_NAME);
+ mRevocationListUrl = FILE_URL_PREFIX + mRevocationListFile.getAbsolutePath();
+ File noSuchFile = new File(mContext.getFilesDir(), "file_does_not_exist");
+ mNonExistentRevocationListUrl = FILE_URL_PREFIX + noSuchFile.getAbsolutePath();
+ mRevocationStatusFile = new File(mContext.getFilesDir(), TEST_REVOCATION_STATUS_FILE_NAME);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ mRevocationListFile.delete();
+ mRevocationStatusFile.delete();
+ }
+
+ @Test
+ public void checkRevocationStatus_doesNotExistOnRemoteRevocationList_noException()
+ throws Exception {
+ copyFromAssetToFile(
+ REVOCATION_LIST_WITHOUT_CERTIFICATES_USED_IN_THIS_TEST, mRevocationListFile);
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mRevocationListUrl, mRevocationStatusFile, false);
+
+ mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1);
+ }
+
+ @Test
+ public void checkRevocationStatus_existsOnRemoteRevocationList_throwsException()
+ throws Exception {
+ copyFromAssetToFile(
+ REVOCATION_LIST_WITH_CERTIFICATES_USED_IN_THIS_TEST, mRevocationListFile);
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mRevocationListUrl, mRevocationStatusFile, false);
+
+ assertThrows(
+ CertPathValidatorException.class,
+ () -> mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1));
+ }
+
+ @Test
+ public void
+ checkRevocationStatus_cannotReachRemoteRevocationList_noStoredStatus_throwsException()
+ throws Exception {
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mNonExistentRevocationListUrl, mRevocationStatusFile, false);
+
+ assertThrows(
+ CertPathValidatorException.class,
+ () -> mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1));
+ }
+
+ @Test
+ public void checkRevocationStatus_savesRevocationStatus() throws Exception {
+ copyFromAssetToFile(
+ REVOCATION_LIST_WITHOUT_CERTIFICATES_USED_IN_THIS_TEST, mRevocationListFile);
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mRevocationListUrl, mRevocationStatusFile, false);
+
+ mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1);
+
+ assertThat(mRevocationStatusFile.length()).isGreaterThan(0);
+ }
+
+ @Test
+ public void checkRevocationStatus_cannotReachRemoteList_certsSaved_noException()
+ throws Exception {
+ // call checkRevocationStatus once to save the revocation status
+ copyFromAssetToFile(
+ REVOCATION_LIST_WITHOUT_CERTIFICATES_USED_IN_THIS_TEST, mRevocationListFile);
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mRevocationListUrl, mRevocationStatusFile, false);
+ mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1);
+ // call checkRevocationStatus again with mNonExistentRevocationListUrl
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mNonExistentRevocationListUrl, mRevocationStatusFile, false);
+
+ mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1);
+ }
+
+ @Test
+ public void checkRevocationStatus_cannotReachRemoteList_someCertsNotSaved_exception()
+ throws Exception {
+ // call checkRevocationStatus once to save the revocation status for mCertificates2
+ copyFromAssetToFile(
+ REVOCATION_LIST_WITHOUT_CERTIFICATES_USED_IN_THIS_TEST, mRevocationListFile);
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mRevocationListUrl, mRevocationStatusFile, false);
+ mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates2);
+ // call checkRevocationStatus again with mNonExistentRevocationListUrl, this time for
+ // mCertificates1
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mNonExistentRevocationListUrl, mRevocationStatusFile, false);
+
+ assertThrows(
+ CertPathValidatorException.class,
+ () -> mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1));
+ }
+
+ @Test
+ public void checkRevocationStatus_cannotReachRemoteList_someCertsStatusTooOld_exception()
+ throws Exception {
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mNonExistentRevocationListUrl, mRevocationStatusFile, false);
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime expiredStatusDate =
+ now.minusDays(CertificateRevocationStatusManager.MAX_DAYS_SINCE_LAST_CHECK + 1);
+ Map<String, LocalDateTime> lastRevocationCheckData = new HashMap<>();
+ lastRevocationCheckData.put(getSerialNumber(mCertificates1.get(0)), expiredStatusDate);
+ for (int i = 1; i < mCertificates1.size(); i++) {
+ lastRevocationCheckData.put(getSerialNumber(mCertificates1.get(i)), now);
+ }
+ mCertificateRevocationStatusManager.storeLastRevocationCheckData(lastRevocationCheckData);
+
+ assertThrows(
+ CertPathValidatorException.class,
+ () -> mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1));
+ }
+
+ @Test
+ public void checkRevocationStatus_cannotReachRemoteList_allCertResultsFresh_noException()
+ throws Exception {
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mNonExistentRevocationListUrl, mRevocationStatusFile, false);
+ LocalDateTime bearlyNotExpiredStatusDate =
+ LocalDateTime.now()
+ .minusDays(
+ CertificateRevocationStatusManager.MAX_DAYS_SINCE_LAST_CHECK - 1);
+ Map<String, LocalDateTime> lastRevocationCheckData = new HashMap<>();
+ for (X509Certificate certificate : mCertificates1) {
+ lastRevocationCheckData.put(getSerialNumber(certificate), bearlyNotExpiredStatusDate);
+ }
+ mCertificateRevocationStatusManager.storeLastRevocationCheckData(lastRevocationCheckData);
+
+ mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1);
+ }
+
+ @Test
+ public void updateLastRevocationCheckData_correctlySavesStatus() throws Exception {
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mNonExistentRevocationListUrl, mRevocationStatusFile, false);
+ Map<String, Boolean> areCertificatesRevoked = new HashMap<>();
+ for (X509Certificate certificate : mCertificates1) {
+ areCertificatesRevoked.put(getSerialNumber(certificate), false);
+ }
+
+ mCertificateRevocationStatusManager.updateLastRevocationCheckData(areCertificatesRevoked);
+
+ // no exception
+ mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1);
+ // revoke one certificate and try again
+ areCertificatesRevoked.put(getSerialNumber(mCertificates1.getLast()), true);
+ mCertificateRevocationStatusManager.updateLastRevocationCheckData(areCertificatesRevoked);
+ assertThrows(
+ CertPathValidatorException.class,
+ () -> mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1));
+ }
+
+ @Test
+ public void updateLastRevocationCheckDataForAllPreviouslySeenCertificates_updatesCorrectly()
+ throws Exception {
+ copyFromAssetToFile(
+ REVOCATION_LIST_WITHOUT_CERTIFICATES_USED_IN_THIS_TEST, mRevocationListFile);
+ mCertificateRevocationStatusManager =
+ new CertificateRevocationStatusManager(
+ mContext, mRevocationListUrl, mRevocationStatusFile, false);
+ // populate the revocation status file
+ mCertificateRevocationStatusManager.checkRevocationStatus(mCertificates1);
+ // Sleep for 2 second so that the current time changes
+ SystemClock.sleep(2000);
+ LocalDateTime timestampBeforeUpdate = LocalDateTime.now();
+ JSONObject revocationList = mCertificateRevocationStatusManager.fetchRemoteRevocationList();
+ List<String> otherCertificatesToCheck = new ArrayList<>();
+ String serialNumber1 = "1234567"; // not revoked
+ String serialNumber2 = "8350192447815228107"; // revoked
+ String serialNumber3 = "987654"; // not revoked
+ otherCertificatesToCheck.add(serialNumber1);
+ otherCertificatesToCheck.add(serialNumber2);
+ otherCertificatesToCheck.add(serialNumber3);
+
+ mCertificateRevocationStatusManager
+ .updateLastRevocationCheckDataForAllPreviouslySeenCertificates(
+ revocationList, otherCertificatesToCheck);
+
+ Map<String, LocalDateTime> lastRevocationCheckData =
+ mCertificateRevocationStatusManager.getLastRevocationCheckData();
+ assertThat(lastRevocationCheckData.get(serialNumber1)).isAtLeast(timestampBeforeUpdate);
+ assertThat(lastRevocationCheckData).doesNotContainKey(serialNumber2); // revoked
+ assertThat(lastRevocationCheckData.get(serialNumber3)).isAtLeast(timestampBeforeUpdate);
+ // validate that the existing certificates on the file got updated too
+ for (X509Certificate certificate : mCertificates1) {
+ assertThat(lastRevocationCheckData.get(getSerialNumber(certificate)))
+ .isAtLeast(timestampBeforeUpdate);
+ }
+ }
+
+ private List<X509Certificate> getCertificateChain(String fileName) throws Exception {
+ Collection<? extends Certificate> certificates =
+ mFactory.generateCertificates(mContext.getResources().getAssets().open(fileName));
+ ArrayList<X509Certificate> x509Certs = new ArrayList<>();
+ for (Certificate cert : certificates) {
+ x509Certs.add((X509Certificate) cert);
+ }
+ return x509Certs;
+ }
+
+ private void copyFromAssetToFile(String assetFileName, File targetFile) throws Exception {
+ byte[] data;
+ try (InputStream in = mContext.getResources().getAssets().open(assetFileName)) {
+ data = in.readAllBytes();
+ }
+ try (FileOutputStream fileOutputStream = new FileOutputStream(targetFile)) {
+ fileOutputStream.write(data);
+ }
+ }
+
+ private String getSerialNumber(X509Certificate certificate) {
+ return certificate.getSerialNumber().toString(16);
+ }
+}