• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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