diff options
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); + } +} |