diff options
author | 2025-02-03 20:09:30 +0000 | |
---|---|---|
committer | 2025-02-06 17:29:40 +0000 | |
commit | 287330e1a2d75f6499b9c1d7e2d126b886ceab8a (patch) | |
tree | 828822c422c4dc12556816c67df11b7350300c31 | |
parent | 518396a3e2a47f5edf48be4d6ceb4b7c1c0f2320 (diff) |
Store certificate revocation status locally
This change stores pairs of <certificate serial number, timestamp of last check against revocation list> locally on the device. It allows attestation to pass even when the device does not have an
Internet connection to fetch the certificate revocation list (CRL) as
long as a policy is satisfied.
The current policy allows skipping CRL check for:
1. All certificates whose notBefore date is within 32 days
2. Chains whose leaf has notBefore date within 32 days and all other
certs have notBefore date within 72 days
3. All certificates that have been checked against the CRL and found to
be not revoked in the past 30 days
This change also schedule a job to fetch the remote CRL if an
attestation is requested when the device does not have Internet
connection.
Test: atest AttestationVerificationTest (the set of failed tests on user
build is the same with and without my changes) and manual tests
Bug: 389088384
Flag: EXEMPT bug fix
Change-Id: Ia58b882cb1e084c1ec9929588bd94a1157b93a5e
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); + } +} |