1 /*
2  * Copyright (C) 2023 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.server.wifi.entitlement;
18 
19 import static com.android.libraries.entitlement.EapAkaHelper.EapAkaResponse;
20 
21 import android.annotation.IntDef;
22 import android.annotation.NonNull;
23 import android.content.Context;
24 import android.os.Handler;
25 import android.telephony.TelephonyManager;
26 import android.text.TextUtils;
27 
28 import com.android.internal.annotations.VisibleForTesting;
29 import com.android.libraries.entitlement.EapAkaHelper;
30 import com.android.libraries.entitlement.ServiceEntitlementException;
31 import com.android.modules.utils.BackgroundThread;
32 import com.android.server.wifi.entitlement.http.HttpClient;
33 import com.android.server.wifi.entitlement.http.HttpConstants.RequestMethod;
34 import com.android.server.wifi.entitlement.http.HttpRequest;
35 import com.android.server.wifi.entitlement.http.HttpResponse;
36 import com.android.server.wifi.entitlement.response.ChallengeResponse;
37 import com.android.server.wifi.entitlement.response.GetImsiPseudonymResponse;
38 import com.android.server.wifi.entitlement.response.Response;
39 
40 import com.google.common.net.HttpHeaders;
41 
42 import java.lang.annotation.Retention;
43 import java.lang.annotation.RetentionPolicy;
44 import java.net.MalformedURLException;
45 import java.net.URL;
46 import java.util.Optional;
47 
48 /**
49  * Implements the protocol to get IMSI pseudonym from service entitlement server.
50  */
51 public class CarrierSpecificServiceEntitlement {
52     public static final int REASON_HTTPS_CONNECTION_FAILURE = 0;
53     public static final int REASON_TRANSIENT_FAILURE = 1;
54     public static final int REASON_NON_TRANSIENT_FAILURE = 2;
55     public static final String[] FAILURE_REASON_NAME = {
56             "HTTP connection failure",
57             "Transient failure",
58             "Non-transient failure",
59     };
60     @IntDef(prefix = { "REASON_" }, value = {
61             REASON_HTTPS_CONNECTION_FAILURE,
62             REASON_TRANSIENT_FAILURE,
63             REASON_NON_TRANSIENT_FAILURE,
64     })
65     @Retention(RetentionPolicy.SOURCE)
66     public @interface FailureReasonCode {}
67 
68     private static final String MIME_TYPE_JSON = "application/json";
69     private static final String ENCODING_GZIP = "gzip";
70     private static final int CONNECT_TIMEOUT_SECS = 30;
71 
72     private final RequestFactory mRequestFactory;
73     private final HttpRequest.Builder mHttpRequestBuilder;
74     private final EapAkaHelper mEapAkaHelper;
75     private final String mImsi;
76     private final Handler mBackgroundHandler;
77 
78     private String mAkaTokenCache;
79 
CarrierSpecificServiceEntitlement(@onNull Context context, int subId, @NonNull String serverUrl)80     public CarrierSpecificServiceEntitlement(@NonNull Context context, int subId,
81             @NonNull String serverUrl) throws MalformedURLException {
82         this(context.getSystemService(TelephonyManager.class).createForSubscriptionId(subId),
83                 EapAkaHelper.getInstance(context, subId), serverUrl);
84     }
85 
CarrierSpecificServiceEntitlement(@onNull TelephonyManager telephonyManager, @NonNull EapAkaHelper eapAkaHelper, @NonNull String serverUrl)86     private CarrierSpecificServiceEntitlement(@NonNull TelephonyManager telephonyManager,
87             @NonNull EapAkaHelper eapAkaHelper, @NonNull String serverUrl)
88             throws MalformedURLException {
89         this(telephonyManager.getSubscriberId(), new RequestFactory(telephonyManager), eapAkaHelper,
90                 serverUrl, BackgroundThread.getHandler());
91     }
92 
93     @VisibleForTesting
CarrierSpecificServiceEntitlement(@onNull String imsi, @NonNull RequestFactory requestFactory, @NonNull EapAkaHelper eapAkaHelper, @NonNull String serverUrl, @NonNull Handler backgroundHandler)94     CarrierSpecificServiceEntitlement(@NonNull String imsi,
95             @NonNull RequestFactory requestFactory,
96             @NonNull EapAkaHelper eapAkaHelper,
97             @NonNull String serverUrl,
98             @NonNull Handler backgroundHandler) throws MalformedURLException {
99         URL url = new URL(serverUrl);
100         if (!TextUtils.equals(url.getProtocol(), "https")) {
101             throw new MalformedURLException("The server URL must use HTTPS protocol");
102         }
103         mImsi = imsi;
104         mRequestFactory = requestFactory;
105         mHttpRequestBuilder = HttpRequest.builder()
106                 .setUrl(serverUrl)
107                 .setRequestMethod(RequestMethod.POST)
108                 .addRequestProperty(HttpHeaders.CONTENT_TYPE, MIME_TYPE_JSON)
109                 .addRequestProperty(HttpHeaders.CONTENT_ENCODING, ENCODING_GZIP)
110                 .addRequestProperty(HttpHeaders.ACCEPT, MIME_TYPE_JSON)
111                 .setTimeoutInSec(CONNECT_TIMEOUT_SECS);
112         mEapAkaHelper = eapAkaHelper;
113         mBackgroundHandler = backgroundHandler;
114     }
115 
116     /**
117      * Retrieve the OOB IMSI pseudonym from the entitlement server in the BackgroundThread.
118      *
119      * @param callbackHandler The handler used to run the callback.
120      * @param callback The callback which will be called when the pseudonym is retrieved from
121      *                server.
122      */
getImsiPseudonym(int carrierId, @NonNull Handler callbackHandler, @NonNull Callback callback)123     public void getImsiPseudonym(int carrierId, @NonNull Handler callbackHandler,
124             @NonNull Callback callback) {
125         mBackgroundHandler.post(() -> {
126             try {
127                 Optional<PseudonymInfo> optionalPseudonymInfo = getImsiPseudonym();
128                 if (optionalPseudonymInfo.isPresent()) {
129                     callbackHandler.post(() -> callback.onSuccess(carrierId,
130                             optionalPseudonymInfo.get()));
131                 } else {
132                     callbackHandler.post(() -> callback.onFailure(carrierId,
133                             REASON_NON_TRANSIENT_FAILURE, "No valid pseudonym is received"));
134                 }
135             } catch (ServiceEntitlementException e) {
136                 callbackHandler.post(() -> callback.onFailure(carrierId,
137                         REASON_HTTPS_CONNECTION_FAILURE, e.toString()));
138             } catch (TransientException e) {
139                 callbackHandler.post(() -> callback.onFailure(carrierId, REASON_TRANSIENT_FAILURE,
140                         e.toString()));
141             } catch (NonTransientException e) {
142                 callbackHandler.post(() -> callback.onFailure(carrierId,
143                         REASON_NON_TRANSIENT_FAILURE, e.toString()));
144             }
145         });
146     }
147 
148     /**
149      * Retrieve the OOB IMSI pseudonym from the entitlement server.
150      * @throws TransientException if a transient failure like failure to connect with server or
151      * server's temporary problem etc.
152      * @throws NonTransientException if a non-transient failure, like failure to get challenge
153      * response or authentication failure from server etc.
154      * @throws ServiceEntitlementException if there is any HTTPS connection failure.
155      */
getImsiPseudonym()156     private Optional<PseudonymInfo> getImsiPseudonym() throws
157             TransientException, NonTransientException, ServiceEntitlementException {
158         String eapAkaChallenge = null;
159         if (TextUtils.isEmpty(mAkaTokenCache)) {
160             eapAkaChallenge = getAuthenticationChallenge();
161         }
162         String eapAkaChallengeResponse = null;
163         if (!TextUtils.isEmpty(eapAkaChallenge)) {
164             EapAkaResponse eapAkaResponse = mEapAkaHelper.getEapAkaResponse(eapAkaChallenge);
165             if (eapAkaResponse == null) {
166                 throw new NonTransientException("Can't get the AKA challenge response.");
167             }
168             eapAkaChallengeResponse = eapAkaResponse.response();
169             if (eapAkaChallengeResponse == null) {
170                 throw new TransientException("EAP-AKA Challenge message not valid!");
171             }
172         }
173 
174         HttpResponse httpResponse = HttpClient.request(
175                 mHttpRequestBuilder.setPostDataJsonArray(
176                         mRequestFactory.createGetImsiPseudonymRequest(
177                                 mAkaTokenCache, eapAkaChallengeResponse)).build());
178 
179         GetImsiPseudonymResponse imsiPseudonymResponse =
180                 new GetImsiPseudonymResponse(httpResponse.body());
181         int authResponseCode = imsiPseudonymResponse.getAuthResponseCode();
182         switch (authResponseCode) {
183             case Response.RESPONSE_CODE_REQUEST_SUCCESSFUL:
184                 // only save AKA token for full authentication
185                 if (!TextUtils.isEmpty(eapAkaChallengeResponse)
186                         && !TextUtils.isEmpty(imsiPseudonymResponse.getAkaToken())) {
187                     mAkaTokenCache = imsiPseudonymResponse.getAkaToken();
188                 }
189                 break;
190             case Response.RESPONSE_CODE_AKA_CHALLENGE:
191                 if (mAkaTokenCache == null) {
192                     throw new TransientException("Something is wrong in the server side.");
193                 }
194                 // clear AKA token to trigger full authentication next time
195                 mAkaTokenCache = null;
196                 // TODO(b/274167498): Optimize the handling of expired AKA token
197                 return getImsiPseudonym();
198             case Response.RESPONSE_CODE_AKA_AUTH_FAILED:
199                 throw new NonTransientException("Authentication failed!");
200             case Response.RESPONSE_CODE_INVALID_REQUEST:
201                 throw new NonTransientException("Invalid request!");
202             case Response.RESPONSE_CODE_SERVER_ERROR:
203                 throw new TransientException("Server error!");
204             default:
205                 throw new NonTransientException("Unknown error!");
206         }
207 
208         int imsiPseudonymResponseCode = imsiPseudonymResponse.getGetImsiPseudonymResponseCode();
209         switch (imsiPseudonymResponseCode) {
210             case Response.RESPONSE_CODE_REQUEST_SUCCESSFUL:
211                 break;
212 
213             /*
214              * As experience, server may respond 1004(RESPONSE_CODE_INVALID_REQUEST) if it detects
215              * the secondary request not going to the same server with the first initial request,
216              * retry to recover it.
217              */
218             case Response.RESPONSE_CODE_INVALID_REQUEST:
219             case Response.RESPONSE_CODE_SERVER_ERROR:
220             case Response.RESPONSE_CODE_3GPP_AUTH_ONGOING:
221                 throw new TransientException("Server transient problem! Response code is "
222                         + imsiPseudonymResponseCode);
223 
224             case Response.RESPONSE_CODE_FORBIDDEN_REQUEST:
225             case Response.RESPONSE_CODE_UNSUPPORTED_OPERATION:
226             default:
227                 throw new NonTransientException("Something wrong when getting IMSI pseudonym! "
228                         + "Response code is " + imsiPseudonymResponseCode);
229         }
230         return imsiPseudonymResponse.toPseudonymInfo(mImsi);
231     }
232 
getAuthenticationChallenge()233     private String getAuthenticationChallenge()
234             throws TransientException, NonTransientException, ServiceEntitlementException {
235         HttpResponse httpResponse =
236                 HttpClient.request(
237                         mHttpRequestBuilder.setPostDataJsonArray(
238                                 mRequestFactory.createAuthRequest()).build());
239         ChallengeResponse challengeResponse = new ChallengeResponse(httpResponse.body());
240         int authResponseCode = challengeResponse.getAuthResponseCode();
241         switch (authResponseCode) {
242             case Response.RESPONSE_CODE_AKA_CHALLENGE:
243                 break;
244 
245             /*
246              * As experience, server may respond 1004(RESPONSE_CODE_INVALID_REQUEST) if it detects
247              * the secondary request not going to the same server with the first initial request,
248              * retry to recover it.
249              */
250             case Response.RESPONSE_CODE_INVALID_REQUEST:
251             case Response.RESPONSE_CODE_SERVER_ERROR:
252                 throw new TransientException("Server transient problem! Response code is "
253                         + authResponseCode);
254 
255             case Response.RESPONSE_CODE_AKA_AUTH_FAILED:
256             case Response.RESPONSE_CODE_REQUEST_SUCCESSFUL:
257             default:
258                 throw new NonTransientException(
259                         "Something wrong when getting authentication challenge! authResponseCode="
260                                 + authResponseCode);
261         }
262         return challengeResponse.getEapAkaChallenge();
263     }
264 
265     /**
266      * Callback which will be called after OOB pseudonym retrieval.
267      */
268     public interface Callback {
269 
270         /**
271          * Indicates an OOB pseudonym have been retrieved successfully.
272          * @param carrierId The target carrier ID of the retrieved OOB pseudonym.
273          * @param pseudonymInfo The retrieved OOB pseudonym info.
274          */
onSuccess(int carrierId, PseudonymInfo pseudonymInfo)275         void onSuccess(int carrierId, PseudonymInfo pseudonymInfo);
276 
277         /**
278          * Indicate a failure happens when to retrieve the OOB pseudonym.
279          * @param carrierId The target carrier ID of the retrieval failure.
280          * @param reasonCode The failure reason code
281          * @param description The description of the failure.
282          */
onFailure(int carrierId, @FailureReasonCode int reasonCode, String description)283         void onFailure(int carrierId, @FailureReasonCode int reasonCode, String description);
284     }
285 }
286 
287