/*
* Copyright (C) 2023 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.internal.telephony.security;
import android.content.Context;
import android.telephony.CellularIdentifierDisclosure;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.telephony.metrics.CellularSecurityTransparencyStats;
import com.android.internal.telephony.subscription.SubscriptionInfoInternal;
import com.android.internal.telephony.subscription.SubscriptionManagerService;
import com.android.telephony.Rlog;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* Encapsulates logic to emit notifications to the user that their cellular identifiers were
* disclosed in the clear. Callers add CellularIdentifierDisclosure instances by calling
* addDisclosure.
*
*
This class is thread safe and is designed to do costly work on worker threads. The intention
* is to allow callers to add disclosures from a Looper thread without worrying about blocking for
* IPC.
*
* @hide
*/
public class CellularIdentifierDisclosureNotifier {
private static final String TAG = "CellularIdentifierDisclosureNotifier";
private static final long DEFAULT_WINDOW_CLOSE_DURATION_IN_MINUTES = 15;
private static CellularIdentifierDisclosureNotifier sInstance = null;
private final long mWindowCloseDuration;
private final TimeUnit mWindowCloseUnit;
private final CellularNetworkSecuritySafetySource mSafetySource;
private final Object mEnabledLock = new Object();
@GuardedBy("mEnabledLock")
private boolean mEnabled = false;
// This is a single threaded executor. This is important because we want to ensure certain
// events are strictly serialized.
private ScheduledExecutorService mSerializedWorkQueue;
// This object should only be accessed from within the thread of mSerializedWorkQueue. Access
// outside of that thread would require additional synchronization.
private Map mWindows;
private SubscriptionManagerService mSubscriptionManagerService;
private CellularSecurityTransparencyStats mCellularSecurityTransparencyStats;
public CellularIdentifierDisclosureNotifier(CellularNetworkSecuritySafetySource safetySource) {
this(Executors.newSingleThreadScheduledExecutor(), DEFAULT_WINDOW_CLOSE_DURATION_IN_MINUTES,
TimeUnit.MINUTES, safetySource, SubscriptionManagerService.getInstance(),
new CellularSecurityTransparencyStats());
}
/**
* Construct a CellularIdentifierDisclosureNotifier by injection. This should only be used for
* testing.
*
* @param notificationQueue a ScheduledExecutorService that should only execute on a single
* thread.
*/
@VisibleForTesting
public CellularIdentifierDisclosureNotifier(
ScheduledExecutorService notificationQueue,
long windowCloseDuration,
TimeUnit windowCloseUnit,
CellularNetworkSecuritySafetySource safetySource,
SubscriptionManagerService subscriptionManagerService,
CellularSecurityTransparencyStats cellularSecurityTransparencyStats) {
mSerializedWorkQueue = notificationQueue;
mWindowCloseDuration = windowCloseDuration;
mWindowCloseUnit = windowCloseUnit;
mWindows = new HashMap<>();
mSafetySource = safetySource;
mSubscriptionManagerService = subscriptionManagerService;
mCellularSecurityTransparencyStats = cellularSecurityTransparencyStats;
}
/**
* Add a CellularIdentifierDisclosure to be tracked by this instance. If appropriate, this will
* trigger a user notification.
*/
public void addDisclosure(Context context, int subId, CellularIdentifierDisclosure disclosure) {
Rlog.d(TAG, "Identifier disclosure reported: " + disclosure);
logDisclosure(subId, disclosure);
synchronized (mEnabledLock) {
if (!mEnabled) {
Rlog.d(TAG, "Skipping disclosure because notifier was disabled.");
return;
}
// Don't notify if this disclosure happened in service of an emergency. That's a user
// initiated action that we don't want to interfere with.
if (disclosure.isEmergency()) {
Rlog.i(TAG, "Ignoring identifier disclosure associated with an emergency.");
return;
}
// Schedule incrementAndNotify from within the lock because we're sure at this point
// that we're enabled. This allows incrementAndNotify to avoid re-checking mEnabled
// because we know that any actions taken on disabled will be scheduled after this
// incrementAndNotify call.
try {
mSerializedWorkQueue.execute(incrementAndNotify(context, subId));
} catch (RejectedExecutionException e) {
Rlog.e(TAG, "Failed to schedule incrementAndNotify: " + e.getMessage());
}
} // end mEnabledLock
}
private void logDisclosure(int subId, CellularIdentifierDisclosure disclosure) {
try {
mSerializedWorkQueue.execute(runLogDisclosure(subId, disclosure));
} catch (RejectedExecutionException e) {
Rlog.e(TAG, "Failed to schedule runLogDisclosure: " + e.getMessage());
}
}
private Runnable runLogDisclosure(int subId,
CellularIdentifierDisclosure disclosure) {
return () -> {
SubscriptionInfoInternal subInfo =
mSubscriptionManagerService.getSubscriptionInfoInternal(subId);
String mcc = null;
String mnc = null;
if (subInfo != null) {
mcc = subInfo.getMcc();
mnc = subInfo.getMnc();
}
mCellularSecurityTransparencyStats.logIdentifierDisclosure(disclosure, mcc, mnc,
isEnabled());
};
}
/**
* Re-enable if previously disabled. This means that {@code addDisclsoure} will start tracking
* disclosures again and potentially emitting notifications.
*/
public void enable(Context context) {
synchronized (mEnabledLock) {
Rlog.d(TAG, "enabled");
mEnabled = true;
try {
mSerializedWorkQueue.execute(onEnableNotifier(context));
} catch (RejectedExecutionException e) {
Rlog.e(TAG, "Failed to schedule onEnableNotifier: " + e.getMessage());
}
}
}
/**
* Clear all internal state and prevent further notifications until optionally re-enabled.
* This can be used to in response to a user disabling the feature to emit notifications.
* If {@code addDisclosure} is called while in a disabled state, disclosures will be dropped.
*/
public void disable(Context context) {
Rlog.d(TAG, "disabled");
synchronized (mEnabledLock) {
mEnabled = false;
try {
mSerializedWorkQueue.execute(onDisableNotifier(context));
} catch (RejectedExecutionException e) {
Rlog.e(TAG, "Failed to schedule onDisableNotifier: " + e.getMessage());
}
}
}
public boolean isEnabled() {
synchronized (mEnabledLock) {
return mEnabled;
}
}
/** Get a singleton CellularIdentifierDisclosureNotifier. */
public static synchronized CellularIdentifierDisclosureNotifier getInstance(
CellularNetworkSecuritySafetySource safetySource) {
if (sInstance == null) {
sInstance = new CellularIdentifierDisclosureNotifier(safetySource);
}
return sInstance;
}
private Runnable incrementAndNotify(Context context, int subId) {
return () -> {
DisclosureWindow window = mWindows.get(subId);
if (window == null) {
window = new DisclosureWindow(subId);
mWindows.put(subId, window);
}
window.increment(context, this);
int disclosureCount = window.getDisclosureCount();
Rlog.d(
TAG,
"Emitting notification for subId: "
+ subId
+ ". New disclosure count "
+ disclosureCount);
mSafetySource.setIdentifierDisclosure(
context,
subId,
disclosureCount,
window.getFirstOpen(),
window.getCurrentEnd());
};
}
private Runnable onDisableNotifier(Context context) {
return () -> {
Rlog.d(TAG, "On disable notifier");
for (DisclosureWindow window : mWindows.values()) {
window.close();
}
mSafetySource.setIdentifierDisclosureIssueEnabled(context, false);
};
}
private Runnable onEnableNotifier(Context context) {
return () -> {
Rlog.i(TAG, "On enable notifier");
mSafetySource.setIdentifierDisclosureIssueEnabled(context, true);
};
}
/**
* Get the disclosure count for a given subId. NOTE: This method is not thread safe. Without
* external synchronization, one should only call it if there are no pending tasks on the
* Executor passed into this class.
*/
@VisibleForTesting
public int getCurrentDisclosureCount(int subId) {
DisclosureWindow window = mWindows.get(subId);
if (window != null) {
return window.getDisclosureCount();
}
return 0;
}
/**
* Get the open time for a given subId. NOTE: This method is not thread safe. Without
* external synchronization, one should only call it if there are no pending tasks on the
* Executor passed into this class.
*/
@VisibleForTesting
public Instant getFirstOpen(int subId) {
DisclosureWindow window = mWindows.get(subId);
if (window != null) {
return window.getFirstOpen();
}
return null;
}
/**
* Get the current end time for a given subId. NOTE: This method is not thread safe. Without
* external synchronization, one should only call it if there are no pending tasks on the
* Executor passed into this class.
*/
@VisibleForTesting
public Instant getCurrentEnd(int subId) {
DisclosureWindow window = mWindows.get(subId);
if (window != null) {
return window.getCurrentEnd();
}
return null;
}
/**
* A helper class that maintains all state associated with the disclosure window for a single
* subId. No methods are thread safe. Callers must implement all synchronization.
*/
private class DisclosureWindow {
private int mDisclosureCount;
private Instant mWindowFirstOpen;
private Instant mLastEvent;
private ScheduledFuture> mWhenWindowCloses;
private int mSubId;
DisclosureWindow(int subId) {
mDisclosureCount = 0;
mWindowFirstOpen = null;
mLastEvent = null;
mSubId = subId;
mWhenWindowCloses = null;
}
void increment(Context context, CellularIdentifierDisclosureNotifier notifier) {
mDisclosureCount++;
Instant now = Instant.now();
if (mDisclosureCount == 1) {
// Our window was opened for the first time
mWindowFirstOpen = now;
}
mLastEvent = now;
cancelWindowCloseFuture();
try {
mWhenWindowCloses =
notifier.mSerializedWorkQueue.schedule(
closeWindowRunnable(context),
notifier.mWindowCloseDuration,
notifier.mWindowCloseUnit);
} catch (RejectedExecutionException e) {
Rlog.e(
TAG,
"Failed to schedule closeWindow for subId "
+ mSubId
+ " : "
+ e.getMessage());
}
}
int getDisclosureCount() {
return mDisclosureCount;
}
Instant getFirstOpen() {
return mWindowFirstOpen;
}
Instant getCurrentEnd() {
return mLastEvent;
}
void close() {
mDisclosureCount = 0;
mWindowFirstOpen = null;
mLastEvent = null;
if (mWhenWindowCloses == null) {
return;
}
mWhenWindowCloses = null;
}
private Runnable closeWindowRunnable(Context context) {
return () -> {
Rlog.i(
TAG,
"Disclosure window closing for subId "
+ mSubId
+ ". Disclosure count was "
+ getDisclosureCount());
close();
mSafetySource.clearIdentifierDisclosure(context, mSubId);
};
}
private boolean cancelWindowCloseFuture() {
if (mWhenWindowCloses == null) {
return false;
}
// Pass false to not interrupt a running Future. Nothing about our notifier is ready
// for this type of preemption.
return mWhenWindowCloses.cancel(false);
}
}
}