diff options
Diffstat (limited to 'provider_src/com/android/email/SecurityPolicy.java')
-rw-r--r-- | provider_src/com/android/email/SecurityPolicy.java | 914 |
1 files changed, 914 insertions, 0 deletions
diff --git a/provider_src/com/android/email/SecurityPolicy.java b/provider_src/com/android/email/SecurityPolicy.java new file mode 100644 index 000000000..58e77a22d --- /dev/null +++ b/provider_src/com/android/email/SecurityPolicy.java @@ -0,0 +1,914 @@ +/* + * Copyright (C) 2010 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.email; + +import android.app.admin.DeviceAdminInfo; +import android.app.admin.DeviceAdminReceiver; +import android.app.admin.DevicePolicyManager; +import android.content.ComponentName; +import android.content.ContentProviderOperation; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.OperationApplicationException; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.RemoteException; + +import com.android.email.NotificationController; +import com.android.email.NotificationControllerCreatorHolder; +import com.android.email.provider.AccountReconciler; +import com.android.email.provider.EmailProvider; +import com.android.email.service.EmailBroadcastProcessorService; +import com.android.email.service.EmailServiceUtils; +import com.android.emailcommon.Logging; +import com.android.emailcommon.provider.Account; +import com.android.emailcommon.provider.EmailContent; +import com.android.emailcommon.provider.EmailContent.AccountColumns; +import com.android.emailcommon.provider.EmailContent.PolicyColumns; +import com.android.emailcommon.provider.Policy; +import com.android.emailcommon.utility.TextUtilities; +import com.android.emailcommon.utility.Utility; +import com.android.mail.utils.LogUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; + +/** + * Utility functions to support reading and writing security policies, and handshaking the device + * into and out of various security states. + */ +public class SecurityPolicy { + private static final String TAG = "Email/SecurityPolicy"; + private static SecurityPolicy sInstance = null; + private Context mContext; + private DevicePolicyManager mDPM; + private final ComponentName mAdminName; + private Policy mAggregatePolicy; + + // Messages used for DevicePolicyManager callbacks + private static final int DEVICE_ADMIN_MESSAGE_ENABLED = 1; + private static final int DEVICE_ADMIN_MESSAGE_DISABLED = 2; + private static final int DEVICE_ADMIN_MESSAGE_PASSWORD_CHANGED = 3; + private static final int DEVICE_ADMIN_MESSAGE_PASSWORD_EXPIRING = 4; + + private static final String HAS_PASSWORD_EXPIRATION = + PolicyColumns.PASSWORD_EXPIRATION_DAYS + ">0"; + + /** + * Get the security policy instance + */ + public synchronized static SecurityPolicy getInstance(Context context) { + if (sInstance == null) { + sInstance = new SecurityPolicy(context.getApplicationContext()); + } + return sInstance; + } + + /** + * Private constructor (one time only) + */ + private SecurityPolicy(Context context) { + mContext = context.getApplicationContext(); + mDPM = null; + mAdminName = new ComponentName(context, PolicyAdmin.class); + mAggregatePolicy = null; + } + + /** + * For testing only: Inject context into already-created instance + */ + /* package */ void setContext(Context context) { + mContext = context; + } + + /** + * Compute the aggregate policy for all accounts that require it, and record it. + * + * The business logic is as follows: + * min password length take the max + * password mode take the max (strongest mode) + * max password fails take the min + * max screen lock time take the min + * require remote wipe take the max (logical or) + * password history take the max (strongest mode) + * password expiration take the min (strongest mode) + * password complex chars take the max (strongest mode) + * encryption take the max (logical or) + * + * @return a policy representing the strongest aggregate. If no policy sets are defined, + * a lightweight "nothing required" policy will be returned. Never null. + */ + @VisibleForTesting + Policy computeAggregatePolicy() { + boolean policiesFound = false; + Policy aggregate = new Policy(); + aggregate.mPasswordMinLength = Integer.MIN_VALUE; + aggregate.mPasswordMode = Integer.MIN_VALUE; + aggregate.mPasswordMaxFails = Integer.MAX_VALUE; + aggregate.mPasswordHistory = Integer.MIN_VALUE; + aggregate.mPasswordExpirationDays = Integer.MAX_VALUE; + aggregate.mPasswordComplexChars = Integer.MIN_VALUE; + aggregate.mMaxScreenLockTime = Integer.MAX_VALUE; + aggregate.mRequireRemoteWipe = false; + aggregate.mRequireEncryption = false; + + // This can never be supported at this time. It exists only for historic reasons where + // this was able to be supported prior to the introduction of proper removable storage + // support for external storage. + aggregate.mRequireEncryptionExternal = false; + + Cursor c = mContext.getContentResolver().query(Policy.CONTENT_URI, + Policy.CONTENT_PROJECTION, null, null, null); + Policy policy = new Policy(); + try { + while (c.moveToNext()) { + policy.restore(c); + if (DebugUtils.DEBUG) { + LogUtils.d(TAG, "Aggregate from: " + policy); + } + aggregate.mPasswordMinLength = + Math.max(policy.mPasswordMinLength, aggregate.mPasswordMinLength); + aggregate.mPasswordMode = Math.max(policy.mPasswordMode, aggregate.mPasswordMode); + if (policy.mPasswordMaxFails > 0) { + aggregate.mPasswordMaxFails = + Math.min(policy.mPasswordMaxFails, aggregate.mPasswordMaxFails); + } + if (policy.mMaxScreenLockTime > 0) { + aggregate.mMaxScreenLockTime = Math.min(policy.mMaxScreenLockTime, + aggregate.mMaxScreenLockTime); + } + if (policy.mPasswordHistory > 0) { + aggregate.mPasswordHistory = + Math.max(policy.mPasswordHistory, aggregate.mPasswordHistory); + } + if (policy.mPasswordExpirationDays > 0) { + aggregate.mPasswordExpirationDays = + Math.min(policy.mPasswordExpirationDays, aggregate.mPasswordExpirationDays); + } + if (policy.mPasswordComplexChars > 0) { + aggregate.mPasswordComplexChars = Math.max(policy.mPasswordComplexChars, + aggregate.mPasswordComplexChars); + } + aggregate.mRequireRemoteWipe |= policy.mRequireRemoteWipe; + aggregate.mRequireEncryption |= policy.mRequireEncryption; + aggregate.mDontAllowCamera |= policy.mDontAllowCamera; + policiesFound = true; + } + } finally { + c.close(); + } + if (policiesFound) { + // final cleanup pass converts any untouched min/max values to zero (not specified) + if (aggregate.mPasswordMinLength == Integer.MIN_VALUE) aggregate.mPasswordMinLength = 0; + if (aggregate.mPasswordMode == Integer.MIN_VALUE) aggregate.mPasswordMode = 0; + if (aggregate.mPasswordMaxFails == Integer.MAX_VALUE) aggregate.mPasswordMaxFails = 0; + if (aggregate.mMaxScreenLockTime == Integer.MAX_VALUE) aggregate.mMaxScreenLockTime = 0; + if (aggregate.mPasswordHistory == Integer.MIN_VALUE) aggregate.mPasswordHistory = 0; + if (aggregate.mPasswordExpirationDays == Integer.MAX_VALUE) + aggregate.mPasswordExpirationDays = 0; + if (aggregate.mPasswordComplexChars == Integer.MIN_VALUE) + aggregate.mPasswordComplexChars = 0; + if (DebugUtils.DEBUG) { + LogUtils.d(TAG, "Calculated Aggregate: " + aggregate); + } + return aggregate; + } + if (DebugUtils.DEBUG) { + LogUtils.d(TAG, "Calculated Aggregate: no policy"); + } + return Policy.NO_POLICY; + } + + /** + * Return updated aggregate policy, from cached value if possible + */ + public synchronized Policy getAggregatePolicy() { + if (mAggregatePolicy == null) { + mAggregatePolicy = computeAggregatePolicy(); + } + return mAggregatePolicy; + } + + /** + * Get the dpm. This mainly allows us to make some utility calls without it, for testing. + */ + /* package */ synchronized DevicePolicyManager getDPM() { + if (mDPM == null) { + mDPM = (DevicePolicyManager) mContext.getSystemService(Context.DEVICE_POLICY_SERVICE); + } + return mDPM; + } + + /** + * API: Report that policies may have been updated due to rewriting values in an Account; we + * clear the aggregate policy (so it can be recomputed) and set the policies in the DPM + */ + public synchronized void policiesUpdated() { + mAggregatePolicy = null; + setActivePolicies(); + } + + /** + * API: Report that policies may have been updated *and* the caller vouches that the + * change is a reduction in policies. This forces an immediate change to device state. + * Typically used when deleting accounts, although we may use it for server-side policy + * rollbacks. + */ + public void reducePolicies() { + if (DebugUtils.DEBUG) { + LogUtils.d(TAG, "reducePolicies"); + } + policiesUpdated(); + } + + /** + * API: Query used to determine if a given policy is "active" (the device is operating at + * the required security level). + * + * @param policy the policies requested, or null to check aggregate stored policies + * @return true if the requested policies are active, false if not. + */ + public boolean isActive(Policy policy) { + int reasons = getInactiveReasons(policy); + if (DebugUtils.DEBUG && (reasons != 0)) { + StringBuilder sb = new StringBuilder("isActive for " + policy + ": "); + sb.append("FALSE -> "); + if ((reasons & INACTIVE_NEED_ACTIVATION) != 0) { + sb.append("no_admin "); + } + if ((reasons & INACTIVE_NEED_CONFIGURATION) != 0) { + sb.append("config "); + } + if ((reasons & INACTIVE_NEED_PASSWORD) != 0) { + sb.append("password "); + } + if ((reasons & INACTIVE_NEED_ENCRYPTION) != 0) { + sb.append("encryption "); + } + if ((reasons & INACTIVE_PROTOCOL_POLICIES) != 0) { + sb.append("protocol "); + } + LogUtils.d(TAG, sb.toString()); + } + return reasons == 0; + } + + /** + * Return bits from isActive: Device Policy Manager has not been activated + */ + public final static int INACTIVE_NEED_ACTIVATION = 1; + + /** + * Return bits from isActive: Some required configuration is not correct (no user action). + */ + public final static int INACTIVE_NEED_CONFIGURATION = 2; + + /** + * Return bits from isActive: Password needs to be set or updated + */ + public final static int INACTIVE_NEED_PASSWORD = 4; + + /** + * Return bits from isActive: Encryption has not be enabled + */ + public final static int INACTIVE_NEED_ENCRYPTION = 8; + + /** + * Return bits from isActive: Protocol-specific policies cannot be enforced + */ + public final static int INACTIVE_PROTOCOL_POLICIES = 16; + + /** + * API: Query used to determine if a given policy is "active" (the device is operating at + * the required security level). + * + * This can be used when syncing a specific account, by passing a specific set of policies + * for that account. Or, it can be used at any time to compare the device + * state against the aggregate set of device policies stored in all accounts. + * + * This method is for queries only, and does not trigger any change in device state. + * + * NOTE: If there are multiple accounts with password expiration policies, the device + * password will be set to expire in the shortest required interval (most secure). This method + * will return 'false' as soon as the password expires - irrespective of which account caused + * the expiration. In other words, all accounts (that require expiration) will run/stop + * based on the requirements of the account with the shortest interval. + * + * @param policy the policies requested, or null to check aggregate stored policies + * @return zero if the requested policies are active, non-zero bits indicates that more work + * is needed (typically, by the user) before the required security polices are fully active. + */ + public int getInactiveReasons(Policy policy) { + // select aggregate set if needed + if (policy == null) { + policy = getAggregatePolicy(); + } + // quick check for the "empty set" of no policies + if (policy == Policy.NO_POLICY) { + return 0; + } + int reasons = 0; + DevicePolicyManager dpm = getDPM(); + if (isActiveAdmin()) { + // check each policy explicitly + if (policy.mPasswordMinLength > 0) { + if (dpm.getPasswordMinimumLength(mAdminName) < policy.mPasswordMinLength) { + reasons |= INACTIVE_NEED_PASSWORD; + } + } + if (policy.mPasswordMode > 0) { + if (dpm.getPasswordQuality(mAdminName) < policy.getDPManagerPasswordQuality()) { + reasons |= INACTIVE_NEED_PASSWORD; + } + if (!dpm.isActivePasswordSufficient()) { + reasons |= INACTIVE_NEED_PASSWORD; + } + } + if (policy.mMaxScreenLockTime > 0) { + // Note, we use seconds, dpm uses milliseconds + if (dpm.getMaximumTimeToLock(mAdminName) > policy.mMaxScreenLockTime * 1000) { + reasons |= INACTIVE_NEED_CONFIGURATION; + } + } + if (policy.mPasswordExpirationDays > 0) { + // confirm that expirations are currently set + long currentTimeout = dpm.getPasswordExpirationTimeout(mAdminName); + if (currentTimeout == 0 + || currentTimeout > policy.getDPManagerPasswordExpirationTimeout()) { + reasons |= INACTIVE_NEED_PASSWORD; + } + // confirm that the current password hasn't expired + long expirationDate = dpm.getPasswordExpiration(mAdminName); + long timeUntilExpiration = expirationDate - System.currentTimeMillis(); + boolean expired = timeUntilExpiration < 0; + if (expired) { + reasons |= INACTIVE_NEED_PASSWORD; + } + } + if (policy.mPasswordHistory > 0) { + if (dpm.getPasswordHistoryLength(mAdminName) < policy.mPasswordHistory) { + // There's no user action for changes here; this is just a configuration change + reasons |= INACTIVE_NEED_CONFIGURATION; + } + } + if (policy.mPasswordComplexChars > 0) { + if (dpm.getPasswordMinimumNonLetter(mAdminName) < policy.mPasswordComplexChars) { + reasons |= INACTIVE_NEED_PASSWORD; + } + } + if (policy.mRequireEncryption) { + int encryptionStatus = getDPM().getStorageEncryptionStatus(); + if (encryptionStatus != DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE) { + reasons |= INACTIVE_NEED_ENCRYPTION; + } + } + if (policy.mDontAllowCamera && !dpm.getCameraDisabled(mAdminName)) { + reasons |= INACTIVE_NEED_CONFIGURATION; + } + // password failures are counted locally - no test required here + // no check required for remote wipe (it's supported, if we're the admin) + + if (policy.mProtocolPoliciesUnsupported != null) { + reasons |= INACTIVE_PROTOCOL_POLICIES; + } + + // If we made it all the way, reasons == 0 here. Otherwise it's a list of grievances. + return reasons; + } + // return false, not active + return INACTIVE_NEED_ACTIVATION; + } + + /** + * Set the requested security level based on the aggregate set of requests. + * If the set is empty, we release our device administration. If the set is non-empty, + * we only proceed if we are already active as an admin. + */ + public void setActivePolicies() { + DevicePolicyManager dpm = getDPM(); + // compute aggregate set of policies + Policy aggregatePolicy = getAggregatePolicy(); + // if empty set, detach from policy manager + if (aggregatePolicy == Policy.NO_POLICY) { + if (DebugUtils.DEBUG) { + LogUtils.d(TAG, "setActivePolicies: none, remove admin"); + } + dpm.removeActiveAdmin(mAdminName); + } else if (isActiveAdmin()) { + if (DebugUtils.DEBUG) { + LogUtils.d(TAG, "setActivePolicies: " + aggregatePolicy); + } + // set each policy in the policy manager + // password mode & length + dpm.setPasswordQuality(mAdminName, aggregatePolicy.getDPManagerPasswordQuality()); + dpm.setPasswordMinimumLength(mAdminName, aggregatePolicy.mPasswordMinLength); + // screen lock time + dpm.setMaximumTimeToLock(mAdminName, aggregatePolicy.mMaxScreenLockTime * 1000); + // local wipe (failed passwords limit) + dpm.setMaximumFailedPasswordsForWipe(mAdminName, aggregatePolicy.mPasswordMaxFails); + // password expiration (days until a password expires). API takes mSec. + dpm.setPasswordExpirationTimeout(mAdminName, + aggregatePolicy.getDPManagerPasswordExpirationTimeout()); + // password history length (number of previous passwords that may not be reused) + dpm.setPasswordHistoryLength(mAdminName, aggregatePolicy.mPasswordHistory); + // password minimum complex characters. + // Note, in Exchange, "complex chars" simply means "non alpha", but in the DPM, + // setting the quality to complex also defaults min symbols=1 and min numeric=1. + // We always / safely clear minSymbols & minNumeric to zero (there is no policy + // configuration in which we explicitly require a minimum number of digits or symbols.) + dpm.setPasswordMinimumSymbols(mAdminName, 0); + dpm.setPasswordMinimumNumeric(mAdminName, 0); + dpm.setPasswordMinimumNonLetter(mAdminName, aggregatePolicy.mPasswordComplexChars); + // Device capabilities + dpm.setCameraDisabled(mAdminName, aggregatePolicy.mDontAllowCamera); + + // encryption required + dpm.setStorageEncryption(mAdminName, aggregatePolicy.mRequireEncryption); + } + } + + /** + * Convenience method; see javadoc below + */ + public static void setAccountHoldFlag(Context context, long accountId, boolean newState) { + Account account = Account.restoreAccountWithId(context, accountId); + if (account != null) { + setAccountHoldFlag(context, account, newState); + if (newState) { + // Make sure there's a notification up + final NotificationController nc = + NotificationControllerCreatorHolder.getInstance(context); + nc.showSecurityNeededNotification(account); + } + } + } + + /** + * API: Set/Clear the "hold" flag in any account. This flag serves a dual purpose: + * Setting it gives us an indication that it was blocked, and clearing it gives EAS a + * signal to try syncing again. + * @param context context + * @param account the account whose hold flag is to be set/cleared + * @param newState true = security hold, false = free to sync + */ + public static void setAccountHoldFlag(Context context, Account account, boolean newState) { + if (newState) { + account.mFlags |= Account.FLAGS_SECURITY_HOLD; + } else { + account.mFlags &= ~Account.FLAGS_SECURITY_HOLD; + } + ContentValues cv = new ContentValues(); + cv.put(AccountColumns.FLAGS, account.mFlags); + account.update(context, cv); + } + + /** + * API: Sync service should call this any time a sync fails due to isActive() returning false. + * This will kick off the notify-acquire-admin-state process and/or increase the security level. + * The caller needs to write the required policies into this account before making this call. + * Should not be called from UI thread - uses DB lookups to prepare new notifications + * + * @param accountId the account for which sync cannot proceed + */ + public void policiesRequired(long accountId) { + Account account = Account.restoreAccountWithId(mContext, accountId); + // In case the account has been deleted, just return + if (account == null) return; + if (account.mPolicyKey == 0) return; + Policy policy = Policy.restorePolicyWithId(mContext, account.mPolicyKey); + if (policy == null) return; + if (DebugUtils.DEBUG) { + LogUtils.d(TAG, "policiesRequired for " + account.mDisplayName + ": " + policy); + } + + // Mark the account as "on hold". + setAccountHoldFlag(mContext, account, true); + + // Put up an appropriate notification + final NotificationController nc = + NotificationControllerCreatorHolder.getInstance(mContext); + if (policy.mProtocolPoliciesUnsupported == null) { + nc.showSecurityNeededNotification(account); + } else { + nc.showSecurityUnsupportedNotification(account); + } + } + + public static void clearAccountPolicy(Context context, Account account) { + setAccountPolicy(context, account, null, null); + } + + /** + * Set the policy for an account atomically; this also removes any other policy associated with + * the account and sets the policy key for the account. If policy is null, the policyKey is + * set to 0 and the securitySyncKey to null. Also, update the account object to reflect the + * current policyKey and securitySyncKey + * @param context the caller's context + * @param account the account whose policy is to be set + * @param policy the policy to set, or null if we're clearing the policy + * @param securitySyncKey the security sync key for this account (ignored if policy is null) + */ + public static void setAccountPolicy(Context context, Account account, Policy policy, + String securitySyncKey) { + ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>(); + + // Make sure this is a valid policy set + if (policy != null) { + policy.normalize(); + // Add the new policy (no account will yet reference this) + ops.add(ContentProviderOperation.newInsert( + Policy.CONTENT_URI).withValues(policy.toContentValues()).build()); + // Make the policyKey of the account our newly created policy, and set the sync key + ops.add(ContentProviderOperation.newUpdate( + ContentUris.withAppendedId(Account.CONTENT_URI, account.mId)) + .withValueBackReference(AccountColumns.POLICY_KEY, 0) + .withValue(AccountColumns.SECURITY_SYNC_KEY, securitySyncKey) + .build()); + } else { + ops.add(ContentProviderOperation.newUpdate( + ContentUris.withAppendedId(Account.CONTENT_URI, account.mId)) + .withValue(AccountColumns.SECURITY_SYNC_KEY, null) + .withValue(AccountColumns.POLICY_KEY, 0) + .build()); + } + + // Delete the previous policy associated with this account, if any + if (account.mPolicyKey > 0) { + ops.add(ContentProviderOperation.newDelete( + ContentUris.withAppendedId( + Policy.CONTENT_URI, account.mPolicyKey)).build()); + } + + try { + context.getContentResolver().applyBatch(EmailContent.AUTHORITY, ops); + account.refresh(context); + syncAccount(context, account); + } catch (RemoteException e) { + // This is fatal to a remote process + throw new IllegalStateException("Exception setting account policy."); + } catch (OperationApplicationException e) { + // Can't happen; our provider doesn't throw this exception + } + } + + private static void syncAccount(final Context context, final Account account) { + final EmailServiceUtils.EmailServiceInfo info = + EmailServiceUtils.getServiceInfo(context, account.getProtocol(context)); + final android.accounts.Account amAccount = + new android.accounts.Account(account.mEmailAddress, info.accountType); + final Bundle extras = new Bundle(3); + extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); + extras.putBoolean(ContentResolver.SYNC_EXTRAS_DO_NOT_RETRY, true); + extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true); + ContentResolver.requestSync(amAccount, EmailContent.AUTHORITY, extras); + LogUtils.i(TAG, "requestSync SecurityPolicy syncAccount %s, %s", account.toString(), + extras.toString()); + } + + public void syncAccount(final Account account) { + syncAccount(mContext, account); + } + + public void setAccountPolicy(long accountId, Policy policy, String securityKey, + boolean notify) { + Account account = Account.restoreAccountWithId(mContext, accountId); + // In case the account has been deleted, just return + if (account == null) { + return; + } + Policy oldPolicy = null; + if (account.mPolicyKey > 0) { + oldPolicy = Policy.restorePolicyWithId(mContext, account.mPolicyKey); + } + + // If attachment policies have changed, fix up any affected attachment records + if (oldPolicy != null && securityKey != null) { + if ((oldPolicy.mDontAllowAttachments != policy.mDontAllowAttachments) || + (oldPolicy.mMaxAttachmentSize != policy.mMaxAttachmentSize)) { + Policy.setAttachmentFlagsForNewPolicy(mContext, account, policy); + } + } + + boolean policyChanged = (oldPolicy == null) || !oldPolicy.equals(policy); + if (!policyChanged && (TextUtilities.stringOrNullEquals(securityKey, + account.mSecuritySyncKey))) { + LogUtils.d(Logging.LOG_TAG, "setAccountPolicy; policy unchanged"); + } else { + setAccountPolicy(mContext, account, policy, securityKey); + policiesUpdated(); + } + + boolean setHold = false; + final NotificationController nc = + NotificationControllerCreatorHolder.getInstance(mContext); + if (policy.mProtocolPoliciesUnsupported != null) { + // We can't support this, reasons in unsupportedRemotePolicies + LogUtils.d(Logging.LOG_TAG, + "Notify policies for " + account.mDisplayName + " not supported."); + setHold = true; + if (notify) { + nc.showSecurityUnsupportedNotification(account); + } + // Erase data + Uri uri = EmailProvider.uiUri("uiaccountdata", accountId); + mContext.getContentResolver().delete(uri, null, null); + } else if (isActive(policy)) { + if (policyChanged) { + LogUtils.d(Logging.LOG_TAG, "Notify policies for " + account.mDisplayName + + " changed."); + if (notify) { + // Notify that policies changed + nc.showSecurityChangedNotification(account); + } + } else { + LogUtils.d(Logging.LOG_TAG, "Policy is active and unchanged; do not notify."); + } + } else { + setHold = true; + LogUtils.d(Logging.LOG_TAG, "Notify policies for " + account.mDisplayName + + " are not being enforced."); + if (notify) { + // Put up a notification + nc.showSecurityNeededNotification(account); + } + } + // Set/clear the account hold. + setAccountHoldFlag(mContext, account, setHold); + } + + /** + * Called from the notification's intent receiver to register that the notification can be + * cleared now. + */ + public void clearNotification() { + final NotificationController nc = + NotificationControllerCreatorHolder.getInstance(mContext); + + nc.cancelSecurityNeededNotification(); + } + + /** + * API: Remote wipe (from server). This is final, there is no confirmation. It will only + * return to the caller if there is an unexpected failure. The wipe includes external storage. + */ + public void remoteWipe() { + DevicePolicyManager dpm = getDPM(); + if (dpm.isAdminActive(mAdminName)) { + dpm.wipeData(DevicePolicyManager.WIPE_EXTERNAL_STORAGE); + } else { + LogUtils.d(Logging.LOG_TAG, "Could not remote wipe because not device admin."); + } + } + /** + * If we are not the active device admin, try to become so. + * + * Also checks for any policies that we have added during the lifetime of this app. + * This catches the case where the user granted an earlier (smaller) set of policies + * but an app upgrade requires that new policies be granted. + * + * @return true if we are already active, false if we are not + */ + public boolean isActiveAdmin() { + DevicePolicyManager dpm = getDPM(); + return dpm.isAdminActive(mAdminName) + && dpm.hasGrantedPolicy(mAdminName, DeviceAdminInfo.USES_POLICY_EXPIRE_PASSWORD) + && dpm.hasGrantedPolicy(mAdminName, DeviceAdminInfo.USES_ENCRYPTED_STORAGE) + && dpm.hasGrantedPolicy(mAdminName, DeviceAdminInfo.USES_POLICY_DISABLE_CAMERA); + } + + /** + * Report admin component name - for making calls into device policy manager + */ + public ComponentName getAdminComponent() { + return mAdminName; + } + + /** + * Delete all accounts whose security flags aren't zero (i.e. they have security enabled). + * This method is synchronous, so it should normally be called within a worker thread (the + * exception being for unit tests) + * + * @param context the caller's context + */ + /*package*/ void deleteSecuredAccounts(Context context) { + ContentResolver cr = context.getContentResolver(); + // Find all accounts with security and delete them + Cursor c = cr.query(Account.CONTENT_URI, EmailContent.ID_PROJECTION, + Account.SECURITY_NONZERO_SELECTION, null, null); + try { + LogUtils.w(TAG, "Email administration disabled; deleting " + c.getCount() + + " secured account(s)"); + while (c.moveToNext()) { + long accountId = c.getLong(EmailContent.ID_PROJECTION_COLUMN); + Uri uri = EmailProvider.uiUri("uiaccount", accountId); + cr.delete(uri, null, null); + } + } finally { + c.close(); + } + policiesUpdated(); + AccountReconciler.reconcileAccounts(context); + } + + /** + * Internal handler for enabled->disabled transitions. Deletes all secured accounts. + * Must call from worker thread, not on UI thread. + */ + /*package*/ void onAdminEnabled(boolean isEnabled) { + if (!isEnabled) { + deleteSecuredAccounts(mContext); + } + } + + /** + * Handle password expiration - if any accounts appear to have triggered this, put up + * warnings, or even shut them down. + * + * NOTE: If there are multiple accounts with password expiration policies, the device + * password will be set to expire in the shortest required interval (most secure). The logic + * in this method operates based on the aggregate setting - irrespective of which account caused + * the expiration. In other words, all accounts (that require expiration) will run/stop + * based on the requirements of the account with the shortest interval. + */ + private void onPasswordExpiring(Context context) { + // 1. Do we have any accounts that matter here? + long nextExpiringAccountId = findShortestExpiration(context); + + // 2. If not, exit immediately + if (nextExpiringAccountId == -1) { + return; + } + + // 3. If yes, are we warning or expired? + long expirationDate = getDPM().getPasswordExpiration(mAdminName); + long timeUntilExpiration = expirationDate - System.currentTimeMillis(); + boolean expired = timeUntilExpiration < 0; + final NotificationController nc = + NotificationControllerCreatorHolder.getInstance(context); + if (!expired) { + // 4. If warning, simply put up a generic notification and report that it came from + // the shortest-expiring account. + nc.showPasswordExpiringNotificationSynchronous(nextExpiringAccountId); + } else { + // 5. Actually expired - find all accounts that expire passwords, and wipe them + boolean wiped = wipeExpiredAccounts(context); + if (wiped) { + nc.showPasswordExpiredNotificationSynchronous(nextExpiringAccountId); + } + } + } + + /** + * Find the account with the shortest expiration time. This is always assumed to be + * the account that forces the password to be refreshed. + * @return -1 if no expirations, or accountId if one is found + */ + @VisibleForTesting + /*package*/ static long findShortestExpiration(Context context) { + long policyId = Utility.getFirstRowLong(context, Policy.CONTENT_URI, Policy.ID_PROJECTION, + HAS_PASSWORD_EXPIRATION, null, PolicyColumns.PASSWORD_EXPIRATION_DAYS + " ASC", + EmailContent.ID_PROJECTION_COLUMN, -1L); + if (policyId < 0) return -1L; + return Policy.getAccountIdWithPolicyKey(context, policyId); + } + + /** + * For all accounts that require password expiration, put them in security hold and wipe + * their data. + * @param context context + * @return true if one or more accounts were wiped + */ + @VisibleForTesting + /*package*/ static boolean wipeExpiredAccounts(Context context) { + boolean result = false; + Cursor c = context.getContentResolver().query(Policy.CONTENT_URI, + Policy.ID_PROJECTION, HAS_PASSWORD_EXPIRATION, null, null); + if (c == null) { + return false; + } + try { + while (c.moveToNext()) { + long policyId = c.getLong(Policy.ID_PROJECTION_COLUMN); + long accountId = Policy.getAccountIdWithPolicyKey(context, policyId); + if (accountId < 0) continue; + Account account = Account.restoreAccountWithId(context, accountId); + if (account != null) { + // Mark the account as "on hold". + setAccountHoldFlag(context, account, true); + // Erase data + Uri uri = EmailProvider.uiUri("uiaccountdata", accountId); + context.getContentResolver().delete(uri, null, null); + // Report one or more were found + result = true; + } + } + } finally { + c.close(); + } + return result; + } + + /** + * Callback from EmailBroadcastProcessorService. This provides the workers for the + * DeviceAdminReceiver calls. These should perform the work directly and not use async + * threads for completion. + */ + public static void onDeviceAdminReceiverMessage(Context context, int message) { + SecurityPolicy instance = SecurityPolicy.getInstance(context); + switch (message) { + case DEVICE_ADMIN_MESSAGE_ENABLED: + instance.onAdminEnabled(true); + break; + case DEVICE_ADMIN_MESSAGE_DISABLED: + instance.onAdminEnabled(false); + break; + case DEVICE_ADMIN_MESSAGE_PASSWORD_CHANGED: + // TODO make a small helper for this + // Clear security holds (if any) + Account.clearSecurityHoldOnAllAccounts(context); + // Cancel any active notifications (if any are posted) + final NotificationController nc = + NotificationControllerCreatorHolder.getInstance(context); + + nc.cancelPasswordExpirationNotifications(); + break; + case DEVICE_ADMIN_MESSAGE_PASSWORD_EXPIRING: + instance.onPasswordExpiring(instance.mContext); + break; + } + } + + /** + * Device Policy administrator. This is primarily a listener for device state changes. + * Note: This is instantiated by incoming messages. + * Note: This is actually a BroadcastReceiver and must remain within the guidelines required + * for proper behavior, including avoidance of ANRs. + * Note: We do not implement onPasswordFailed() because the default behavior of the + * DevicePolicyManager - complete local wipe after 'n' failures - is sufficient. + */ + public static class PolicyAdmin extends DeviceAdminReceiver { + + /** + * Called after the administrator is first enabled. + */ + @Override + public void onEnabled(Context context, Intent intent) { + EmailBroadcastProcessorService.processDevicePolicyMessage(context, + DEVICE_ADMIN_MESSAGE_ENABLED); + } + + /** + * Called prior to the administrator being disabled. + */ + @Override + public void onDisabled(Context context, Intent intent) { + EmailBroadcastProcessorService.processDevicePolicyMessage(context, + DEVICE_ADMIN_MESSAGE_DISABLED); + } + + /** + * Called when the user asks to disable administration; we return a warning string that + * will be presented to the user + */ + @Override + public CharSequence onDisableRequested(Context context, Intent intent) { + return context.getString(R.string.disable_admin_warning); + } + + /** + * Called after the user has changed their password. + */ + @Override + public void onPasswordChanged(Context context, Intent intent) { + EmailBroadcastProcessorService.processDevicePolicyMessage(context, + DEVICE_ADMIN_MESSAGE_PASSWORD_CHANGED); + } + + /** + * Called when device password is expiring + */ + @Override + public void onPasswordExpiring(Context context, Intent intent) { + EmailBroadcastProcessorService.processDevicePolicyMessage(context, + DEVICE_ADMIN_MESSAGE_PASSWORD_EXPIRING); + } + } +} |