Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 287330e1 authored by Tom Chan's avatar Tom Chan
Browse files

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
parent 518396a3
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -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>
+8 −102
Original line number Diff line number Diff line
@@ -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 {

+366 −0
Original line number Diff line number Diff line
/*
 * 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);
    }
}
+81 −0

File added.

Preview size limit exceeded, changes collapsed.

+1 −1
Original line number Diff line number Diff line
@@ -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>
Loading