From 938293e4153b053b6490e229bf5368e196eb9c81 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Thu, 11 Jun 2026 04:18:13 -0700 Subject: [PATCH 1/8] gmscompat: declare scoped recoverable keystore permission --- core/api/system-current.txt | 1 + core/res/AndroidManifest.xml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/core/api/system-current.txt b/core/api/system-current.txt index c0df87a641bb3..0106fc03896aa 100644 --- a/core/api/system-current.txt +++ b/core/api/system-current.txt @@ -411,6 +411,7 @@ package android { field public static final String RECORD_BACKGROUND_AUDIO = "android.permission.RECORD_BACKGROUND_AUDIO"; field public static final String RECOVERY = "android.permission.RECOVERY"; field public static final String RECOVER_KEYSTORE = "android.permission.RECOVER_KEYSTORE"; + field public static final String RECOVER_KEYSTORE_GMSCORE = "android.permission.RECOVER_KEYSTORE_GMSCORE"; field public static final String REGISTER_CALL_PROVIDER = "android.permission.REGISTER_CALL_PROVIDER"; field public static final String REGISTER_CONNECTION_MANAGER = "android.permission.REGISTER_CONNECTION_MANAGER"; field @FlaggedApi("android.net.platform.flags.register_nsd_offload_engine") public static final String REGISTER_NSD_OFFLOAD_ENGINE = "android.permission.REGISTER_NSD_OFFLOAD_ENGINE"; diff --git a/core/res/AndroidManifest.xml b/core/res/AndroidManifest.xml index fa2c3f0534c0d..e377711f2c775 100644 --- a/core/res/AndroidManifest.xml +++ b/core/res/AndroidManifest.xml @@ -7959,6 +7959,10 @@ + + + From 6b0f1dfdd4cb6fc60f62528e8e2e7cf1c1dee1d5 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Thu, 11 Jun 2026 04:18:23 -0700 Subject: [PATCH 2/8] gmscompat: add support for RECOVER_KEYSTORE_GMSCORE permission Let the GmsCompat package flag grant RECOVER_KEYSTORE_GMSCORE to Play services and accept it only for the account keychain RecoveryController methods used for setup, sync, generated keys, status, snapshots and restore sessions. We keep remaining functions like direct raw key import and remote lockscreen validation behind the existing privileged permissions. --- .../app/compat/gms/GmsCorePackageFlag.java | 1 + .../RecoverableKeyStoreManager.java | 55 ++++++++++++------- .../android/server/pm/ext/GmsCoreHooks.java | 6 +- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/core/java/android/app/compat/gms/GmsCorePackageFlag.java b/core/java/android/app/compat/gms/GmsCorePackageFlag.java index e8ffcb3140ae6..060f0daa44849 100644 --- a/core/java/android/app/compat/gms/GmsCorePackageFlag.java +++ b/core/java/android/app/compat/gms/GmsCorePackageFlag.java @@ -3,4 +3,5 @@ /** @hide */ public interface GmsCorePackageFlag { int GRANT_PERMS_FOR_ICC_AUTHENTICATION = 0; + int GRANT_PERMS_FOR_RECOVER_KEYSTORE_GMSCORE = 1; } diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManager.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManager.java index 710e9c5dcc1b5..bda0d309d5c50 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManager.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManager.java @@ -34,6 +34,7 @@ import android.app.RemoteLockscreenValidationResult; import android.app.RemoteLockscreenValidationSession; import android.content.Context; +import android.content.pm.PackageManager; import android.os.Binder; import android.os.RemoteException; import android.os.ServiceSpecificException; @@ -219,7 +220,7 @@ public class RecoverableKeyStoreManager { void initRecoveryService( @NonNull String rootCertificateAlias, @NonNull byte[] recoveryServiceCertFile) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); int userId = UserHandle.getCallingUserId(); int uid = Binder.getCallingUid(); @@ -334,7 +335,7 @@ public void initRecoveryServiceWithSigFile( @NonNull String rootCertificateAlias, @NonNull byte[] recoveryServiceCertFile, @NonNull byte[] recoveryServiceSigFile) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); rootCertificateAlias = mTestCertHelper.getDefaultCertificateAliasIfEmpty(rootCertificateAlias); Objects.requireNonNull(recoveryServiceCertFile, "recoveryServiceCertFile is null"); @@ -373,7 +374,7 @@ public void initRecoveryServiceWithSigFile( */ public @NonNull KeyChainSnapshot getKeyChainSnapshot() throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); int uid = Binder.getCallingUid(); KeyChainSnapshot snapshot = mSnapshotStorage.get(uid); if (snapshot == null) { @@ -384,7 +385,7 @@ public void initRecoveryServiceWithSigFile( public void setSnapshotCreatedPendingIntent(@Nullable PendingIntent intent) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); int uid = Binder.getCallingUid(); mListenersStorage.setSnapshotListener(uid, intent); } @@ -394,7 +395,7 @@ public void setSnapshotCreatedPendingIntent(@Nullable PendingIntent intent) * chain. Along with the counter ID, it is used to uniquely identify an instance of a vault. */ public void setServerParams(@NonNull byte[] serverParams) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); int userId = UserHandle.getCallingUserId(); int uid = Binder.getCallingUid(); @@ -428,7 +429,7 @@ public void setServerParams(@NonNull byte[] serverParams) throws RemoteException * Sets the recovery status of key with {@code alias} to {@code status}. */ public void setRecoveryStatus(@NonNull String alias, int status) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); Objects.requireNonNull(alias, "alias is null"); long updatedRows = mDatabase.setRecoveryStatus(Binder.getCallingUid(), alias, status); if (updatedRows < 0) { @@ -447,7 +448,7 @@ public void setRecoveryStatus(@NonNull String alias, int status) throws RemoteEx * {@link RecoveryController#RECOVERY_STATUS_PERMANENT_FAILURE}. */ public @NonNull Map getRecoveryStatus() throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); return mDatabase.getStatusForAllKeys(Binder.getCallingUid()); } @@ -459,7 +460,7 @@ public void setRecoveryStatus(@NonNull String alias, int status) throws RemoteEx public void setRecoverySecretTypes( @NonNull @KeyChainProtectionParams.UserSecretType int[] secretTypes) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); Objects.requireNonNull(secretTypes, "secretTypes is null"); int userId = UserHandle.getCallingUserId(); int uid = Binder.getCallingUid(); @@ -497,7 +498,7 @@ public void setRecoverySecretTypes( * @hide */ public @NonNull int[] getRecoverySecretTypes() throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); return mDatabase.getRecoverySecretTypes(UserHandle.getCallingUserId(), Binder.getCallingUid()); } @@ -524,7 +525,7 @@ public void setRecoverySecretTypes( @NonNull byte[] vaultChallenge, @NonNull List secrets) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); int uid = Binder.getCallingUid(); if (secrets.size() != 1) { @@ -589,7 +590,7 @@ public void setRecoverySecretTypes( @NonNull byte[] vaultChallenge, @NonNull List secrets) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); rootCertificateAlias = mTestCertHelper.getDefaultCertificateAliasIfEmpty(rootCertificateAlias); Objects.requireNonNull(sessionId, "invalid session"); @@ -640,7 +641,7 @@ public void setRecoverySecretTypes( @NonNull String sessionId, @NonNull byte[] encryptedRecoveryKey, @NonNull List applicationKeys) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); int userId = UserHandle.getCallingUserId(); int uid = Binder.getCallingUid(); RecoverySessionStorage.Entry sessionEntry = mRecoverySessionStorage.get(uid, sessionId); @@ -702,13 +703,13 @@ public void setRecoverySecretTypes( * Destroys the session with the given {@code sessionId}. */ public void closeSession(@NonNull String sessionId) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); Objects.requireNonNull(sessionId, "invalid session"); mRecoverySessionStorage.remove(Binder.getCallingUid(), sessionId); } public void removeKey(@NonNull String alias) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); Objects.requireNonNull(alias, "alias is null"); int uid = Binder.getCallingUid(); int userId = UserHandle.getCallingUserId(); @@ -747,7 +748,7 @@ public String generateKey(@NonNull String alias) throws RemoteException { */ public String generateKeyWithMetadata(@NonNull String alias, @Nullable byte[] metadata) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); Objects.requireNonNull(alias, "alias is null"); int uid = Binder.getCallingUid(); int userId = UserHandle.getCallingUserId(); @@ -852,7 +853,7 @@ public String generateKeyWithMetadata(@NonNull String alias, @Nullable byte[] me * @return grant alias, which caller can use to access the key. */ public @Nullable String getKey(@NonNull String alias) throws RemoteException { - checkRecoverKeyStorePermission(); + checkRecoverKeyStoreGmsCorePermission(); Objects.requireNonNull(alias, "alias is null"); int uid = Binder.getCallingUid(); int userId = UserHandle.getCallingUserId(); @@ -1154,9 +1155,25 @@ private int lockPatternUtilsToKeyguardType(int credentialsType) { } private void checkRecoverKeyStorePermission() { - mContext.enforceCallingOrSelfPermission( - Manifest.permission.RECOVER_KEYSTORE, - "Caller " + Binder.getCallingUid() + " doesn't have RecoverKeyStore permission."); + checkRecoverKeyStorePermission(/*allowGmsCorePermission=*/ false); + } + + private void checkRecoverKeyStoreGmsCorePermission() { + checkRecoverKeyStorePermission(/*allowGmsCorePermission=*/ true); + } + + private void checkRecoverKeyStorePermission(boolean allowGmsCorePermission) { + boolean permissionGranted = mContext.checkCallingOrSelfPermission( + Manifest.permission.RECOVER_KEYSTORE) == PackageManager.PERMISSION_GRANTED; + if (!permissionGranted && allowGmsCorePermission) { + permissionGranted = mContext.checkCallingOrSelfPermission( + Manifest.permission.RECOVER_KEYSTORE_GMSCORE) + == PackageManager.PERMISSION_GRANTED; + } + if (!permissionGranted) { + throw new SecurityException("Caller " + Binder.getCallingUid() + + " doesn't have RecoverKeyStore permission."); + } int userId = UserHandle.getCallingUserId(); int uid = Binder.getCallingUid(); mCleanupManager.registerRecoveryAgent(userId, uid); diff --git a/services/core/java/com/android/server/pm/ext/GmsCoreHooks.java b/services/core/java/com/android/server/pm/ext/GmsCoreHooks.java index 5abcf28bc28a5..4aa0d5fc4835b 100644 --- a/services/core/java/com/android/server/pm/ext/GmsCoreHooks.java +++ b/services/core/java/com/android/server/pm/ext/GmsCoreHooks.java @@ -41,6 +41,9 @@ public int overridePermissionState(String permission, int userId) { case Manifest.permission.USE_ICC_AUTH_WITH_DEVICE_IDENTIFIER: flag = GmsCorePackageFlag.GRANT_PERMS_FOR_ICC_AUTHENTICATION; break; + case Manifest.permission.RECOVER_KEYSTORE_GMSCORE: + flag = GmsCorePackageFlag.GRANT_PERMS_FOR_RECOVER_KEYSTORE_GMSCORE; + break; default: return NO_PERMISSION_OVERRIDE; } @@ -99,7 +102,8 @@ public List addUsesPermissions() { var l = createUsesPerms( Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Manifest.permission.READ_PHONE_NUMBERS, - Manifest.permission.USE_ICC_AUTH_WITH_DEVICE_IDENTIFIER + Manifest.permission.USE_ICC_AUTH_WITH_DEVICE_IDENTIFIER, + Manifest.permission.RECOVER_KEYSTORE_GMSCORE ); res.addAll(l); return res; From 6ca24a71c117c76c6a4ccc6e1ae06b2a618a1f91 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Tue, 28 Jul 2026 18:36:44 -0700 Subject: [PATCH 3/8] gmscompat: clear GmsCore recoverable keystore state with app data RecoverableKeyStore identifies recovery agent state by Android user ID and recovery agent UID. It removes that state when an Android user is removed, but has no equivalent cleanup for app data removal. On the stock OS, Package Manager restores GmsCore's saved app ID when updates are uninstalled, so it normally keeps the same UID. On GrapheneOS, sandboxed GmsCore can be fully uninstalled; a later installation is a fresh install and may receive a new UID. Also, clearing its storage keeps the UID but discards the app state associated with its recoverable keystore entries. After successful app data removal for GmsCore installed as a user app, remove its database rows, snapshot, listener and pending snapshot state, recovery sessions and application keys. Preserve system apps and DELETE_KEEP_DATA flows such as package replacement and archiving. Delete agent-owned rows by user ID and UID using each table's schema contract and one transaction. Enumerate the internal alias prefix so restored application keys without database rows are included, and serialize cleanup with pending key sync work. Keep the per-user platform keys because they are shared by all recovery agents for the user. --- .../locksettings/LockSettingsInternal.java | 9 ++++ .../locksettings/LockSettingsService.java | 7 ++++ .../recoverablekeystore/KeyStoreProxy.java | 4 ++ .../KeyStoreProxyImpl.java | 6 +++ .../RecoverableKeyStoreManager.java | 22 ++++++++++ .../RecoverySnapshotListenersStorage.java | 8 ++++ .../storage/ApplicationKeyStorage.java | 42 ++++++++++++++++++- .../storage/CleanupManager.java | 19 +++++++++ .../storage/RecoverableKeyStoreDb.java | 32 ++++++++++++++ .../RecoverableKeyStoreDbContract.java | 4 +- .../server/pm/GosPackageStatePmHooks.java | 37 +++++++++++++++- .../server/pm/PackageManagerService.java | 4 ++ .../server/pm/RemovePackageHelper.java | 11 ++++- 13 files changed, 200 insertions(+), 5 deletions(-) diff --git a/services/core/java/com/android/server/locksettings/LockSettingsInternal.java b/services/core/java/com/android/server/locksettings/LockSettingsInternal.java index 67224496e48e4..a3de4df6af75e 100644 --- a/services/core/java/com/android/server/locksettings/LockSettingsInternal.java +++ b/services/core/java/com/android/server/locksettings/LockSettingsInternal.java @@ -80,6 +80,15 @@ public abstract class LockSettingsInternal { */ public abstract void removeUser(@UserIdInt int userId); + /** + * Removes recoverable keystore state for the given recovery agent. + * + * @param userId the ID of the user whose state is being removed + * @param uid the UID of the recovery agent + */ + public abstract void removeRecoverableKeystoreStateForRecoveryAgent( + @UserIdInt int userId, int uid); + /** * Create an escrow token for the current user, which can later be used to unlock FBE * or change user password. diff --git a/services/core/java/com/android/server/locksettings/LockSettingsService.java b/services/core/java/com/android/server/locksettings/LockSettingsService.java index adfeeab98043b..a0cbe2f53916a 100644 --- a/services/core/java/com/android/server/locksettings/LockSettingsService.java +++ b/services/core/java/com/android/server/locksettings/LockSettingsService.java @@ -4358,6 +4358,13 @@ public void removeUser(@UserIdInt int userId) { LockSettingsService.this.removeUser(userId); } + @Override + public void removeRecoverableKeystoreStateForRecoveryAgent( + @UserIdInt int userId, int uid) { + mRecoverableKeyStoreManager.removeRecoverableKeystoreStateForRecoveryAgent( + userId, uid); + } + @Override public long addEscrowToken(byte[] token, int userId, EscrowTokenStateChangeCallback callback) { diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/KeyStoreProxy.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/KeyStoreProxy.java index 81031770ff680..dbdedf300c6bc 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/KeyStoreProxy.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/KeyStoreProxy.java @@ -21,6 +21,7 @@ import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.UnrecoverableKeyException; +import java.util.Enumeration; /** * Proxies {@link java.security.KeyStore}. As all of its methods are final, it cannot otherwise be @@ -43,4 +44,7 @@ void setEntry(String alias, KeyStore.Entry entry, KeyStore.ProtectionParameter p /** @see KeyStore#deleteEntry(String) */ void deleteEntry(String alias) throws KeyStoreException; + + /** @see KeyStore#aliases() */ + Enumeration aliases() throws KeyStoreException; } diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/KeyStoreProxyImpl.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/KeyStoreProxyImpl.java index f5941361bd897..603a439f88f9f 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/KeyStoreProxyImpl.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/KeyStoreProxyImpl.java @@ -23,6 +23,7 @@ import java.security.NoSuchAlgorithmException; import java.security.UnrecoverableKeyException; import java.security.cert.CertificateException; +import java.util.Enumeration; /** * Implementation of {@link KeyStoreProxy} that delegates all method calls to the {@link KeyStore}. @@ -62,6 +63,11 @@ public void deleteEntry(String alias) throws KeyStoreException { mKeyStore.deleteEntry(alias); } + @Override + public Enumeration aliases() throws KeyStoreException { + return mKeyStore.aliases(); + } + /** * Returns AndroidKeyStore-provided {@link KeyStore}, having already invoked * {@link KeyStore#load(KeyStore.LoadStoreParameter)}. diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManager.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManager.java index bda0d309d5c50..088717bd4c5ac 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManager.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManager.java @@ -90,6 +90,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -999,6 +1000,27 @@ public void lockScreenSecretChanged(@NonNull LockscreenCredential credential, in } } + /** + * Removes recoverable keystore state for a recovery agent. + * + *

Run the cleanup on the key sync executor so it cannot race a previously scheduled key + * sync task. + */ + public void removeRecoverableKeystoreStateForRecoveryAgent(int userId, int uid) { + try { + mExecutorService.submit(() -> { + mRecoverySessionStorage.remove(uid); + mListenersStorage.remove(uid); + mCleanupManager.removeDataForRecoveryAgent(userId, uid); + }).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Log.e(TAG, "Interrupted while removing recoverable keystore state for uid " + uid, e); + } catch (ExecutionException e) { + Log.e(TAG, "Failed to remove recoverable keystore state for uid " + uid, e.getCause()); + } + } + /** * Starts a session to verify lock screen credentials provided by a remote device. */ diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverySnapshotListenersStorage.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverySnapshotListenersStorage.java index bd9f0fdbe63dd..d5bd5f00fb250 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverySnapshotListenersStorage.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/RecoverySnapshotListenersStorage.java @@ -64,6 +64,14 @@ public synchronized boolean hasListener(int recoveryAgentUid) { return mAgentIntents.get(recoveryAgentUid) != null; } + /** + * Removes the listener and pending snapshot state for the recovery agent. + */ + public synchronized void remove(int recoveryAgentUid) { + mAgentIntents.remove(recoveryAgentUid); + mAgentsWithPendingSnapshots.remove(recoveryAgentUid); + } + /** * Notifies recovery agent that new snapshot is available. If a recovery agent has not yet * registered a {@link PendingIntent}, remembers that a snapshot is pending for it, so that diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/ApplicationKeyStorage.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/ApplicationKeyStorage.java index ef56a1e268543..2dcfa35dfd37e 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/ApplicationKeyStorage.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/ApplicationKeyStorage.java @@ -35,6 +35,9 @@ import java.security.KeyStore.SecretKeyEntry; import java.security.KeyStoreException; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; import java.util.Locale; import javax.crypto.spec.SecretKeySpec; @@ -105,6 +108,39 @@ public void deleteEntry(int userId, int uid, String alias) { } } + /** + * Deletes all application keys belonging to the given recovery agent. + */ + public void deleteEntriesForRecoveryAgent(int userId, int uid) { + String aliasPrefix = getInternalAliasPrefix(userId, uid); + List aliasesToDelete = new ArrayList<>(); + try { + Enumeration aliases = mKeyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (alias.startsWith(aliasPrefix)) { + aliasesToDelete.add(alias); + } + } + } catch (KeyStoreException e) { + throw new ServiceSpecificException(ERROR_SERVICE_INTERNAL_ERROR, e.getMessage()); + } + + KeyStoreException deletionException = null; + for (String alias : aliasesToDelete) { + try { + mKeyStore.deleteEntry(alias); + } catch (KeyStoreException e) { + Log.e(TAG, "Failed to delete recoverable application key " + alias, e); + deletionException = e; + } + } + if (deletionException != null) { + throw new ServiceSpecificException( + ERROR_SERVICE_INTERNAL_ERROR, deletionException.getMessage()); + } + } + /** * Returns the alias in locksettins service's KeyStore namespace used for given application key. * @@ -117,7 +153,11 @@ public void deleteEntry(int userId, int uid, String alias) { * @return The alias. */ private String getInternalAlias(int userId, int uid, String alias) { - return APPLICATION_KEY_ALIAS_PREFIX + userId + "/" + uid + "/" + alias; + return getInternalAliasPrefix(userId, uid) + alias; + } + + private String getInternalAliasPrefix(int userId, int uid) { + return APPLICATION_KEY_ALIAS_PREFIX + userId + "/" + uid + "/"; } private String makeKeystoreEngineGrantString(int uid, String alias) { diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/CleanupManager.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/CleanupManager.java index 7d5fd652ef38f..f1b789eccce8b 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/CleanupManager.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/CleanupManager.java @@ -154,6 +154,16 @@ private void removeDataForUser(int userId) { mDatabase.removeUserFromAllTables(userId); } + /** + * Removes all data belonging to the given recovery agent. + */ + public synchronized void removeDataForRecoveryAgent(int userId, int uid) { + Log.d(TAG, "Removing data for recovery agent " + uid + " for user " + userId + "."); + mSnapshotStorage.remove(uid); + removeAllKeysForRecoveryAgentByPrefix(userId, uid); + mDatabase.removeRecoveryAgentFromAllTables(userId, uid); + } + /** * Removes keys from Android KeyStore for the recovery agent; * Doesn't remove encrypted key material from the database. @@ -171,4 +181,13 @@ private void removeAllKeysForRecoveryAgent(int userId, int uid) { } } } + + private void removeAllKeysForRecoveryAgentByPrefix(int userId, int uid) { + try { + mApplicationKeyStorage.deleteEntriesForRecoveryAgent(userId, uid); + } catch (ServiceSpecificException e) { + // Ignore errors during key removal. + Log.e(TAG, "Error while removing recoverable keys for uid " + uid, e); + } + } } diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDb.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDb.java index f2c55544e859c..32828c54ecbab 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDb.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDb.java @@ -1346,6 +1346,38 @@ public void removeUserFromAllTables(int userId) { removeUserFromRootOfTrustTable(userId); } + /** + * Removes all entries for the given recovery agent without removing user-wide metadata. + */ + public void removeRecoveryAgentFromAllTables(int userId, int uid) { + SQLiteDatabase db = mKeyStoreDbHelper.getWritableDatabase(); + String[] selectionArgs = { + Integer.toString(userId), + Integer.toString(uid) + }; + + db.beginTransaction(); + try { + // Use each table's own contract so ownership-column changes are caught during rebases. + deleteRecoveryAgentRows(db, KeysEntry.TABLE_NAME, KeysEntry.COLUMN_NAME_USER_ID, + KeysEntry.COLUMN_NAME_UID, selectionArgs); + deleteRecoveryAgentRows(db, RecoveryServiceMetadataEntry.TABLE_NAME, + RecoveryServiceMetadataEntry.COLUMN_NAME_USER_ID, + RecoveryServiceMetadataEntry.COLUMN_NAME_UID, selectionArgs); + deleteRecoveryAgentRows(db, RootOfTrustEntry.TABLE_NAME, + RootOfTrustEntry.COLUMN_NAME_USER_ID, RootOfTrustEntry.COLUMN_NAME_UID, + selectionArgs); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private static void deleteRecoveryAgentRows(SQLiteDatabase db, String tableName, + String userIdColumn, String uidColumn, String[] selectionArgs) { + db.delete(tableName, userIdColumn + " = ? AND " + uidColumn + " = ?", selectionArgs); + } + /** * Removes all entries for given userId from Keys table. * diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDbContract.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDbContract.java index 599ba89478a25..60e363543bca0 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDbContract.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDbContract.java @@ -21,7 +21,9 @@ /** * Contract for recoverable key database. Describes the tables present. * - * Make sure that {@code removeUserFromAllKnownTables} is updated, when new table is added. + * Make sure that {@link RecoverableKeyStoreDb#removeUserFromAllTables(int)} is updated when a table + * is added. If the table contains recovery-agent state, also update + * {@link RecoverableKeyStoreDb#removeRecoveryAgentFromAllTables(int, int)}. */ class RecoverableKeyStoreDbContract { /** diff --git a/services/core/java/com/android/server/pm/GosPackageStatePmHooks.java b/services/core/java/com/android/server/pm/GosPackageStatePmHooks.java index 4a8c2b52a80c2..013e02c963363 100644 --- a/services/core/java/com/android/server/pm/GosPackageStatePmHooks.java +++ b/services/core/java/com/android/server/pm/GosPackageStatePmHooks.java @@ -7,8 +7,9 @@ import android.app.ActivityManagerInternal; import android.content.pm.GosPackageState; import android.content.pm.GosPackageStateFlag; -import android.ext.KnownSystemPackages; import android.ext.DerivedPackageFlag; +import android.ext.KnownSystemPackages; +import android.ext.PackageId; import android.os.Binder; import android.os.Process; import android.os.RemoteException; @@ -19,7 +20,9 @@ import com.android.internal.pm.parsing.pkg.AndroidPackageInternal; import com.android.internal.pm.pkg.component.ParsedUsesPermission; import com.android.server.LocalServices; +import com.android.server.locksettings.LockSettingsInternal; import com.android.server.pm.PackageManagerLocal.GosPackageStateChangeCallback; +import com.android.server.pm.ext.PackageExt; import com.android.server.pm.pkg.AndroidPackage; import com.android.server.pm.pkg.PackageStateInternal; import com.android.server.pm.pkg.PackageUserStateInternal; @@ -352,6 +355,38 @@ public static void onClearApplicationUserData(PackageManagerService pm, String p } } + static void onApplicationDataCleared( + PackageManagerService pm, String packageName, int userId) { + PackageStateInternal packageState = + pm.snapshotComputer().getPackageStateInternal(packageName); + if (shouldClearGmsCoreRecoverableKeystoreState(packageState)) { + clearGmsCoreRecoverableKeystoreState( + pm, userId, packageState.getAppId()); + } + } + + static boolean shouldClearGmsCoreRecoverableKeystoreState( + @Nullable PackageStateInternal packageState) { + return packageState != null + && !packageState.isSystem() + && PackageExt.get(packageState).getPackageId() == PackageId.GMS_CORE; + } + + static void clearGmsCoreRecoverableKeystoreState( + PackageManagerService pm, int userId, int appId) { + LockSettingsInternal lockSettings = + LocalServices.getService(LockSettingsInternal.class); + if (lockSettings == null) { + Slog.w(TAG, "LockSettingsInternal is unavailable"); + return; + } + + for (int realUserId : pm.resolveUserIds(userId)) { + lockSettings.removeRecoverableKeystoreStateForRecoveryAgent( + realUserId, UserHandle.getUid(realUserId, appId)); + } + } + private static void clearContactScopesStorage(PackageManagerService pm, int userId) { for (PackageStateInternal ps : pm.snapshotComputer().getPackageStates().values()) { PackageUserStateInternal us = ps.getUserStateOrDefault(userId); diff --git a/services/core/java/com/android/server/pm/PackageManagerService.java b/services/core/java/com/android/server/pm/PackageManagerService.java index 201d99aa9e9af..955d9d1aad790 100644 --- a/services/core/java/com/android/server/pm/PackageManagerService.java +++ b/services/core/java/com/android/server/pm/PackageManagerService.java @@ -5176,6 +5176,10 @@ public void run() { callingUid); } } + if (succeeded) { + GosPackageStatePmHooks.onApplicationDataCleared( + PackageManagerService.this, packageName, userId); + } } if (succeeded) { // invoke DeviceStorageMonitor's update method to clear any notifications diff --git a/services/core/java/com/android/server/pm/RemovePackageHelper.java b/services/core/java/com/android/server/pm/RemovePackageHelper.java index 7099c2e0bd468..3fb5e14a68077 100644 --- a/services/core/java/com/android/server/pm/RemovePackageHelper.java +++ b/services/core/java/com/android/server/pm/RemovePackageHelper.java @@ -353,14 +353,21 @@ public void clearPackageStateForUserLIF(PackageSetting ps, @CanBeALL @UserIdInt if (ps.getAppId() == SYSTEM_UID) { return; } + final boolean clearGmsCoreRecoverableKeystoreState = + GosPackageStatePmHooks.shouldClearGmsCoreRecoverableKeystoreState(ps); + final int appId = ps.getAppId(); mPm.mInjector.getBackgroundHandler().post(() -> { try { Trace.traceBegin(TRACE_TAG_PACKAGE_MANAGER, - "clearKeystoreData:" + ps.getAppId() + " for user: " + userId); - mAppDataHelper.clearKeystoreData(userId, ps.getAppId()); + "clearKeystoreData:" + appId + " for user: " + userId); + mAppDataHelper.clearKeystoreData(userId, appId); } finally { Trace.traceEnd(TRACE_TAG_PACKAGE_MANAGER); } + if (clearGmsCoreRecoverableKeystoreState) { + GosPackageStatePmHooks.clearGmsCoreRecoverableKeystoreState( + mPm, userId, appId); + } }); } From 9029d241b3598ade98d9051f7753009b53233328 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Tue, 28 Jul 2026 19:01:57 -0700 Subject: [PATCH 4/8] add recoverable keystore cleanup tests Verify LockSettings serializes cleanup with key sync work and removes sessions, listeners, snapshots, internal Android Keystore aliases and only the matching recovery agent database rows. Keep regression coverage for the existing removed Android user cleanup path, including its database-backed key enumeration and user-wide database removal. Test: atest FrameworksServicesTests:ApplicationKeyStorageTest Test: atest FrameworksServicesTests:RecoverableKeyStoreManagerTest Test: atest FrameworksServicesTests:RecoverySnapshotListenersStorageTest Test: atest FrameworksServicesTests:CleanupManagerTest Test: atest FrameworksServicesTests:RecoverableKeyStoreDbTest --- .../RecoverableKeyStoreManagerTest.java | 43 ++++++++- .../RecoverySnapshotListenersStorageTest.java | 52 ++++++++++ .../storage/ApplicationKeyStorageTest.java | 96 +++++++++++++++++++ .../storage/CleanupManagerTest.java | 34 ++++++- .../storage/RecoverableKeyStoreDbTest.java | 46 +++++++++ 5 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/ApplicationKeyStorageTest.java diff --git a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManagerTest.java b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManagerTest.java index 0965916588920..bed579fd65c86 100644 --- a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManagerTest.java +++ b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverableKeyStoreManagerTest.java @@ -95,6 +95,7 @@ import java.util.Arrays; import java.util.Map; import java.util.Random; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import javax.crypto.KeyGenerator; @@ -172,6 +173,7 @@ public class RecoverableKeyStoreManagerTest { @Mock private ApplicationKeyStorage mApplicationKeyStorage; @Mock private CleanupManager mCleanupManager; @Mock private ScheduledExecutorService mExecutorService; + @Mock private Future mFuture; @Mock private LockSettingsService mLockSettingsService; @Spy private TestOnlyInsecureCertificateHelper mTestOnlyInsecureCertificateHelper; @@ -764,9 +766,8 @@ public void startRecoverySession_checksPermissionFirst() throws Exception { TEST_VAULT_CHALLENGE, ImmutableList.of(TEST_PROTECTION_PARAMS)); - verify(mMockContext, times(1)) - .enforceCallingOrSelfPermission( - eq(Manifest.permission.RECOVER_KEYSTORE), any()); + verify(mMockContext, times(1)).checkCallingOrSelfPermission( + eq(Manifest.permission.RECOVER_KEYSTORE)); } @Ignore("Causing breakages so ignoring to resolve, b/281583079") @@ -1133,6 +1134,42 @@ public void setSnapshotCreatedPendingIntent() throws Exception { verify(mMockListenersStorage).setSnapshotListener(eq(uid), any(PendingIntent.class)); } + @Test + public void removeRecoverableKeystoreStateForRecoveryAgent_removesOnlyTargetAgentState() + throws Exception { + int uid = 1234; + int otherUid = 5678; + mRecoverySessionStorage.add( + uid, + new RecoverySessionStorage.Entry( + TEST_SESSION_ID, + TEST_SECRET.clone(), + TEST_SALT.clone(), + TEST_VAULT_PARAMS.clone())); + mRecoverySessionStorage.add( + otherUid, + new RecoverySessionStorage.Entry( + TEST_SESSION_ID, + TEST_SECRET.clone(), + TEST_SALT.clone(), + TEST_VAULT_PARAMS.clone())); + when(mExecutorService.submit(any(Runnable.class))) + .thenAnswer( + invocation -> { + invocation.getArgument(0).run(); + return mFuture; + }); + + mRecoverableKeyStoreManager.removeRecoverableKeystoreStateForRecoveryAgent( + TEST_USER_ID, uid); + + verify(mFuture).get(); + assertThat(mRecoverySessionStorage.get(uid, TEST_SESSION_ID)).isNull(); + assertThat(mRecoverySessionStorage.get(otherUid, TEST_SESSION_ID)).isNotNull(); + verify(mMockListenersStorage).remove(uid); + verify(mCleanupManager).removeDataForRecoveryAgent(TEST_USER_ID, uid); + } + @Test public void setServerParams_updatesServerParams() throws Exception { int uid = Binder.getCallingUid(); diff --git a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverySnapshotListenersStorageTest.java b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverySnapshotListenersStorageTest.java index 418d474523306..074a311d9a97d 100644 --- a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverySnapshotListenersStorageTest.java +++ b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/RecoverySnapshotListenersStorageTest.java @@ -49,6 +49,58 @@ public void hasListener_isTrueForRegisteredUid() { assertTrue(mStorage.hasListener(recoveryAgentUid)); } + @Test + public void remove_clearsListener() { + int recoveryAgentUid = 1000; + PendingIntent intent = + PendingIntent.getBroadcast( + InstrumentationRegistry.getTargetContext(), + /* requestCode= */ 1, + new Intent() + .setPackage( + InstrumentationRegistry.getTargetContext() + .getPackageName()), + /* flags= */ PendingIntent.FLAG_MUTABLE); + mStorage.setSnapshotListener(recoveryAgentUid, intent); + + mStorage.remove(recoveryAgentUid); + + assertFalse(mStorage.hasListener(recoveryAgentUid)); + } + + @Test + public void remove_clearsPendingSnapshot() throws Exception { + Context context = InstrumentationRegistry.getTargetContext(); + int recoveryAgentUid = 1000; + mStorage.recoverySnapshotAvailable(recoveryAgentUid); + mStorage.remove(recoveryAgentUid); + PendingIntent intent = + PendingIntent.getBroadcast( + context, + /* requestCode= */ 0, + new Intent(TEST_INTENT_ACTION).setPackage(context.getPackageName()), + /* flags= */ PendingIntent.FLAG_MUTABLE); + CountDownLatch latch = new CountDownLatch(1); + BroadcastReceiver broadcastReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + latch.countDown(); + } + }; + context.registerReceiver( + broadcastReceiver, + new IntentFilter(TEST_INTENT_ACTION), + Context.RECEIVER_EXPORTED_UNAUDITED); + + try { + mStorage.setSnapshotListener(recoveryAgentUid, intent); + assertFalse(latch.await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + } finally { + context.unregisterReceiver(broadcastReceiver); + } + } + @Test public void setSnapshotListener_invokesIntentImmediatelyIfPreviouslyNotified() throws Exception { diff --git a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/ApplicationKeyStorageTest.java b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/ApplicationKeyStorageTest.java new file mode 100644 index 0000000000000..6ff7d13d0c9e0 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/ApplicationKeyStorageTest.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2026 GrapheneOS + * + * 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.server.locksettings.recoverablekeystore.storage; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertThrows; + +import android.os.ServiceSpecificException; + +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import com.android.server.locksettings.recoverablekeystore.KeyStoreProxy; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.security.KeyStoreException; +import java.util.Arrays; +import java.util.Collections; + +@SmallTest +@RunWith(AndroidJUnit4.class) +public class ApplicationKeyStorageTest { + private static final int USER_ID = 10; + private static final int UID = 1012345; + private static final String ALIAS_PREFIX = + "com.android.server.locksettings.recoverablekeystore/application/10/1012345/"; + private static final String MATCHING_ALIAS_1 = ALIAS_PREFIX + "first"; + private static final String MATCHING_ALIAS_2 = ALIAS_PREFIX + "second"; + + @Mock private KeyStoreProxy mKeyStore; + + private ApplicationKeyStorage mStorage; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mStorage = new ApplicationKeyStorage(mKeyStore); + } + + @Test + public void deleteEntriesForRecoveryAgent_deletesOnlyMatchingAliases() throws Exception { + when(mKeyStore.aliases()) + .thenReturn( + Collections.enumeration( + Arrays.asList( + MATCHING_ALIAS_1, + "com.android.server.locksettings.recoverablekeystore/application/11/1012345/first", + "com.android.server.locksettings.recoverablekeystore/application/10/1012346/first", + "com.android.server.locksettings.recoverablekeystore/application/10/10123450/first", + MATCHING_ALIAS_2))); + + mStorage.deleteEntriesForRecoveryAgent(USER_ID, UID); + + verify(mKeyStore).aliases(); + verify(mKeyStore).deleteEntry(MATCHING_ALIAS_1); + verify(mKeyStore).deleteEntry(MATCHING_ALIAS_2); + verifyNoMoreInteractions(mKeyStore); + } + + @Test + public void deleteEntriesForRecoveryAgent_continuesAfterDeletionFailure() throws Exception { + when(mKeyStore.aliases()) + .thenReturn( + Collections.enumeration(Arrays.asList(MATCHING_ALIAS_1, MATCHING_ALIAS_2))); + doThrow(new KeyStoreException("failure")).when(mKeyStore).deleteEntry(MATCHING_ALIAS_1); + + assertThrows( + ServiceSpecificException.class, + () -> mStorage.deleteEntriesForRecoveryAgent(USER_ID, UID)); + + verify(mKeyStore).deleteEntry(MATCHING_ALIAS_1); + verify(mKeyStore).deleteEntry(MATCHING_ALIAS_2); + } +} diff --git a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/CleanupManagerTest.java b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/CleanupManagerTest.java index 1c9c6dc8d0db3..8065a2f6f26d2 100644 --- a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/CleanupManagerTest.java +++ b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/CleanupManagerTest.java @@ -16,7 +16,9 @@ package com.android.server.locksettings.recoverablekeystore.storage; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -27,6 +29,8 @@ import androidx.test.filters.SmallTest; import androidx.test.runner.AndroidJUnit4; +import com.android.server.locksettings.recoverablekeystore.WrappedKey; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -108,5 +112,33 @@ public void verifyKnownUsers_newSerialNumber_deletesData() throws Exception { verify(mDatabase).setUserSerialNumber(USER_ID, USER_SERIAL_NUMBER_2); verify(mRecoverySnapshotStorage).remove(UID); } -} + @Test + public void verifyKnownUsers_removedUser_removesAllUserState() throws Exception { + Map knownSerialNumbers = new HashMap<>(); + knownSerialNumbers.put(USER_ID, USER_SERIAL_NUMBER); + when(mDatabase.getUserSerialNumbers()).thenReturn(knownSerialNumbers); + when(mDatabase.getRecoveryAgents(USER_ID)).thenReturn(List.of(UID)); + when(mDatabase.getPlatformKeyGenerationId(USER_ID)).thenReturn(1); + Map keys = new HashMap<>(); + keys.put("alias", new WrappedKey(new byte[] {1}, new byte[] {2}, null, 1)); + when(mDatabase.getAllKeys(USER_ID, UID, 1)).thenReturn(keys); + when(mUserManager.getSerialNumberForUser(eq(UserHandle.of(USER_ID)))).thenReturn(-1L); + + mManager.verifyKnownUsers(); + + verify(mRecoverySnapshotStorage).remove(UID); + verify(mApplicationKeyStorage).deleteEntry(USER_ID, UID, "alias"); + verify(mDatabase).removeUserFromAllTables(USER_ID); + } + + @Test + public void removeDataForRecoveryAgent_removesOnlyAgentState() { + mManager.removeDataForRecoveryAgent(USER_ID, UID); + + verify(mRecoverySnapshotStorage).remove(UID); + verify(mApplicationKeyStorage).deleteEntriesForRecoveryAgent(USER_ID, UID); + verify(mDatabase).removeRecoveryAgentFromAllTables(USER_ID, UID); + verify(mDatabase, never()).removeUserFromAllTables(anyInt()); + } +} diff --git a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDbTest.java b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDbTest.java index 036742559ef69..b96136e6f0c6d 100644 --- a/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDbTest.java +++ b/services/tests/servicestests/src/com/android/server/locksettings/recoverablekeystore/storage/RecoverableKeyStoreDbTest.java @@ -448,6 +448,52 @@ public void removeUserFromAllTables_removesData() throws Exception { assertThat(mRecoverableKeyStoreDb.getRecoverySecretTypes(userId, uid)).isEmpty(); } + @Test + public void removeRecoveryAgentFromAllTables_removesOnlyAgentData() throws Exception { + int userId = 12; + int uid = 120001; + int otherUid = 120002; + int generationId = 24; + long userSerialNumber = 42L; + String alias = "target"; + String otherAlias = "other"; + WrappedKey wrappedKey = + new WrappedKey( + getUtf8Bytes("nonce"), + getUtf8Bytes("key"), + /* metadata= */ null, + generationId); + mRecoverableKeyStoreDb.setPlatformKeyGenerationId(userId, generationId); + mRecoverableKeyStoreDb.setUserSerialNumber(userId, userSerialNumber); + mRecoverableKeyStoreDb.insertKey(userId, uid, alias, wrappedKey); + mRecoverableKeyStoreDb.insertKey(userId, otherUid, otherAlias, wrappedKey); + mRecoverableKeyStoreDb.setRecoverySecretTypes(userId, uid, new int[] {1}); + mRecoverableKeyStoreDb.setRecoverySecretTypes(userId, otherUid, new int[] {2}); + mRecoverableKeyStoreDb.setRecoveryServiceCertSerial(userId, uid, TEST_ROOT_CERT_ALIAS, 1L); + mRecoverableKeyStoreDb.setRecoveryServiceCertSerial( + userId, otherUid, TEST_ROOT_CERT_ALIAS, 2L); + + mRecoverableKeyStoreDb.removeRecoveryAgentFromAllTables(userId, uid); + + assertThat(mRecoverableKeyStoreDb.getKey(uid, alias)).isNull(); + assertThat(mRecoverableKeyStoreDb.getKey(otherUid, otherAlias)).isNotNull(); + assertThat(mRecoverableKeyStoreDb.getRecoverySecretTypes(userId, uid)).isEmpty(); + assertThat(mRecoverableKeyStoreDb.getRecoverySecretTypes(userId, otherUid)) + .isEqualTo(new int[] {2}); + assertThat( + mRecoverableKeyStoreDb.getRecoveryServiceCertSerial( + userId, uid, TEST_ROOT_CERT_ALIAS)) + .isNull(); + assertThat( + mRecoverableKeyStoreDb.getRecoveryServiceCertSerial( + userId, otherUid, TEST_ROOT_CERT_ALIAS)) + .isEqualTo(2L); + assertThat(mRecoverableKeyStoreDb.getPlatformKeyGenerationId(userId)) + .isEqualTo(generationId); + assertThat(mRecoverableKeyStoreDb.getUserSerialNumbers().get(userId)) + .isEqualTo(userSerialNumber); + } + @Test public void setRecoveryStatus_withSingleKey() { int userId = 12; From 4faea35067bb4b2766844d8ed136d4454440bd2e Mon Sep 17 00:00:00 2001 From: inthewaves Date: Tue, 28 Jul 2026 23:36:00 -0700 Subject: [PATCH 5/8] gmscompat: notify about recovery keystore permission for passkeys and Find Hub When RECOVER_KEYSTORE_GMSCORE is not granted, ask GmsCompat to show a notification linking to its recovery keystore permission screen. Require a visible com.google AccountManager account before notifying during passkey use. The same FIDO activities are used during initial Google account sign-in, before Play services has an account whose account keychain could use the permission. If the account query fails, skip the optional notification. --- .../android/internal/gmscompat/GmsHooks.java | 149 ++++++++++++++++++ .../android/internal/gmscompat/IGms2Gca.aidl | 4 + 2 files changed, 153 insertions(+) diff --git a/core/java/com/android/internal/gmscompat/GmsHooks.java b/core/java/com/android/internal/gmscompat/GmsHooks.java index f0c8d248f5ec2..41a72bada8a1b 100644 --- a/core/java/com/android/internal/gmscompat/GmsHooks.java +++ b/core/java/com/android/internal/gmscompat/GmsHooks.java @@ -17,6 +17,7 @@ package com.android.internal.gmscompat; import android.Manifest; +import android.accounts.AccountManager; import android.annotation.Nullable; import android.annotation.SuppressLint; import android.app.Activity; @@ -34,11 +35,13 @@ import android.content.Intent; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; +import android.credentials.CredentialOption; import android.database.Cursor; import android.database.MatrixCursor; import android.database.sqlite.SQLiteOpenHelper; import android.ext.PackageId; import android.net.Uri; +import android.os.BadParcelableException; import android.os.Binder; import android.os.Bundle; import android.os.DeadSystemRuntimeException; @@ -51,6 +54,9 @@ import android.os.UserHandle; import android.provider.Downloads; import android.provider.Settings; +import android.service.credentials.CreateCredentialRequest; +import android.service.credentials.CredentialProviderService; +import android.service.credentials.GetCredentialRequest; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; @@ -489,6 +495,149 @@ public static void activityOnCreate(Activity activity) { Log.i(TAG, "calling setTranslucent(false) for " + activity.getClass().getName()); activity.setTranslucent(false); } + + maybeShowRecoverableKeystorePermissionNotification(activity.getIntent()); + } + + private static void maybeShowRecoverableKeystorePermissionNotification( + @Nullable Intent intent) { + if (!GmsCompat.isGmsCore() + || GmsCompat.hasPermission(Manifest.permission.RECOVER_KEYSTORE_GMSCORE)) { + return; + } + + if (isPublicKeyCredentialRequest(intent)) { + if (!hasGoogleAccount()) { + return; + } + try { + GmsCompatApp.iGms2Gca() + .maybeShowGmsCoreMissingRecoverableKeystorePermissionNotification(); + } catch (RemoteException e) { + Log.w(TAG, "failed to show passkey account keychain notification", e); + } + return; + } + + if (!isFindHubProvisioningIntent(intent)) { + return; + } + + try { + GmsCompatApp.iGms2Gca() + .maybeShowGmsCoreMissingFindHubAccountKeychainPermissionNotification(); + } catch (RemoteException e) { + Log.w(TAG, "failed to show Find Hub account keychain notification", e); + } + } + + private static final String TYPE_PUBLIC_KEY_CREDENTIAL = + "androidx.credentials.TYPE_PUBLIC_KEY_CREDENTIAL"; + private static final String FIDO_REGISTRATION_ACTIVITY = + "com.google.android.gms.auth.api.credentials.fido.registration.ui." + + "RegistrationActivity"; + private static final String FIDO_AUTHENTICATION_ACTIVITY = + "com.google.android.gms.auth.api.credentials.fido.authentication.ui." + + "AuthenticationActivity"; + private static final String ACTION_FIDO_REGISTRATION = + "com.google.android.gms.auth.api.credentials.FIDO_REGISTRATION"; + private static final String ACTION_FIDO_AUTHENTICATION = + "com.google.android.gms.auth.api.credentials.FIDO_AUTHENTICATION"; + private static final String GOOGLE_ACCOUNT_TYPE = "com.google"; + private static final String FIND_HUB_PROVISION_ACTIVITY = + "com.google.android.gms.findmydevice.spot.fastpair.halfsheet.ProvisionActivity"; + private static final String FAST_PAIR_HALF_SHEET_ACTIVITY = + "com.google.android.gms.nearby.discovery.fastpair.HalfSheetActivity"; + private static final String EXTRA_HALF_SHEET_TYPE = + "com.google.android.gms.nearby.discovery.fastpair.EXTRA_HALF_SHEET_TYPE"; + private static final String HALF_SHEET_TYPE_SPOT = "SPOT"; + + private static boolean hasGoogleAccount() { + try { + return AccountManager.get(GmsCompat.appContext()) + .getAccountsByType(GOOGLE_ACCOUNT_TYPE).length != 0; + } catch (RuntimeException e) { + // Skip the optional notification if the account query fails. + Log.w(TAG, "failed to query Google accounts", e); + return false; + } + } + + private static boolean isPublicKeyCredentialRequest(@Nullable Intent intent) { + if (intent == null) { + return false; + } + + if (isGmsCoreFidoIntent(intent)) { + return true; + } + + try { + CreateCredentialRequest createRequest = intent.getParcelableExtra( + CredentialProviderService.EXTRA_CREATE_CREDENTIAL_REQUEST, + CreateCredentialRequest.class); + if (createRequest != null + && TYPE_PUBLIC_KEY_CREDENTIAL.equals(createRequest.getType())) { + return true; + } + + GetCredentialRequest getRequest = intent.getParcelableExtra( + CredentialProviderService.EXTRA_GET_CREDENTIAL_REQUEST, + GetCredentialRequest.class); + if (getRequest != null) { + for (CredentialOption option : getRequest.getCredentialOptions()) { + if (option != null + && TYPE_PUBLIC_KEY_CREDENTIAL.equals(option.getType())) { + return true; + } + } + } + } catch (BadParcelableException e) { + Log.w(TAG, "failed to inspect Credential Manager request", e); + } + + return false; + } + + private static boolean isGmsCoreFidoIntent(Intent intent) { + ComponentName component = intent.getComponent(); + if (component == null || !PACKAGE_GMS_CORE.equals(component.getPackageName())) { + return false; + } + + String className = component.getClassName(); + String action = intent.getAction(); + return (FIDO_REGISTRATION_ACTIVITY.equals(className) + && ACTION_FIDO_REGISTRATION.equals(action)) + || (FIDO_AUTHENTICATION_ACTIVITY.equals(className) + && ACTION_FIDO_AUTHENTICATION.equals(action)); + } + + private static boolean isFindHubProvisioningIntent(@Nullable Intent intent) { + if (intent == null) { + return false; + } + + ComponentName component = intent.getComponent(); + if (component == null || !PACKAGE_GMS_CORE.equals(component.getPackageName())) { + return false; + } + + String className = component.getClassName(); + if (FIND_HUB_PROVISION_ACTIVITY.equals(className)) { + return true; + } + + if (!FAST_PAIR_HALF_SHEET_ACTIVITY.equals(className)) { + return false; + } + + try { + return HALF_SHEET_TYPE_SPOT.equals(intent.getStringExtra(EXTRA_HALF_SHEET_TYPE)); + } catch (BadParcelableException e) { + Log.w(TAG, "failed to inspect Fast Pair half sheet type", e); + return false; + } } // ContentResolver#insert(Uri, ContentValues, Bundle) diff --git a/core/java/com/android/internal/gmscompat/IGms2Gca.aidl b/core/java/com/android/internal/gmscompat/IGms2Gca.aidl index f44173479d5e1..8e217a252f7d5 100644 --- a/core/java/com/android/internal/gmscompat/IGms2Gca.aidl +++ b/core/java/com/android/internal/gmscompat/IGms2Gca.aidl @@ -30,6 +30,10 @@ interface IGms2Gca { oneway void showGmsCoreMissingNearbyDevicesPermissionGeneric(); + oneway void maybeShowGmsCoreMissingRecoverableKeystorePermissionNotification(); + + oneway void maybeShowGmsCoreMissingFindHubAccountKeychainPermissionNotification(); + oneway void showMissingPostNotifsPermissionNotification(String callerPkg); oneway void maybeShowContactsSyncNotification(); From 8171365cc6f6cff119770a3616f5ff22173c30cf Mon Sep 17 00:00:00 2001 From: inthewaves Date: Sat, 1 Aug 2026 17:56:26 -0700 Subject: [PATCH 6/8] harden recoverable keystore sensitive data cleanup Derive the LSKF hash immediately before it is used to protect the recovery key and zeroize it afterward instead of retaining it while recoverable keys are loaded and encrypted. Destroy secrets when an explicitly closed recovery session is removed. Zeroization clears only the current movable array and cannot erase copies made by the runtime. Trailing underscores mark owned locals that must not be used after zeroization. After key sync enters the cryptographic path, run best effort garbage collection once the task releases its credential to shorten retention of unreachable Java objects and associated native contexts. This does not guarantee that stale heap copies or native temporary data are overwritten. Test: atest FrameworksServicesTests:KeySyncTaskTest Test: atest FrameworksServicesTests:RecoverySessionStorageTest --- .../recoverablekeystore/KeySyncTask.java | 28 ++++++++++++++----- .../storage/RecoverySessionStorage.java | 8 +++++- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/KeySyncTask.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/KeySyncTask.java index 5279219c29285..a0180c0f272c2 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/KeySyncTask.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/KeySyncTask.java @@ -91,6 +91,7 @@ public class KeySyncTask implements Runnable { private final RecoverySnapshotListenersStorage mSnapshotListenersStorage; private final TestOnlyInsecureCertificateHelper mTestOnlyInsecureCertificateHelper; private final Scrypt mScrypt; + private boolean mSensitiveCryptoStarted; public static KeySyncTask newInstance( Context context, @@ -158,6 +159,12 @@ public void run() { Log.e(TAG, "Unexpected exception thrown during KeySyncTask", e); } finally { mCredential.zeroize(); // no longer needed + if (mSensitiveCryptoStarted) { + // Best effort cleanup for unreachable sensitive objects and native contexts. + System.gc(); + System.runFinalization(); + System.gc(); + } } } @@ -274,12 +281,9 @@ private void syncKeysForAgent(int recoveryAgentUid) throws IOException, RemoteEx boolean useScryptToHashCredential = shouldUseScryptToHashCredential(); byte[] salt = generateNewSaltIfNecessary(); - byte[] localLskfHash; - if (useScryptToHashCredential) { - localLskfHash = hashCredentialsByScrypt(salt, mCredential.getCredential()); - } else { - localLskfHash = hashCredentialsBySaltedSha256(salt, mCredential.getCredential()); - } + + // Loading keys can create movable plaintext key material before reporting a failure. + mSensitiveCryptoStarted = true; Map> rawKeysWithMetadata; try { @@ -343,10 +347,18 @@ private void syncKeysForAgent(int recoveryAgentUid) throws IOException, RemoteEx vaultHandle); byte[] encryptedRecoveryKey; + // Derive the LSKF hash immediately before its only use so it stays in memory for less time. + byte[] localLskfHash_ = null; try { + if (useScryptToHashCredential) { + localLskfHash_ = hashCredentialsByScrypt(salt, mCredential.getCredential()); + } else { + localLskfHash_ = + hashCredentialsBySaltedSha256(salt, mCredential.getCredential()); + } encryptedRecoveryKey = KeySyncUtils.thmEncryptRecoveryKey( publicKey, - localLskfHash, + localLskfHash_, vaultParams, recoveryKey); } catch (NoSuchAlgorithmException e) { @@ -355,6 +367,8 @@ private void syncKeysForAgent(int recoveryAgentUid) throws IOException, RemoteEx } catch (InvalidKeyException e) { Log.e(TAG,"Could not encrypt with recovery key", e); return; + } finally { + ArrayUtils.zeroize(localLskfHash_); } KeyDerivationParams keyDerivationParams; diff --git a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorage.java b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorage.java index ef6dfe96f58a6..b2a2ac0e8ebd0 100644 --- a/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorage.java +++ b/services/core/java/com/android/server/locksettings/recoverablekeystore/storage/RecoverySessionStorage.java @@ -80,7 +80,13 @@ public void remove(int uid, String sessionId) { if (mSessionsByUid.get(uid) == null) { return; } - mSessionsByUid.get(uid).removeIf(session -> session.mSessionId.equals(sessionId)); + mSessionsByUid.get(uid).removeIf(session -> { + if (!session.mSessionId.equals(sessionId)) { + return false; + } + session.destroy(); + return true; + }); } /** From cd874779a981d507fbe5e72f41d7e13c1ac7ad19 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Wed, 5 Aug 2026 17:42:34 -0700 Subject: [PATCH 7/8] gmscompat: add GmsCompat to known system packages --- core/api/system-current.txt | 2 ++ core/java/android/ext/KnownSystemPackage.java | 2 ++ core/java/android/ext/KnownSystemPackages.java | 3 +++ 3 files changed, 7 insertions(+) diff --git a/core/api/system-current.txt b/core/api/system-current.txt index 0106fc03896aa..fcbbc6aa9bf3c 100644 --- a/core/api/system-current.txt +++ b/core/api/system-current.txt @@ -5822,6 +5822,7 @@ package android.ext { } public interface KnownSystemPackage { + field public static final int GMS_COMPAT = 4; // 0x4 field public static final int SETTINGS = 0; // 0x0 field public static final int SETUP_WIZARD = 3; // 0x3 field public static final int SHELL = 1; // 0x1 @@ -5832,6 +5833,7 @@ package android.ext { method @NonNull public static android.ext.KnownSystemPackages get(@NonNull android.content.Context); method @NonNull public String getById(int); field @NonNull public final String contactsProvider; + field @NonNull public final String gmsCompat; field @NonNull public final String launcher; field @NonNull public final String mediaProvider; field @NonNull public final String permissionController; diff --git a/core/java/android/ext/KnownSystemPackage.java b/core/java/android/ext/KnownSystemPackage.java index bb88a92edad51..fd5d91d54feca 100644 --- a/core/java/android/ext/KnownSystemPackage.java +++ b/core/java/android/ext/KnownSystemPackage.java @@ -13,6 +13,7 @@ public interface KnownSystemPackage { int SHELL = 1; int SYSTEM_UI = 2; int SETUP_WIZARD = 3; + int GMS_COMPAT = 4; /** @hide */ @IntDef(value = { @@ -20,6 +21,7 @@ public interface KnownSystemPackage { SHELL, SYSTEM_UI, SETUP_WIZARD, + GMS_COMPAT, }) @Retention(RetentionPolicy.SOURCE) @interface Enum {} diff --git a/core/java/android/ext/KnownSystemPackages.java b/core/java/android/ext/KnownSystemPackages.java index 839f04c0773bc..83d162167c8fe 100644 --- a/core/java/android/ext/KnownSystemPackages.java +++ b/core/java/android/ext/KnownSystemPackages.java @@ -22,6 +22,7 @@ public static KnownSystemPackages get(@NonNull Context ctx) { } @NonNull public final String contactsProvider; + @NonNull public final String gmsCompat; @NonNull public final String launcher; @NonNull public final String mediaProvider; @NonNull public final String permissionController; @@ -33,6 +34,7 @@ public static KnownSystemPackages get(@NonNull Context ctx) { private KnownSystemPackages(Context ctx) { Resources res = ctx.getResources(); contactsProvider = "com.android.providers.contacts"; + gmsCompat = "app.grapheneos.gmscompat"; launcher = "com.android.launcher3"; mediaProvider = "com.android.providers.media.module"; permissionController = "com.android.permissioncontroller"; @@ -49,6 +51,7 @@ public String getById(@KnownSystemPackage.Enum int id) { case KnownSystemPackage.SHELL -> shell; case KnownSystemPackage.SYSTEM_UI -> systemUi; case KnownSystemPackage.SETUP_WIZARD -> setupWizard; + case KnownSystemPackage.GMS_COMPAT -> gmsCompat; default -> throw new IllegalArgumentException(); }; } From 7d57e7baca2aaf8fb7ceb7d2fb60d9ba44ed1077 Mon Sep 17 00:00:00 2001 From: inthewaves Date: Wed, 5 Aug 2026 17:42:57 -0700 Subject: [PATCH 8/8] gmscompat: allow GmsCompat to read Bluetooth auto-off timeout Allow GmsCompat to inform users if Bluetooth auto-off is on, since it might affect Find Hub functionality that they might want. --- core/java/android/provider/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/java/android/provider/Settings.java b/core/java/android/provider/Settings.java index bcf423537bb66..b8bb77cd32c7b 100644 --- a/core/java/android/provider/Settings.java +++ b/core/java/android/provider/Settings.java @@ -14470,7 +14470,7 @@ public static final class Global extends NameValueTable { public static final String WIFI_AUTO_OFF = "wifi_off_timeout"; /** @hide */ - @Protected(readWrite = KnownSystemPackage.SETTINGS) + @Protected(read = KnownSystemPackage.GMS_COMPAT, readWrite = KnownSystemPackage.SETTINGS) public static final String BLUETOOTH_AUTO_OFF = "bluetooth_off_timeout"; /** @hide */