• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2021 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.libraries.entitlement.eapaka;
18 
19 import static com.android.libraries.entitlement.ServiceEntitlementException.ERROR_EAP_AKA_FAILURE;
20 import static com.android.libraries.entitlement.ServiceEntitlementException.ERROR_EAP_AKA_SYNCHRONIZATION_FAILURE;
21 import static com.android.libraries.entitlement.ServiceEntitlementException.ERROR_MALFORMED_HTTP_RESPONSE;
22 
23 import android.content.Context;
24 import android.net.Uri;
25 import android.telephony.TelephonyManager;
26 import android.text.TextUtils;
27 import android.util.Log;
28 
29 import androidx.annotation.Nullable;
30 import androidx.annotation.VisibleForTesting;
31 
32 import com.android.libraries.entitlement.CarrierConfig;
33 import com.android.libraries.entitlement.EsimOdsaOperation;
34 import com.android.libraries.entitlement.ServiceEntitlementException;
35 import com.android.libraries.entitlement.ServiceEntitlementRequest;
36 import com.android.libraries.entitlement.http.HttpClient;
37 import com.android.libraries.entitlement.http.HttpConstants.ContentType;
38 import com.android.libraries.entitlement.http.HttpConstants.RequestMethod;
39 import com.android.libraries.entitlement.http.HttpRequest;
40 import com.android.libraries.entitlement.http.HttpResponse;
41 
42 import com.google.common.collect.ImmutableList;
43 import com.google.common.net.HttpHeaders;
44 
45 import org.json.JSONException;
46 import org.json.JSONObject;
47 
48 import java.util.List;
49 
50 public class EapAkaApi {
51     private static final String TAG = "ServiceEntitlement";
52 
53     public static final String EAP_CHALLENGE_RESPONSE = "eap-relay-packet";
54 
55     private static final String VERS = "vers";
56     private static final String ENTITLEMENT_VERSION = "entitlement_version";
57     private static final String TERMINAL_ID = "terminal_id";
58     private static final String TERMINAL_VENDOR = "terminal_vendor";
59     private static final String TERMINAL_MODEL = "terminal_model";
60     private static final String TERMIAL_SW_VERSION = "terminal_sw_version";
61     private static final String APP = "app";
62     private static final String EAP_ID = "EAP_ID";
63     private static final String IMSI = "IMSI";
64     private static final String TOKEN = "token";
65     private static final String TEMPORARY_TOKEN = "temporary_token";
66     private static final String NOTIF_ACTION = "notif_action";
67     private static final String NOTIF_TOKEN = "notif_token";
68     private static final String APP_VERSION = "app_version";
69     private static final String APP_NAME = "app_name";
70 
71     private static final String OPERATION = "operation";
72     private static final String OPERATION_TYPE = "operation_type";
73     private static final String OPERATION_TARGETS = "operation_targets";
74     private static final String COMPANION_TERMINAL_ID = "companion_terminal_id";
75     private static final String COMPANION_TERMINAL_VENDOR = "companion_terminal_vendor";
76     private static final String COMPANION_TERMINAL_MODEL = "companion_terminal_model";
77     private static final String COMPANION_TERMINAL_SW_VERSION = "companion_terminal_sw_version";
78     private static final String COMPANION_TERMINAL_FRIENDLY_NAME =
79             "companion_terminal_friendly_name";
80     private static final String COMPANION_TERMINAL_SERVICE = "companion_terminal_service";
81     private static final String COMPANION_TERMINAL_ICCID = "companion_terminal_iccid";
82     private static final String COMPANION_TERMINAL_EID = "companion_terminal_eid";
83 
84     private static final String TERMINAL_ICCID = "terminal_iccid";
85     private static final String TERMINAL_EID = "terminal_eid";
86 
87     private static final String TARGET_TERMINAL_ID = "target_terminal_id";
88     private static final String TARGET_TERMINAL_ICCID = "target_terminal_iccid";
89     private static final String TARGET_TERMINAL_EID = "target_terminal_eid";
90 
91     private static final String OLD_TERMINAL_ID = "old_terminal_id";
92     private static final String OLD_TERMINAL_ICCID = "old_terminal_iccid";
93 
94     private static final String BOOST_TYPE = "boost_type";
95 
96     // In case of EAP-AKA synchronization failure or another challenge, we try to authenticate for
97     // at most three times.
98     private static final int MAX_EAP_AKA_ATTEMPTS = 3;
99 
100     private final Context mContext;
101     private final int mSimSubscriptionId;
102     private final HttpClient mHttpClient;
103     private final String mBypassEapAkaResponse;
104 
EapAkaApi( Context context, int simSubscriptionId, boolean saveHistory, String bypassEapAkaResponse)105     public EapAkaApi(
106             Context context,
107             int simSubscriptionId,
108             boolean saveHistory,
109             String bypassEapAkaResponse) {
110         this(context, simSubscriptionId, new HttpClient(saveHistory), bypassEapAkaResponse);
111     }
112 
113     @VisibleForTesting
EapAkaApi( Context context, int simSubscriptionId, HttpClient httpClient, String bypassEapAkaResponse)114     EapAkaApi(
115             Context context,
116             int simSubscriptionId,
117             HttpClient httpClient,
118             String bypassEapAkaResponse) {
119         this.mContext = context;
120         this.mSimSubscriptionId = simSubscriptionId;
121         this.mHttpClient = httpClient;
122         this.mBypassEapAkaResponse = bypassEapAkaResponse;
123     }
124 
125     /**
126      * Retrieves raw entitlement configuration doc though EAP-AKA authentication.
127      *
128      * <p>Implementation based on GSMA TS.43-v5.0 2.6.1.
129      *
130      * @throws ServiceEntitlementException when getting an unexpected http response.
131      */
132     @Nullable
queryEntitlementStatus(ImmutableList<String> appIds, CarrierConfig carrierConfig, ServiceEntitlementRequest request)133     public String queryEntitlementStatus(ImmutableList<String> appIds,
134             CarrierConfig carrierConfig, ServiceEntitlementRequest request)
135             throws ServiceEntitlementException {
136         Uri.Builder urlBuilder = Uri.parse(carrierConfig.serverUrl()).buildUpon();
137         appendParametersForServiceEntitlementRequest(urlBuilder, appIds, request);
138         if (!TextUtils.isEmpty(request.authenticationToken())) {
139             // Fast Re-Authentication flow with pre-existing auth token
140             Log.d(TAG, "Fast Re-Authentication");
141             return httpGet(
142                     urlBuilder.toString(), carrierConfig, request.acceptContentType()).body();
143         } else {
144             // Full Authentication flow
145             Log.d(TAG, "Full Authentication");
146             HttpResponse challengeResponse =
147                     httpGet(
148                         urlBuilder.toString(),
149                         carrierConfig,
150                         ServiceEntitlementRequest.ACCEPT_CONTENT_TYPE_JSON);
151             String eapAkaChallenge = getEapAkaChallenge(challengeResponse);
152             if (eapAkaChallenge == null) {
153                 throw new ServiceEntitlementException(
154                         ERROR_MALFORMED_HTTP_RESPONSE,
155                         "Failed to parse EAP-AKA challenge: " + challengeResponse.body());
156             }
157             return respondToEapAkaChallenge(
158                     carrierConfig,
159                     eapAkaChallenge,
160                     challengeResponse.cookies(),
161                     MAX_EAP_AKA_ATTEMPTS,
162                     request.acceptContentType())
163                     .body();
164         }
165     }
166 
167     /**
168      * Sends a follow-up HTTP request to the HTTP {@code response} using the same cookie, and
169      * returns the follow-up HTTP response.
170      *
171      * <p>The {@code eapAkaChallenge} should be the EAP-AKA challenge from server, and the follow-up
172      * request could contain:
173      *
174      * <ul>
175      *   <li>The EAP-AKA response message, and the follow-up response should contain the service
176      *       entitlement configuration, or another EAP-AKA challenge in which case the method calls
177      *       if {@code remainingAttempts} is greater than zero (If {@code remainingAttempts} reaches
178      *       0, the method will throw ServiceEntitlementException) ; or
179      *   <li>The EAP-AKA synchronization failure message, and the follow-up response should contain
180      *       the new EAP-AKA challenge. Then this method calls itself to follow-up the new challenge
181      *       and return a new response, as long as {@code remainingAttempts} is greater than zero.
182      * </ul>
183      *
184      * @param response Challenge response from server which its content type is JSON
185      */
respondToEapAkaChallenge( CarrierConfig carrierConfig, String eapAkaChallenge, ImmutableList<String> cookies, int remainingAttempts, String contentType)186     private HttpResponse respondToEapAkaChallenge(
187             CarrierConfig carrierConfig,
188             String eapAkaChallenge,
189             ImmutableList<String> cookies,
190             int remainingAttempts,
191             String contentType)
192             throws ServiceEntitlementException {
193         if (!mBypassEapAkaResponse.isEmpty()) {
194             return challengeResponse(mBypassEapAkaResponse, carrierConfig, cookies, contentType);
195         }
196 
197         EapAkaChallenge challenge = EapAkaChallenge.parseEapAkaChallenge(eapAkaChallenge);
198         EapAkaResponse eapAkaResponse =
199                 EapAkaResponse.respondToEapAkaChallenge(mContext, mSimSubscriptionId, challenge);
200         // This could be a successful authentication, another challenge, or synchronization failure.
201         if (eapAkaResponse.response() != null) {
202             HttpResponse response =
203                     challengeResponse(
204                             eapAkaResponse.response(), carrierConfig, cookies, contentType);
205             String nextEapAkaChallenge = getEapAkaChallenge(response);
206             // successful authentication
207             if (nextEapAkaChallenge == null) {
208                 return response;
209             }
210             // another challenge
211             Log.d(TAG, "Received another challenge");
212             if (remainingAttempts > 0) {
213                 return respondToEapAkaChallenge(
214                         carrierConfig,
215                         nextEapAkaChallenge,
216                         cookies,
217                         remainingAttempts - 1,
218                         contentType);
219             } else {
220                 throw new ServiceEntitlementException(
221                         ERROR_EAP_AKA_FAILURE, "Unable to EAP-AKA authenticate");
222             }
223         } else if (eapAkaResponse.synchronizationFailureResponse() != null) {
224             Log.d(TAG, "synchronization failure");
225             HttpResponse newChallenge =
226                     challengeResponse(
227                             eapAkaResponse.synchronizationFailureResponse(),
228                             carrierConfig,
229                             cookies,
230                             ServiceEntitlementRequest.ACCEPT_CONTENT_TYPE_JSON);
231             String nextEapAkaChallenge = getEapAkaChallenge(newChallenge);
232             if (nextEapAkaChallenge == null) {
233                 throw new ServiceEntitlementException(
234                         ERROR_MALFORMED_HTTP_RESPONSE,
235                         "Failed to parse EAP-AKA challenge: " + newChallenge.body());
236             }
237             if (remainingAttempts > 0) {
238                 return respondToEapAkaChallenge(
239                         carrierConfig,
240                         nextEapAkaChallenge,
241                         cookies,
242                         remainingAttempts - 1,
243                         contentType);
244             } else {
245                 throw new ServiceEntitlementException(
246                         ERROR_EAP_AKA_SYNCHRONIZATION_FAILURE,
247                         "Unable to recover from EAP-AKA synchroinization failure");
248             }
249         } else { // not possible
250             throw new AssertionError("EapAkaResponse invalid.");
251         }
252     }
253 
challengeResponse( String eapAkaChallengeResponse, CarrierConfig carrierConfig, ImmutableList<String> cookies, String contentType)254     private HttpResponse challengeResponse(
255             String eapAkaChallengeResponse,
256             CarrierConfig carrierConfig,
257             ImmutableList<String> cookies,
258             String contentType)
259             throws ServiceEntitlementException {
260         Log.d(TAG, "challengeResponse");
261         JSONObject postData = new JSONObject();
262         try {
263             postData.put(EAP_CHALLENGE_RESPONSE, eapAkaChallengeResponse);
264         } catch (JSONException jsonException) {
265             throw new ServiceEntitlementException(
266                     ERROR_MALFORMED_HTTP_RESPONSE, "Failed to put post data", jsonException);
267         }
268         HttpRequest request =
269                 HttpRequest.builder()
270                         .setUrl(carrierConfig.serverUrl())
271                         .setRequestMethod(RequestMethod.POST)
272                         .setPostData(postData)
273                         .addRequestProperty(HttpHeaders.ACCEPT, contentType)
274                         .addRequestProperty(
275                                 HttpHeaders.CONTENT_TYPE,
276                                 ServiceEntitlementRequest.ACCEPT_CONTENT_TYPE_JSON)
277                         .addRequestProperty(HttpHeaders.COOKIE, cookies)
278                         .setTimeoutInSec(carrierConfig.timeoutInSec())
279                         .setNetwork(carrierConfig.network())
280                         .build();
281         return mHttpClient.request(request);
282     }
283 
284     /**
285      * Retrieves raw doc of performing ODSA operations. For operation type, see {@link
286      * EsimOdsaOperation}.
287      *
288      * <p>Implementation based on GSMA TS.43-v5.0 6.1.
289      */
performEsimOdsaOperation(String appId, CarrierConfig carrierConfig, ServiceEntitlementRequest request, EsimOdsaOperation odsaOperation)290     public String performEsimOdsaOperation(String appId, CarrierConfig carrierConfig,
291             ServiceEntitlementRequest request, EsimOdsaOperation odsaOperation)
292             throws ServiceEntitlementException {
293         Uri.Builder urlBuilder = Uri.parse(carrierConfig.serverUrl()).buildUpon();
294         appendParametersForServiceEntitlementRequest(urlBuilder, ImmutableList.of(appId), request);
295         appendParametersForEsimOdsaOperation(urlBuilder, odsaOperation);
296 
297         if (!TextUtils.isEmpty(request.authenticationToken())
298                 || !TextUtils.isEmpty(request.temporaryToken())) {
299             // Fast Re-Authentication flow with pre-existing auth token
300             Log.d(TAG, "Fast Re-Authentication");
301             return httpGet(
302                     urlBuilder.toString(), carrierConfig, request.acceptContentType()).body();
303         } else {
304             // Full Authentication flow
305             Log.d(TAG, "Full Authentication");
306             HttpResponse challengeResponse =
307                     httpGet(
308                             urlBuilder.toString(),
309                             carrierConfig,
310                             ServiceEntitlementRequest.ACCEPT_CONTENT_TYPE_JSON);
311             String eapAkaChallenge = getEapAkaChallenge(challengeResponse);
312             if (eapAkaChallenge == null) {
313                 throw new ServiceEntitlementException(
314                         ERROR_MALFORMED_HTTP_RESPONSE,
315                         "Failed to parse EAP-AKA challenge: " + challengeResponse.body());
316             }
317             return respondToEapAkaChallenge(
318                     carrierConfig,
319                     eapAkaChallenge,
320                     challengeResponse.cookies(),
321                     MAX_EAP_AKA_ATTEMPTS,
322                     request.acceptContentType())
323                     .body();
324         }
325     }
326 
appendParametersForServiceEntitlementRequest( Uri.Builder urlBuilder, ImmutableList<String> appIds, ServiceEntitlementRequest request)327     private void appendParametersForServiceEntitlementRequest(
328             Uri.Builder urlBuilder, ImmutableList<String> appIds,
329             ServiceEntitlementRequest request) {
330         TelephonyManager telephonyManager = mContext.getSystemService(
331                 TelephonyManager.class).createForSubscriptionId(mSimSubscriptionId);
332         if (!TextUtils.isEmpty(request.authenticationToken())) {
333             // IMSI and token required for fast AuthN.
334             urlBuilder
335                     .appendQueryParameter(IMSI, telephonyManager.getSubscriberId())
336                     .appendQueryParameter(TOKEN, request.authenticationToken());
337         } else if (!TextUtils.isEmpty(request.temporaryToken())) {
338             // temporary_token required for fast AuthN.
339             urlBuilder.appendQueryParameter(TEMPORARY_TOKEN, request.temporaryToken());
340         } else {
341             // EAP_ID required for initial AuthN
342             urlBuilder.appendQueryParameter(
343                     EAP_ID,
344                     getImsiEap(telephonyManager.getSimOperator(),
345                             telephonyManager.getSubscriberId()));
346         }
347 
348         if (!TextUtils.isEmpty(request.notificationToken())) {
349             urlBuilder
350                     .appendQueryParameter(NOTIF_ACTION,
351                             Integer.toString(request.notificationAction()))
352                     .appendQueryParameter(NOTIF_TOKEN, request.notificationToken());
353         }
354 
355         // Assign terminal ID with device IMEI if not set.
356         if (TextUtils.isEmpty(request.terminalId())) {
357             urlBuilder.appendQueryParameter(TERMINAL_ID, telephonyManager.getImei());
358         } else {
359             urlBuilder.appendQueryParameter(TERMINAL_ID, request.terminalId());
360         }
361 
362         // Optional query parameters, append them if not empty
363         appendOptionalQueryParameter(urlBuilder, APP_VERSION, request.appVersion());
364         appendOptionalQueryParameter(urlBuilder, APP_NAME, request.appName());
365         appendOptionalQueryParameter(urlBuilder, BOOST_TYPE, request.boostType());
366 
367         for (String appId : appIds) {
368             urlBuilder.appendQueryParameter(APP, appId);
369         }
370 
371         urlBuilder
372                 // Identity and Authentication parameters
373                 .appendQueryParameter(TERMINAL_VENDOR, request.terminalVendor())
374                 .appendQueryParameter(TERMINAL_MODEL, request.terminalModel())
375                 .appendQueryParameter(TERMIAL_SW_VERSION, request.terminalSoftwareVersion())
376                 // General Service parameters
377                 .appendQueryParameter(VERS, Integer.toString(request.configurationVersion()))
378                 .appendQueryParameter(ENTITLEMENT_VERSION, request.entitlementVersion());
379     }
380 
appendParametersForEsimOdsaOperation( Uri.Builder urlBuilder, EsimOdsaOperation odsaOperation)381     private void appendParametersForEsimOdsaOperation(
382             Uri.Builder urlBuilder, EsimOdsaOperation odsaOperation) {
383         urlBuilder.appendQueryParameter(OPERATION, odsaOperation.operation());
384         if (odsaOperation.operationType() != EsimOdsaOperation.OPERATION_TYPE_NOT_SET) {
385             urlBuilder.appendQueryParameter(OPERATION_TYPE,
386                     Integer.toString(odsaOperation.operationType()));
387         }
388         appendOptionalQueryParameter(
389                 urlBuilder,
390                 OPERATION_TARGETS,
391                 TextUtils.join(",", odsaOperation.operationTargets()));
392         appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_ID,
393                 odsaOperation.companionTerminalId());
394         appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_VENDOR,
395                 odsaOperation.companionTerminalVendor());
396         appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_MODEL,
397                 odsaOperation.companionTerminalModel());
398         appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_SW_VERSION,
399                 odsaOperation.companionTerminalSoftwareVersion());
400         appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_FRIENDLY_NAME,
401                 odsaOperation.companionTerminalFriendlyName());
402         appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_SERVICE,
403                 odsaOperation.companionTerminalService());
404         appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_ICCID,
405                 odsaOperation.companionTerminalIccid());
406         appendOptionalQueryParameter(urlBuilder, COMPANION_TERMINAL_EID,
407                 odsaOperation.companionTerminalEid());
408         appendOptionalQueryParameter(urlBuilder, TERMINAL_ICCID,
409                 odsaOperation.terminalIccid());
410         appendOptionalQueryParameter(urlBuilder, TERMINAL_EID, odsaOperation.terminalEid());
411         appendOptionalQueryParameter(urlBuilder, TARGET_TERMINAL_ID,
412                 odsaOperation.targetTerminalId());
413         appendOptionalQueryParameter(urlBuilder, TARGET_TERMINAL_ICCID,
414                 odsaOperation.targetTerminalIccid());
415         appendOptionalQueryParameter(urlBuilder, TARGET_TERMINAL_EID,
416                 odsaOperation.targetTerminalEid());
417         appendOptionalQueryParameter(urlBuilder, OLD_TERMINAL_ICCID,
418                 odsaOperation.oldTerminalIccid());
419         appendOptionalQueryParameter(urlBuilder, OLD_TERMINAL_ID,
420                 odsaOperation.oldTerminalId());
421     }
422 
httpGet(String url, CarrierConfig carrierConfig, String contentType)423     private HttpResponse httpGet(String url, CarrierConfig carrierConfig, String contentType)
424             throws ServiceEntitlementException {
425         HttpRequest httpRequest =
426                 HttpRequest.builder()
427                         .setUrl(url)
428                         .setRequestMethod(RequestMethod.GET)
429                         .addRequestProperty(HttpHeaders.ACCEPT, contentType)
430                         .setTimeoutInSec(carrierConfig.timeoutInSec())
431                         .setNetwork(carrierConfig.network())
432                         .build();
433         return mHttpClient.request(httpRequest);
434     }
435 
appendOptionalQueryParameter(Uri.Builder urlBuilder, String key, String value)436     private void appendOptionalQueryParameter(Uri.Builder urlBuilder, String key, String value) {
437         if (!TextUtils.isEmpty(value)) {
438             urlBuilder.appendQueryParameter(key, value);
439         }
440     }
441 
442     @Nullable
getEapAkaChallenge(HttpResponse response)443     private String getEapAkaChallenge(HttpResponse response) throws ServiceEntitlementException {
444         String eapAkaChallenge = null;
445         String responseBody = response.body();
446         if (response.contentType() == ContentType.JSON) {
447             try {
448                 eapAkaChallenge =
449                         new JSONObject(responseBody).optString(EAP_CHALLENGE_RESPONSE, null);
450             } catch (JSONException jsonException) {
451                 throw new ServiceEntitlementException(
452                         ERROR_MALFORMED_HTTP_RESPONSE,
453                         "Failed to parse json object",
454                         jsonException);
455             }
456         } else if (response.contentType() == ContentType.XML) {
457             // TODO: possibly support parsing eap-relay-packet in XML format
458             return null;
459         } else {
460             throw new ServiceEntitlementException(
461                     ERROR_MALFORMED_HTTP_RESPONSE, "Unknown HTTP content type");
462         }
463         return eapAkaChallenge;
464     }
465 
466     /**
467      * Returns the IMSI EAP value. The resulting realm part of the Root NAI in 3GPP TS 23.003 clause
468      * 19.3.2 will be in the form:
469      *
470      * <p>{@code 0<IMSI>@nai.epc.mnc<MNC>.mcc<MCC>.3gppnetwork.org}
471      */
472     @Nullable
getImsiEap(@ullable String mccmnc, @Nullable String imsi)473     public static String getImsiEap(@Nullable String mccmnc, @Nullable String imsi) {
474         if (mccmnc == null || mccmnc.length() < 5 || imsi == null) {
475             return null;
476         }
477 
478         String mcc = mccmnc.substring(0, 3);
479         String mnc = mccmnc.substring(3);
480         if (mnc.length() == 2) {
481             mnc = "0" + mnc;
482         }
483         return "0" + imsi + "@nai.epc.mnc" + mnc + ".mcc" + mcc + ".3gppnetwork.org";
484     }
485 
486     /**
487      * Retrieves the history of past HTTP request and responses.
488      */
getHistory()489     public List<String> getHistory() {
490         return mHttpClient.getHistory();
491     }
492 
493     /**
494      * Clears the history of past HTTP request and responses.
495      */
clearHistory()496     public void clearHistory() {
497         mHttpClient.clearHistory();
498     }
499 }
500