1 /*
2  * Copyright (C) 2022 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 
17 package com.android.rkpdapp.interfaces;
18 
19 import android.content.Context;
20 import android.content.pm.PackageManager;
21 import android.net.ConnectivityManager;
22 import android.net.NetworkCapabilities;
23 import android.net.TrafficStats;
24 import android.net.Uri;
25 import android.os.SystemProperties;
26 import android.util.Base64;
27 import android.util.Log;
28 
29 import androidx.annotation.VisibleForTesting;
30 
31 import com.android.rkpdapp.GeekResponse;
32 import com.android.rkpdapp.RkpdException;
33 import com.android.rkpdapp.metrics.ProvisioningAttempt;
34 import com.android.rkpdapp.utils.CborUtils;
35 import com.android.rkpdapp.utils.Settings;
36 import com.android.rkpdapp.utils.StopWatch;
37 import com.android.rkpdapp.utils.X509Utils;
38 
39 import java.io.BufferedInputStream;
40 import java.io.ByteArrayOutputStream;
41 import java.io.IOException;
42 import java.io.InputStream;
43 import java.io.OutputStream;
44 import java.net.HttpURLConnection;
45 import java.net.MalformedURLException;
46 import java.net.SocketTimeoutException;
47 import java.net.URL;
48 import java.nio.charset.Charset;
49 import java.nio.charset.StandardCharsets;
50 import java.security.MessageDigest;
51 import java.security.NoSuchAlgorithmException;
52 import java.security.cert.X509Certificate;
53 import java.util.Arrays;
54 import java.util.List;
55 import java.util.Objects;
56 import java.util.UUID;
57 
58 /**
59  * Provides convenience methods for interfacing with the remote provisioning server.
60  */
61 public class ServerInterface {
62     public static final int SYNC_CONNECT_TIMEOUT_RETRICTED_MS = 400;
63     public static final int SYNC_CONNECT_TIMEOUT_OPEN_MS = 1000;
64     public static final int TIMEOUT_MS = 20000;
65 
66     private static final int BACKOFF_TIME_MS = 100;
67 
68     private static final String TAG = "RkpdServerInterface";
69     private static final String GEEK_URL = ":fetchEekChain";
70     private static final String CERTIFICATE_SIGNING_URL = ":signCertificates";
71     private static final String CHALLENGE_PARAMETER = "challenge";
72     private static final String REQUEST_ID_PARAMETER = "request_id";
73     private static final String GMS_PACKAGE = "com.google.android.gms";
74     private static final String CHINA_GMS_FEATURE = "cn.google.services";
75 
76     private final Context mContext;
77     private final boolean mIsAsync;
78 
79     private enum Operation {
80         FETCH_GEEK(1),
81         SIGN_CERTS(2);
82 
83         private final int mTrafficTag;
84 
Operation(int trafficTag)85         Operation(int trafficTag) {
86             mTrafficTag = trafficTag;
87         }
88 
getTrafficTag()89         public int getTrafficTag() {
90             return mTrafficTag;
91         }
92 
getHttpErrorStatus()93         public ProvisioningAttempt.Status getHttpErrorStatus() {
94             if (Objects.equals(name(), FETCH_GEEK.name())) {
95                 return ProvisioningAttempt.Status.FETCH_GEEK_HTTP_ERROR;
96             } else if (Objects.equals(name(), SIGN_CERTS.name())) {
97                 return ProvisioningAttempt.Status.SIGN_CERTS_HTTP_ERROR;
98             }
99             throw new IllegalStateException("Please declare status for new operation.");
100         }
101 
getIoExceptionStatus()102         public ProvisioningAttempt.Status getIoExceptionStatus() {
103             if (Objects.equals(name(), FETCH_GEEK.name())) {
104                 return ProvisioningAttempt.Status.FETCH_GEEK_IO_EXCEPTION;
105             } else if (Objects.equals(name(), SIGN_CERTS.name())) {
106                 return ProvisioningAttempt.Status.SIGN_CERTS_IO_EXCEPTION;
107             }
108             throw new IllegalStateException("Please declare status for new operation.");
109         }
110 
getTimedOutStatus()111         public ProvisioningAttempt.Status getTimedOutStatus() {
112             if (Objects.equals(name(), FETCH_GEEK.name())) {
113                 return ProvisioningAttempt.Status.FETCH_GEEK_TIMED_OUT;
114             } else if (Objects.equals(name(), SIGN_CERTS.name())) {
115                 return ProvisioningAttempt.Status.SIGN_CERTS_TIMED_OUT;
116             }
117             throw new IllegalStateException("Please declare status for new operation.");
118         }
119     }
120 
ServerInterface(Context context, boolean isAsync)121     public ServerInterface(Context context, boolean isAsync) {
122         this.mContext = context;
123         this.mIsAsync = isAsync;
124     }
125 
126     /**
127      * Gets the system property value for country code for network.
128      */
129     @VisibleForTesting
getRegionalProperty()130     public String getRegionalProperty() {
131         return SystemProperties.get("gsm.operator.iso-country");
132     }
133 
134     /**
135      * Gets the server connection timeout in milliseconds.
136      */
137     @VisibleForTesting
getConnectTimeoutMs()138     public int getConnectTimeoutMs() {
139         if (mIsAsync) {
140             return TIMEOUT_MS;
141         }
142 
143         int timeout = SystemProperties.getInt("remote_provisioning.connect_timeout_millis", 0);
144 
145         // Setting a zero connection timeout doesn't work as it indicates that there is no timeout.
146         // Hence, ignoring zero and negative values by default.
147         if (timeout > 0) {
148             return timeout;
149         }
150 
151         String regionProperty = getRegionalProperty();
152         if (regionProperty == null || regionProperty.isEmpty()) {
153             Log.i(TAG, "Could not get regions from system property.");
154             return SYNC_CONNECT_TIMEOUT_OPEN_MS;
155         }
156         String[] regions = regionProperty.split(",");
157         if (Arrays.stream(regions).anyMatch(x -> x.equalsIgnoreCase("cn"))) {
158             Log.i(TAG, "Possible restricted network. Taking a lower connect timeout");
159             return SYNC_CONNECT_TIMEOUT_RETRICTED_MS;
160         }
161         return SYNC_CONNECT_TIMEOUT_OPEN_MS;
162     }
163 
164     /**
165      * Ferries the CBOR blobs returned by KeyMint to the provisioning server. The data sent to the
166      * provisioning server contains the MAC'ed CSRs and encrypted bundle containing the MAC key and
167      * the hardware unique public key.
168      *
169      * @param csr The CBOR encoded data containing the relevant pieces needed for the server to
170      *                    sign the CSRs. The data encoded within comes from Keystore / KeyMint.
171      * @param challenge The challenge that was sent from the server. It is included here even though
172      *                    it is also included in `cborBlob` in order to allow the server to more
173      *                    easily reject bad requests.
174      * @return A List of byte arrays, where each array contains an entire DER-encoded certificate
175      *                    chain for one attestation key pair.
176      */
requestSignedCertificates(byte[] csr, byte[] challenge, ProvisioningAttempt metrics)177     public List<byte[]> requestSignedCertificates(byte[] csr, byte[] challenge,
178             ProvisioningAttempt metrics) throws RkpdException, InterruptedException {
179         final byte[] cborBytes =
180                 connectAndGetData(metrics, generateSignCertsUrl(challenge),
181                                   csr, Operation.SIGN_CERTS);
182         List<byte[]> certChains = CborUtils.parseSignedCertificates(cborBytes);
183         if (certChains == null) {
184             metrics.setStatus(ProvisioningAttempt.Status.INTERNAL_ERROR);
185             throw new RkpdException(
186                     RkpdException.ErrorCode.INTERNAL_ERROR,
187                     "Response failed to parse.");
188         } else if (certChains.isEmpty()) {
189             metrics.setCertChainLength(0);
190             metrics.setRootCertFingerprint("");
191         } else {
192             try {
193                 X509Certificate[] certs = X509Utils.formatX509Certs(certChains.get(0));
194                 metrics.setCertChainLength(certs.length);
195                 byte[] pubKey = certs[certs.length - 1].getPublicKey().getEncoded();
196                 byte[] pubKeyDigest = MessageDigest.getInstance("SHA-256").digest(pubKey);
197                 metrics.setRootCertFingerprint(Base64.encodeToString(pubKeyDigest, Base64.DEFAULT));
198             } catch (NoSuchAlgorithmException e) {
199                 throw new RkpdException(RkpdException.ErrorCode.INTERNAL_ERROR,
200                         "Algorithm not found", e);
201             }
202         }
203         return certChains;
204     }
205 
generateSignCertsUrl(byte[] challenge)206     private URL generateSignCertsUrl(byte[] challenge) throws RkpdException {
207         try {
208             return new URL(Uri.parse(Settings.getUrl(mContext)).buildUpon()
209                     .appendEncodedPath(CERTIFICATE_SIGNING_URL)
210                     .appendQueryParameter(CHALLENGE_PARAMETER,
211                             Base64.encodeToString(challenge, Base64.URL_SAFE | Base64.NO_WRAP))
212                     .appendQueryParameter(REQUEST_ID_PARAMETER, generateAndLogRequestId())
213                     .build()
214                     .toString()
215                     // Needed due to the `:` in the URL endpoint.
216                     .replaceFirst("%3A", ":"));
217         } catch (MalformedURLException e) {
218             throw new RkpdException(RkpdException.ErrorCode.HTTP_CLIENT_ERROR, "Bad URL", e);
219         }
220     }
221 
generateAndLogRequestId()222     private String generateAndLogRequestId() {
223         String reqId = UUID.randomUUID().toString();
224         Log.i(TAG, "request_id: " + reqId);
225         return reqId;
226     }
227 
228     /**
229      * Calls out to the specified backend servers to retrieve an Endpoint Encryption Key and
230      * corresponding certificate chain to provide to KeyMint. This public key will be used to
231      * perform an ECDH computation, using the shared secret to encrypt privacy-sensitive components
232      * in the bundle that the server needs from the device in order to provision certificates.
233      *
234      * A challenge is also returned from the server so that it can check freshness of the follow-up
235      * request to get keys signed.
236      *
237      * @return A GeekResponse object which optionally contains configuration data.
238      */
fetchGeek(ProvisioningAttempt metrics)239     public GeekResponse fetchGeek(ProvisioningAttempt metrics)
240             throws RkpdException, InterruptedException {
241         if (!isNetworkConnected(mContext)) {
242             throw new RkpdException(RkpdException.ErrorCode.NO_NETWORK_CONNECTIVITY,
243                     "No network detected.");
244         }
245         // Since fetchGeek would be the first call for any sort of provisioning, we are okay
246         // checking network consent here.
247         if (!assumeNetworkConsent(mContext)) {
248             throw new RkpdException(RkpdException.ErrorCode.NETWORK_COMMUNICATION_ERROR,
249                     "Network communication consent not provided. Need to enable GMSCore app.");
250         }
251         byte[] input = CborUtils.buildProvisioningInfo(mContext);
252         byte[] cborBytes =
253                 connectAndGetData(metrics, generateFetchGeekUrl(), input, Operation.FETCH_GEEK);
254         GeekResponse resp = CborUtils.parseGeekResponse(cborBytes);
255         if (resp == null) {
256             metrics.setStatus(ProvisioningAttempt.Status.FETCH_GEEK_HTTP_ERROR);
257             throw new RkpdException(
258                     RkpdException.ErrorCode.HTTP_SERVER_ERROR,
259                     "Response failed to parse.");
260         }
261         return resp;
262     }
263 
generateFetchGeekUrl()264     private URL generateFetchGeekUrl() throws RkpdException {
265         try {
266             return new URL(Uri.parse(Settings.getUrl(mContext)).buildUpon()
267                             .appendPath(GEEK_URL)
268                             .build()
269                             .toString()
270                             // Needed due to the `:` in the URL endpoint.
271                             .replaceFirst("%3A", ":"));
272         } catch (MalformedURLException e) {
273             throw new RkpdException(RkpdException.ErrorCode.INTERNAL_ERROR, "Bad URL", e);
274         }
275     }
276 
checkDataBudget(ProvisioningAttempt metrics)277     private void checkDataBudget(ProvisioningAttempt metrics)
278             throws RkpdException {
279         if (!Settings.hasErrDataBudget(mContext, null /* curTime */)) {
280             metrics.setStatus(ProvisioningAttempt.Status.OUT_OF_ERROR_BUDGET);
281             int bytesConsumed = Settings.getErrDataBudgetConsumed(mContext);
282             throw makeNetworkError("Out of data budget due to repeated errors. Consumed "
283                     + bytesConsumed + " bytes.", metrics);
284         }
285     }
286 
makeNetworkError(String message, ProvisioningAttempt metrics)287     private RkpdException makeNetworkError(String message,
288             ProvisioningAttempt metrics) {
289         if (isNetworkConnected(mContext)) {
290             return new RkpdException(
291                     RkpdException.ErrorCode.NETWORK_COMMUNICATION_ERROR, message);
292         }
293         metrics.setStatus(ProvisioningAttempt.Status.NO_NETWORK_CONNECTIVITY);
294         return new RkpdException(
295                 RkpdException.ErrorCode.NO_NETWORK_CONNECTIVITY, message);
296     }
297 
298     /**
299      * Checks whether network is connected.
300      * @return true if connected else false.
301      */
isNetworkConnected(Context context)302     public static boolean isNetworkConnected(Context context) {
303         ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
304         NetworkCapabilities capabilities = cm.getNetworkCapabilities(cm.getActiveNetwork());
305         return capabilities != null
306                 && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
307                 && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
308     }
309 
310     /**
311      * Fetch a GEEK from the server and update SettingsManager appropriately with the return
312      * values. This will also delete all keys in the attestation key pool if the server has
313      * indicated that RKP should be turned off.
314      */
fetchGeekAndUpdate(ProvisioningAttempt metrics)315     public GeekResponse fetchGeekAndUpdate(ProvisioningAttempt metrics)
316             throws InterruptedException, RkpdException {
317         GeekResponse resp = fetchGeek(metrics);
318 
319         Settings.setDeviceConfig(mContext,
320                 resp.numExtraAttestationKeys,
321                 resp.timeToRefresh,
322                 resp.provisioningUrl);
323         return resp;
324     }
325 
326     /**
327      * Reads error data from the RKP server suitable for logging.
328      * @param con The HTTP connection from which to read the error
329      * @return The error string, or a description of why we couldn't read an error.
330      */
readErrorFromConnection(HttpURLConnection con)331     public static String readErrorFromConnection(HttpURLConnection con) {
332         final String contentType = con.getContentType();
333         if (!contentType.startsWith("text") && !contentType.startsWith("application/json")) {
334             return "Unexpected content type from the server: " + contentType;
335         }
336 
337         InputStream inputStream;
338         try {
339             inputStream = con.getInputStream();
340         } catch (IOException exception) {
341             inputStream = con.getErrorStream();
342         }
343 
344         if (inputStream == null) {
345             return "No error data returned by server.";
346         }
347 
348         byte[] bytes;
349         try {
350             bytes = new byte[1024];
351             final int read = inputStream.read(bytes);
352             if (read <= 0) {
353                 return "No error data returned by server.";
354             }
355             bytes = java.util.Arrays.copyOf(bytes, read);
356         } catch (IOException e) {
357             return "Error reading error string from server: " + e;
358         }
359 
360         final Charset charset = getCharsetFromContentTypeHeader(contentType);
361         return new String(bytes, charset);
362     }
363 
364     /**
365      * Checks whether GMSCore is installed and enabled for restricted regions.
366      * This lets us assume that user has consented to connecting to Google
367      * servers to provide attestation service.
368      * For all other regions, we assume consent by default since this is an
369      * Android OS-level application.
370      *
371      * @return True if user consent can be assumed else false.
372      */
373     @VisibleForTesting
assumeNetworkConsent(Context context)374     public static boolean assumeNetworkConsent(Context context) {
375         PackageManager pm = context.getPackageManager();
376         if (pm.hasSystemFeature(CHINA_GMS_FEATURE)) {
377             // For china GMS, we can simply check whether GMS package is installed and enabled.
378             try {
379                 return pm.getApplicationInfo(GMS_PACKAGE, 0).enabled;
380             } catch (PackageManager.NameNotFoundException e) {
381                 return false;
382             }
383         }
384         return true;
385     }
386 
getCharsetFromContentTypeHeader(String contentType)387     private static Charset getCharsetFromContentTypeHeader(String contentType) {
388         final String[] contentTypeParts = contentType.split(";");
389         if (contentTypeParts.length != 2) {
390             Log.w(TAG, "Simple content type; defaulting to ASCII");
391             return StandardCharsets.US_ASCII;
392         }
393 
394         final String[] charsetParts = contentTypeParts[1].strip().split("=");
395         if (charsetParts.length != 2 || !charsetParts[0].equals("charset")) {
396             Log.w(TAG, "The charset is missing from content-type, defaulting to ASCII");
397             return StandardCharsets.US_ASCII;
398         }
399 
400         final String charsetString = charsetParts[1].strip();
401         try {
402             return Charset.forName(charsetString);
403         } catch (IllegalArgumentException e) {
404             Log.w(TAG, "Unsupported charset: " + charsetString + "; defaulting to ASCII");
405             return StandardCharsets.US_ASCII;
406         }
407     }
408 
connectAndGetData(ProvisioningAttempt metrics, URL url, byte[] input, Operation operation)409     private byte[] connectAndGetData(ProvisioningAttempt metrics, URL url, byte[] input,
410             Operation operation) throws RkpdException, InterruptedException {
411         final int oldTrafficTag = TrafficStats.getAndSetThreadStatsTag(operation.getTrafficTag());
412         int backoff_time = BACKOFF_TIME_MS;
413         int attempt = 1;
414         RkpdException lastSeenRkpdException;
415         try (StopWatch retryTimer = new StopWatch(TAG)) {
416             retryTimer.start();
417             // Retry logic.
418             // Provide longer retries (up to 10s) for RkpdExceptions
419             // Provide shorter retries (once) for everything else.
420             while (true) {
421                 lastSeenRkpdException = null;
422                 checkDataBudget(metrics);
423                 try {
424                     Log.v(TAG, "Requesting data from server. Attempt " + attempt);
425                     return requestData(metrics, url, input);
426                 } catch (SocketTimeoutException e) {
427                     metrics.setStatus(operation.getTimedOutStatus());
428                     Log.e(TAG, "Server timed out. " + e.getMessage());
429                 } catch (IOException e) {
430                     metrics.setStatus(operation.getIoExceptionStatus());
431                     Log.e(TAG, "Failed to complete request from server. " + e.getMessage());
432                 } catch (RkpdException e) {
433                     lastSeenRkpdException = e;
434                     if (e.getErrorCode() == RkpdException.ErrorCode.DEVICE_NOT_REGISTERED) {
435                         metrics.setStatus(
436                                 ProvisioningAttempt.Status.SIGN_CERTS_DEVICE_NOT_REGISTERED);
437                         throw e;
438                     } else {
439                         metrics.setStatus(operation.getHttpErrorStatus());
440                         if (e.getErrorCode() == RkpdException.ErrorCode.HTTP_CLIENT_ERROR) {
441                             throw e;
442                         }
443                     }
444                 }
445                 // Only RkpdExceptions should get retries.
446                 if (retryTimer.getElapsedMillis() > Settings.getMaxRequestTime(mContext)
447                         || lastSeenRkpdException == null) {
448                     break;
449                 }
450                 Thread.sleep(backoff_time);
451                 backoff_time *= 2;
452                 attempt += 1;
453             }
454         } finally {
455             TrafficStats.setThreadStatsTag(oldTrafficTag);
456         }
457         if (lastSeenRkpdException != null) {
458             throw lastSeenRkpdException;
459         }
460         Settings.incrementFailureCounter(mContext);
461         throw makeNetworkError("Error getting data from server.", metrics);
462     }
463 
requestData(ProvisioningAttempt metrics, URL url, byte[] input)464     private byte[] requestData(ProvisioningAttempt metrics, URL url, byte[] input)
465             throws IOException, RkpdException {
466         int bytesTransacted = 0;
467         HttpURLConnection con = null;
468         try (StopWatch serverWaitTimer = metrics.startServerWait()) {
469             con = (HttpURLConnection) url.openConnection();
470             con.setRequestMethod("POST");
471             con.setConnectTimeout(getConnectTimeoutMs());
472             con.setReadTimeout(TIMEOUT_MS);
473             con.setDoOutput(true);
474             con.setFixedLengthStreamingMode(input.length);
475 
476             try (OutputStream os = con.getOutputStream()) {
477                 os.write(input, 0, input.length);
478                 bytesTransacted += input.length;
479             }
480 
481             metrics.setHttpStatusError(con.getResponseCode());
482             if (con.getResponseCode() != HttpURLConnection.HTTP_OK) {
483                 int failures = Settings.incrementFailureCounter(mContext);
484                 Log.e(TAG, "Server connection failed for url: " + url + ", response code: "
485                         + con.getResponseCode() + "\nRepeated failure count: " + failures);
486                 Log.e(TAG, readErrorFromConnection(con));
487                 throw RkpdException.createFromHttpError(con.getResponseCode());
488             }
489             serverWaitTimer.stop();
490             Settings.clearFailureCounter(mContext);
491             BufferedInputStream inputStream = new BufferedInputStream(con.getInputStream());
492             ByteArrayOutputStream cborBytes = new ByteArrayOutputStream();
493             byte[] buffer = new byte[1024];
494             int read;
495             serverWaitTimer.start();
496             while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) {
497                 cborBytes.write(buffer, 0, read);
498                 bytesTransacted += read;
499             }
500             inputStream.close();
501             Log.v(TAG, "Network request completed successfully.");
502             return cborBytes.toByteArray();
503         } catch (Exception e) {
504             Settings.consumeErrDataBudget(mContext, bytesTransacted);
505             throw e;
506         } finally {
507             if (con != null) {
508                 con.disconnect();
509             }
510         }
511     }
512 }
513