1 /* 2 * Copyright (C) 2025 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.server.security; 17 18 import static java.nio.charset.StandardCharsets.UTF_8; 19 20 import android.app.job.JobInfo; 21 import android.app.job.JobScheduler; 22 import android.content.ComponentName; 23 import android.content.Context; 24 import android.net.NetworkCapabilities; 25 import android.net.NetworkRequest; 26 import android.os.Binder; 27 import android.os.Environment; 28 import android.util.AtomicFile; 29 import android.util.Slog; 30 31 import com.android.internal.R; 32 import com.android.internal.annotations.VisibleForTesting; 33 34 import org.json.JSONException; 35 import org.json.JSONObject; 36 37 import java.io.File; 38 import java.io.FileInputStream; 39 import java.io.FileNotFoundException; 40 import java.io.FileOutputStream; 41 import java.io.IOException; 42 import java.io.InputStream; 43 import java.net.MalformedURLException; 44 import java.net.URL; 45 import java.security.cert.CertPathValidatorException; 46 import java.security.cert.X509Certificate; 47 import java.time.LocalDateTime; 48 import java.time.OffsetDateTime; 49 import java.util.ArrayList; 50 import java.util.List; 51 52 /** Manages the revocation status of certificates used in remote attestation. */ 53 class CertificateRevocationStatusManager { 54 private static final String TAG = "AVF_CRL"; 55 // Must be unique within system server 56 private static final int JOB_ID = 1737671340; 57 private static final String REVOCATION_LIST_FILE_NAME = "certificate_revocation_list.json"; 58 @VisibleForTesting static final int MAX_OFFLINE_REVOCATION_LIST_AGE_DAYS = 30; 59 60 @VisibleForTesting static final int NUM_HOURS_BEFORE_NEXT_FETCH = 24; 61 62 private static final String TOP_LEVEL_JSON_PROPERTY_KEY = "entries"; 63 private static final Object sFileLock = new Object(); 64 65 private final Context mContext; 66 private final String mTestRemoteRevocationListUrl; 67 private final File mTestStoredRevocationListFile; 68 private final boolean mShouldScheduleJob; 69 CertificateRevocationStatusManager(Context context)70 CertificateRevocationStatusManager(Context context) { 71 this(context, null, null, true); 72 } 73 74 @VisibleForTesting CertificateRevocationStatusManager( Context context, String testRemoteRevocationListUrl, File testStoredRevocationListFile, boolean shouldScheduleJob)75 CertificateRevocationStatusManager( 76 Context context, 77 String testRemoteRevocationListUrl, 78 File testStoredRevocationListFile, 79 boolean shouldScheduleJob) { 80 mContext = context; 81 mTestRemoteRevocationListUrl = testRemoteRevocationListUrl; 82 mTestStoredRevocationListFile = testStoredRevocationListFile; 83 mShouldScheduleJob = shouldScheduleJob; 84 } 85 86 /** 87 * Check the revocation status of the provided {@link X509Certificate}s. 88 * 89 * @param certificates List of certificates to be checked 90 * @throws CertPathValidatorException if the check failed 91 */ checkRevocationStatus(List<X509Certificate> certificates)92 void checkRevocationStatus(List<X509Certificate> certificates) 93 throws CertPathValidatorException { 94 List<String> serialNumbers = new ArrayList<>(); 95 for (X509Certificate certificate : certificates) { 96 String serialNumber = certificate.getSerialNumber().toString(16); 97 if (serialNumber == null) { 98 throw new CertPathValidatorException("Certificate serial number cannot be null."); 99 } 100 serialNumbers.add(serialNumber); 101 } 102 LocalDateTime now = LocalDateTime.now(); 103 JSONObject revocationList; 104 try { 105 if (getLastModifiedDateTime(getRevocationListFile()) 106 .isAfter(now.minusHours(NUM_HOURS_BEFORE_NEXT_FETCH))) { 107 Slog.d(TAG, "CRL is fetched recently, do not fetch again."); 108 revocationList = getStoredRevocationList(); 109 checkRevocationStatus(revocationList, serialNumbers); 110 return; 111 } 112 } catch (IOException | JSONException ignored) { 113 // Proceed to fetch the remote revocation list 114 } 115 try { 116 byte[] revocationListBytes = fetchRemoteRevocationListBytes(); 117 silentlyStoreRevocationList(revocationListBytes); 118 revocationList = parseRevocationList(revocationListBytes); 119 checkRevocationStatus(revocationList, serialNumbers); 120 } catch (IOException | JSONException ex) { 121 Slog.d(TAG, "Fallback to check stored revocation status", ex); 122 if (ex instanceof IOException && mShouldScheduleJob) { 123 Binder.withCleanCallingIdentity(this::scheduleJobToFetchRemoteRevocationJob); 124 } 125 try { 126 revocationList = getStoredRevocationList(); 127 checkRevocationStatus(revocationList, serialNumbers); 128 } catch (IOException | JSONException ex2) { 129 throw new CertPathValidatorException( 130 "Unable to load or parse stored revocation status", ex2); 131 } 132 } 133 } 134 checkRevocationStatus(JSONObject revocationList, List<String> serialNumbers)135 private static void checkRevocationStatus(JSONObject revocationList, List<String> serialNumbers) 136 throws CertPathValidatorException { 137 for (String serialNumber : serialNumbers) { 138 if (revocationList.has(serialNumber)) { 139 throw new CertPathValidatorException( 140 "Certificate has been revoked: " + serialNumber); 141 } 142 } 143 } 144 getStoredRevocationList()145 private JSONObject getStoredRevocationList() throws IOException, JSONException { 146 File offlineRevocationListFile = getRevocationListFile(); 147 if (!offlineRevocationListFile.exists() 148 || isRevocationListExpired(offlineRevocationListFile)) { 149 throw new FileNotFoundException("Offline copy does not exist or has expired."); 150 } 151 synchronized (sFileLock) { 152 try (FileInputStream inputStream = new FileInputStream(offlineRevocationListFile)) { 153 return parseRevocationList(inputStream.readAllBytes()); 154 } 155 } 156 } 157 isRevocationListExpired(File offlineRevocationListFile)158 private boolean isRevocationListExpired(File offlineRevocationListFile) { 159 LocalDateTime acceptableLastModifiedDate = 160 LocalDateTime.now().minusDays(MAX_OFFLINE_REVOCATION_LIST_AGE_DAYS); 161 LocalDateTime lastModifiedDate = getLastModifiedDateTime(offlineRevocationListFile); 162 return lastModifiedDate.isBefore(acceptableLastModifiedDate); 163 } 164 getLastModifiedDateTime(File file)165 private static LocalDateTime getLastModifiedDateTime(File file) { 166 // if the file does not exist, file.lastModified() returns 0, so this method returns the 167 // epoch time 168 return LocalDateTime.ofEpochSecond( 169 file.lastModified() / 1000, 0, OffsetDateTime.now().getOffset()); 170 } 171 172 /** 173 * Store the provided bytes to the local revocation list file. 174 * 175 * <p>This method does not throw an exception even if it fails to store the bytes. 176 * 177 * <p>This method internally synchronize file access with other methods in this class. 178 * 179 * @param revocationListBytes The bytes to store to the local revocation list file. 180 */ silentlyStoreRevocationList(byte[] revocationListBytes)181 void silentlyStoreRevocationList(byte[] revocationListBytes) { 182 synchronized (sFileLock) { 183 AtomicFile atomicRevocationListFile = new AtomicFile(getRevocationListFile()); 184 FileOutputStream fileOutputStream = null; 185 try { 186 fileOutputStream = atomicRevocationListFile.startWrite(); 187 fileOutputStream.write(revocationListBytes); 188 atomicRevocationListFile.finishWrite(fileOutputStream); 189 Slog.d(TAG, "Successfully stored revocation list."); 190 } catch (IOException ex) { 191 Slog.e(TAG, "Failed to store the certificate revocation list.", ex); 192 // this happens when fileOutputStream.write fails 193 if (fileOutputStream != null) { 194 atomicRevocationListFile.failWrite(fileOutputStream); 195 } 196 } 197 } 198 } 199 getRevocationListFile()200 private File getRevocationListFile() { 201 if (mTestStoredRevocationListFile != null) { 202 return mTestStoredRevocationListFile; 203 } 204 return new File(Environment.getDataSystemDirectory(), REVOCATION_LIST_FILE_NAME); 205 } 206 scheduleJobToFetchRemoteRevocationJob()207 private void scheduleJobToFetchRemoteRevocationJob() { 208 JobScheduler jobScheduler = mContext.getSystemService(JobScheduler.class); 209 if (jobScheduler == null) { 210 Slog.e(TAG, "Unable to get job scheduler."); 211 return; 212 } 213 Slog.d(TAG, "Scheduling job to fetch remote CRL."); 214 jobScheduler.forNamespace(TAG).schedule( 215 new JobInfo.Builder( 216 JOB_ID, 217 new ComponentName( 218 mContext, 219 UpdateCertificateRevocationStatusJobService.class)) 220 .setRequiredNetwork( 221 new NetworkRequest.Builder() 222 .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) 223 .build()) 224 .build()); 225 } 226 227 /** 228 * Fetches the revocation list from the URL specified in 229 * R.string.vendor_required_attestation_revocation_list_url 230 * 231 * @return The remote revocation list entries in a byte[]. 232 * @throws CertPathValidatorException if the URL is not defined or is malformed. 233 * @throws IOException if the URL is valid but the fetch failed. 234 */ fetchRemoteRevocationListBytes()235 byte[] fetchRemoteRevocationListBytes() throws CertPathValidatorException, IOException { 236 String urlString = getRemoteRevocationListUrl(); 237 if (urlString == null || urlString.isEmpty()) { 238 throw new CertPathValidatorException( 239 "R.string.vendor_required_attestation_revocation_list_url is empty."); 240 } 241 URL url; 242 try { 243 url = new URL(urlString); 244 } catch (MalformedURLException ex) { 245 throw new CertPathValidatorException("Unable to parse the URL " + urlString, ex); 246 } 247 try (InputStream inputStream = url.openStream()) { 248 return inputStream.readAllBytes(); 249 } 250 } 251 parseRevocationList(byte[] revocationListBytes)252 private JSONObject parseRevocationList(byte[] revocationListBytes) 253 throws IOException, JSONException { 254 JSONObject revocationListJson = new JSONObject(new String(revocationListBytes, UTF_8)); 255 return revocationListJson.getJSONObject(TOP_LEVEL_JSON_PROPERTY_KEY); 256 } 257 getRemoteRevocationListUrl()258 private String getRemoteRevocationListUrl() { 259 if (mTestRemoteRevocationListUrl != null) { 260 return mTestRemoteRevocationListUrl; 261 } 262 return mContext.getResources() 263 .getString(R.string.vendor_required_attestation_revocation_list_url); 264 } 265 } 266