blob: d47dc8cce45873866c7fdd728d0ef6e6cf087e5e [file] [log] [blame]
/**
* Copyright (C) 2022 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.remoteprovisioner;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.TrafficStats;
import android.security.IGenerateRkpKeyService;
import android.util.Base64;
import android.util.Log;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* Provides convenience methods for interfacing with the remote provisioning server.
*/
public class ServerInterface {
private static final int TIMEOUT_MS = 20000;
private static final String TAG = "ServerInterface";
private static final String GEEK_URL = ":fetchEekChain";
private static final String CERTIFICATE_SIGNING_URL = ":signCertificates?challenge=";
/**
* Ferries the CBOR blobs returned by KeyMint to the provisioning server. The data sent to the
* provisioning server contains the MAC'ed CSRs and encrypted bundle containing the MAC key and
* the hardware unique public key.
*
* @param context The application context which is required to use SettingsManager.
* @param csr The CBOR encoded data containing the relevant pieces needed for the server to
* sign the CSRs. The data encoded within comes from Keystore / KeyMint.
* @param challenge The challenge that was sent from the server. It is included here even though
* it is also included in `cborBlob` in order to allow the server to more
* easily reject bad requests.
* @return A List of byte arrays, where each array contains an entire DER-encoded certificate
* chain for one attestation key pair.
*/
public static List<byte[]> requestSignedCertificates(Context context, byte[] csr,
byte[] challenge, ProvisionerMetrics metrics) throws RemoteProvisioningException {
TrafficStats.setThreadStatsTag(0);
checkDataBudget(context, metrics);
int bytesTransacted = 0;
try (ProvisionerMetrics.StopWatch serverWaitTimer = metrics.startServerWait()) {
URL url = new URL(SettingsManager.getUrl(context) + CERTIFICATE_SIGNING_URL
+ Base64.encodeToString(challenge, Base64.URL_SAFE));
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.setRequestMethod("POST");
con.setDoOutput(true);
con.setConnectTimeout(TIMEOUT_MS);
con.setReadTimeout(TIMEOUT_MS);
// May not be able to use try-with-resources here if the connection gets closed due to
// the output stream being automatically closed.
try (OutputStream os = con.getOutputStream()) {
os.write(csr, 0, csr.length);
bytesTransacted += csr.length;
}
metrics.setHttpStatusError(con.getResponseCode());
if (con.getResponseCode() != HttpURLConnection.HTTP_OK) {
serverWaitTimer.stop();
int failures = SettingsManager.incrementFailureCounter(context);
Log.e(TAG, "Server connection for signing failed, response code: "
+ con.getResponseCode() + "\nRepeated failure count: " + failures);
Log.e(TAG, readErrorFromConnection(con));
SettingsManager.consumeErrDataBudget(context, bytesTransacted);
RemoteProvisioningException ex =
RemoteProvisioningException.createFromHttpError(con.getResponseCode());
if (ex.getErrorCode() == IGenerateRkpKeyService.Status.DEVICE_NOT_REGISTERED) {
metrics.setStatus(ProvisionerMetrics.Status.SIGN_CERTS_DEVICE_NOT_REGISTERED);
} else {
metrics.setStatus(ProvisionerMetrics.Status.SIGN_CERTS_HTTP_ERROR);
}
throw ex;
}
serverWaitTimer.stop();
SettingsManager.clearFailureCounter(context);
BufferedInputStream inputStream = new BufferedInputStream(con.getInputStream());
ByteArrayOutputStream cborBytes = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int read = 0;
serverWaitTimer.start();
while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) {
cborBytes.write(buffer, 0, read);
bytesTransacted += read;
}
serverWaitTimer.stop();
return CborUtils.parseSignedCertificates(cborBytes.toByteArray());
} catch (SocketTimeoutException e) {
Log.e(TAG, "Server timed out", e);
metrics.setStatus(ProvisionerMetrics.Status.SIGN_CERTS_TIMED_OUT);
} catch (IOException e) {
Log.e(TAG, "Failed to request signed certificates from the server", e);
metrics.setStatus(ProvisionerMetrics.Status.SIGN_CERTS_IO_EXCEPTION);
}
SettingsManager.incrementFailureCounter(context);
SettingsManager.consumeErrDataBudget(context, bytesTransacted);
throw makeNetworkError(context, "Error getting CSR signed.", metrics);
}
/**
* Calls out to the specified backend servers to retrieve an Endpoint Encryption Key and
* corresponding certificate chain to provide to KeyMint. This public key will be used to
* perform an ECDH computation, using the shared secret to encrypt privacy sensitive components
* in the bundle that the server needs from the device in order to provision certificates.
*
* A challenge is also returned from the server so that it can check freshness of the follow-up
* request to get keys signed.
*
* @param context The application context which is required to use SettingsManager.
* @param metrics
* @return A GeekResponse object which optionally contains configuration data.
*/
public static GeekResponse fetchGeek(Context context,
ProvisionerMetrics metrics) throws RemoteProvisioningException {
TrafficStats.setThreadStatsTag(0);
checkDataBudget(context, metrics);
int bytesTransacted = 0;
try {
URL url = new URL(SettingsManager.getUrl(context) + GEEK_URL);
ByteArrayOutputStream cborBytes = new ByteArrayOutputStream();
try (ProvisionerMetrics.StopWatch serverWaitTimer = metrics.startServerWait()) {
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.setRequestMethod("POST");
con.setConnectTimeout(TIMEOUT_MS);
con.setReadTimeout(TIMEOUT_MS);
con.setDoOutput(true);
byte[] config = CborUtils.buildProvisioningInfo(context);
try (OutputStream os = con.getOutputStream()) {
os.write(config, 0, config.length);
bytesTransacted += config.length;
}
metrics.setHttpStatusError(con.getResponseCode());
if (con.getResponseCode() != HttpURLConnection.HTTP_OK) {
serverWaitTimer.stop();
int failures = SettingsManager.incrementFailureCounter(context);
Log.e(TAG, "Server connection for GEEK failed, response code: "
+ con.getResponseCode() + "\nRepeated failure count: " + failures);
Log.e(TAG, readErrorFromConnection(con));
SettingsManager.consumeErrDataBudget(context, bytesTransacted);
metrics.setStatus(ProvisionerMetrics.Status.FETCH_GEEK_HTTP_ERROR);
throw RemoteProvisioningException.createFromHttpError(con.getResponseCode());
}
serverWaitTimer.stop();
SettingsManager.clearFailureCounter(context);
BufferedInputStream inputStream = new BufferedInputStream(con.getInputStream());
byte[] buffer = new byte[1024];
int read = 0;
serverWaitTimer.start();
while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) {
cborBytes.write(buffer, 0, read);
bytesTransacted += read;
}
inputStream.close();
}
GeekResponse resp = CborUtils.parseGeekResponse(cborBytes.toByteArray());
if (resp == null) {
metrics.setStatus(ProvisionerMetrics.Status.FETCH_GEEK_HTTP_ERROR);
throw new RemoteProvisioningException(
IGenerateRkpKeyService.Status.HTTP_SERVER_ERROR,
"Response failed to parse.");
}
return resp;
} catch (SocketTimeoutException e) {
Log.e(TAG, "Server timed out", e);
metrics.setStatus(ProvisionerMetrics.Status.FETCH_GEEK_TIMED_OUT);
} catch (IOException e) {
// This exception will trigger on a completely malformed URL.
Log.e(TAG, "Failed to fetch GEEK from the servers.", e);
metrics.setStatus(ProvisionerMetrics.Status.FETCH_GEEK_IO_EXCEPTION);
}
SettingsManager.incrementFailureCounter(context);
SettingsManager.consumeErrDataBudget(context, bytesTransacted);
throw makeNetworkError(context, "Error fetching GEEK", metrics);
}
private static void checkDataBudget(Context context, ProvisionerMetrics metrics)
throws RemoteProvisioningException {
if (!SettingsManager.hasErrDataBudget(context, null /* curTime */)) {
metrics.setStatus(ProvisionerMetrics.Status.OUT_OF_ERROR_BUDGET);
int bytesConsumed = SettingsManager.getErrDataBudgetConsumed(context);
throw makeNetworkError(context,
"Out of data budget due to repeated errors. Consumed "
+ bytesConsumed + " bytes.", metrics);
}
}
private static RemoteProvisioningException makeNetworkError(Context context, String message,
ProvisionerMetrics metrics) {
ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
NetworkInfo networkInfo = cm.getActiveNetworkInfo();
if (networkInfo != null && networkInfo.isConnected()) {
return new RemoteProvisioningException(
IGenerateRkpKeyService.Status.NETWORK_COMMUNICATION_ERROR, message);
}
metrics.setStatus(ProvisionerMetrics.Status.NO_NETWORK_CONNECTIVITY);
return new RemoteProvisioningException(
IGenerateRkpKeyService.Status.NO_NETWORK_CONNECTIVITY, message);
}
/**
* Reads error data from the RKP server suitable for logging.
* @param con The HTTP connection from which to read the error
* @return The error string, or a description of why we couldn't read an error.
*/
public static String readErrorFromConnection(HttpURLConnection con) {
final String contentType = con.getContentType();
if (!contentType.startsWith("text") && !contentType.startsWith("application/json")) {
return "Unexpected content type from the server: " + contentType;
}
InputStream inputStream = null;
try {
inputStream = con.getInputStream();
} catch (IOException exception) {
inputStream = con.getErrorStream();
}
if (inputStream == null) {
return "No error data returned by server.";
}
byte[] bytes;
try {
bytes = new byte[1024];
final int read = inputStream.read(bytes);
if (read <= 0) {
return "No error data returned by server.";
}
bytes = java.util.Arrays.copyOf(bytes, read);
} catch (IOException e) {
return "Error reading error string from server: " + e;
}
final Charset charset = getCharsetFromContentTypeHeader(contentType);
return new String(bytes, charset);
}
private static Charset getCharsetFromContentTypeHeader(String contentType) {
final String[] contentTypeParts = contentType.split(";");
if (contentTypeParts.length != 2) {
Log.w(TAG, "Simple content type; defaulting to ASCII");
return StandardCharsets.US_ASCII;
}
final String[] charsetParts = contentTypeParts[1].strip().split("=");
if (charsetParts.length != 2 || !charsetParts[0].equals("charset")) {
Log.w(TAG, "The charset is missing from content-type, defaulting to ASCII");
return StandardCharsets.US_ASCII;
}
final String charsetString = charsetParts[1].strip();
try {
return Charset.forName(charsetString);
} catch (IllegalArgumentException e) {
Log.w(TAG, "Unsupported charset: " + charsetString + "; defaulting to ASCII");
return StandardCharsets.US_ASCII;
}
}
}