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