diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index e63d9a2d445..a24d5aba7a1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -431,4 +431,23 @@ interface AuthDiskSource : AppIdProvider { * Stores the v2 upgrade token for the given [userId]. */ fun storeV2UpgradeToken(userId: String, v2UpgradeToken: V2UpgradeTokenJson?) + + /** + * Gets the start of the v2 encrypted migrations grace period for the given [userId]. + * + * This value is retained across logout and lock and is only removed when explicitly stored as + * `null`. + */ + fun getV2EncryptedMigrationsGracePeriodStart(userId: String): Instant? + + /** + * Stores the start of the v2 encrypted migrations grace period for the given [userId]. + * + * This value is retained across logout and lock and is only removed when explicitly stored as + * `null`. + */ + fun storeV2EncryptedMigrationsGracePeriodStart( + userId: String, + gracePeriodStart: Instant?, + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index e6ffa854d72..2a0e3a271d9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -62,6 +62,8 @@ private const val LAST_LOCK_TIMESTAMP = "lastLockTimestamp" private const val PROFILE_ACCOUNT_KEYS_KEY = "profileAccountKeys" private const val V2_UPGRADE_TOKEN = "v2UpgradeToken" private const val USER_KEY_ID_KEY = "userKeyId" +private const val V2_ENCRYPTED_MIGRATIONS_GRACE_PERIOD_START = + "v2EncryptedMigrationsGracePeriodStart" /** * Primary implementation of [AuthDiskSource]. @@ -208,6 +210,7 @@ class AuthDiskSourceImpl( // * DeviceKey // * PendingAuthRequest // * OnboardingStatus + // * V2EncryptedMigrationsGracePeriodStart } override fun getAuthenticatorSyncUnlockKey(userId: String): String? = @@ -632,6 +635,20 @@ class AuthDiskSourceImpl( ) } + override fun getV2EncryptedMigrationsGracePeriodStart(userId: String): Instant? = + getLong(key = V2_ENCRYPTED_MIGRATIONS_GRACE_PERIOD_START.appendIdentifier(userId)) + ?.let { Instant.ofEpochMilli(it) } + + override fun storeV2EncryptedMigrationsGracePeriodStart( + userId: String, + gracePeriodStart: Instant?, + ) { + putLong( + key = V2_ENCRYPTED_MIGRATIONS_GRACE_PERIOD_START.appendIdentifier(userId), + value = gracePeriodStart?.toEpochMilli(), + ) + } + private fun generateAndStoreUniqueAppId(): String = UUID .randomUUID() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridge.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridge.kt index 4d96553d2cd..aa528c744bc 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridge.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridge.kt @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.updateMasterPasswordUnlock import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeToken import com.x8bit.bitwarden.data.vault.repository.util.toV2UpgradeTokenJson +import java.time.Instant /** * A user-scoped implementation of a Bitwarden SDK [StateBridgeForeignImpl]. @@ -167,6 +168,23 @@ internal class SdkStateBridge( ) } + override suspend fun setV2EncryptedMigrationsGracePeriodStart(value: Instant) { + authDiskSource.storeV2EncryptedMigrationsGracePeriodStart( + userId = userId, + gracePeriodStart = value, + ) + } + + override suspend fun getV2EncryptedMigrationsGracePeriodStart(): Instant? = + authDiskSource.getV2EncryptedMigrationsGracePeriodStart(userId = userId) + + override suspend fun clearV2EncryptedMigrationsGracePeriodStart() { + authDiskSource.storeV2EncryptedMigrationsGracePeriodStart( + userId = userId, + gracePeriodStart = null, + ) + } + override suspend fun getWebauthnPrfUnlockData(): WebAuthnPrfUnlockData? { // We do not support unlock with WebAuthn, so we can just return null return null diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index 29d111827ce..b9d1f77a4e4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -378,6 +378,11 @@ class AuthDiskSourceTest { ), ) authDiskSource.storeUserKeyId(userId = userId, userKeyId = "userKeyId") + val gracePeriodStart = Instant.parse("2025-01-13T12:00:00Z") + authDiskSource.storeV2EncryptedMigrationsGracePeriodStart( + userId = userId, + gracePeriodStart = gracePeriodStart, + ) authDiskSource.clearData(userId = userId) @@ -389,6 +394,10 @@ class AuthDiskSourceTest { OnboardingStatus.AUTOFILL_SETUP, authDiskSource.getOnboardingStatus(userId = userId), ) + assertEquals( + gracePeriodStart, + authDiskSource.getV2EncryptedMigrationsGracePeriodStart(userId = userId), + ) // These should be cleared assertNull(authDiskSource.getUserBiometricInitVector(userId = userId)) @@ -1557,6 +1566,61 @@ class AuthDiskSourceTest { val actual = authDiskSource.getLastLockTimestamp(userId = mockUserId) assertNull(actual) } + + @Test + fun `getV2EncryptedMigrationsGracePeriodStart should pull from SharedPreferences`() { + val storeKey = "bwPreferencesStorage:v2EncryptedMigrationsGracePeriodStart" + val mockUserId = "mockUserId" + val expectedState = Instant.parse("2025-01-13T12:00:00Z") + fakeSharedPreferences.edit { + putLong("${storeKey}_$mockUserId", expectedState.toEpochMilli()) + } + + val actual = authDiskSource.getV2EncryptedMigrationsGracePeriodStart(userId = mockUserId) + + assertEquals(expectedState, actual) + } + + @Test + fun `getV2EncryptedMigrationsGracePeriodStart should pull null when there is no data`() { + val mockUserId = "mockUserId" + + val actual = authDiskSource.getV2EncryptedMigrationsGracePeriodStart(userId = mockUserId) + + assertNull(actual) + } + + @Test + fun `storeV2EncryptedMigrationsGracePeriodStart should update SharedPreferences`() { + val mockUserId = "mockUserId" + val expectedState = Instant.parse("2025-01-13T12:00:00Z") + + authDiskSource.storeV2EncryptedMigrationsGracePeriodStart( + userId = mockUserId, + gracePeriodStart = expectedState, + ) + + assertEquals( + expectedState, + authDiskSource.getV2EncryptedMigrationsGracePeriodStart(userId = mockUserId), + ) + } + + @Test + fun `storeV2EncryptedMigrationsGracePeriodStart should clear the value when null is passed`() { + val mockUserId = "mockUserId" + authDiskSource.storeV2EncryptedMigrationsGracePeriodStart( + userId = mockUserId, + gracePeriodStart = Instant.parse("2025-01-13T12:00:00Z"), + ) + + authDiskSource.storeV2EncryptedMigrationsGracePeriodStart( + userId = mockUserId, + gracePeriodStart = null, + ) + + assertNull(authDiskSource.getV2EncryptedMigrationsGracePeriodStart(userId = mockUserId)) + } } private const val USER_STATE_JSON = """ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 3bdecac2d18..6f080c69cd7 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -74,6 +74,7 @@ class FakeAuthDiskSource : AuthDiskSource { mutableMapOf>() private val storedV2UpgradeTokens = mutableMapOf() private val storedUserKeyIds = mutableMapOf() + private val storedV2EncryptedMigrationsGracePeriodStarts = mutableMapOf() override var userState: UserStateJson? = null set(value) { @@ -109,6 +110,12 @@ class FakeAuthDiskSource : AuthDiskSource { mutableAccountTokensFlowMap.remove(userId) mutableEphemeralPinProtectedUserKeyEnvelopesFlowMap.remove(userId) mutablePersistentPinProtectedUserKeyEnvelopesFlowMap.remove(userId) + + // Certain values are never removed as required by the feature requirements: + // * DeviceKey + // * PendingAuthRequest + // * OnboardingStatus + // * V2EncryptedMigrationsGracePeriodStart } override fun getShouldUseKeyConnectorFlow( @@ -178,6 +185,16 @@ class FakeAuthDiskSource : AuthDiskSource { storedV2UpgradeTokens[userId] = v2UpgradeToken } + override fun getV2EncryptedMigrationsGracePeriodStart(userId: String): Instant? = + storedV2EncryptedMigrationsGracePeriodStarts[userId] + + override fun storeV2EncryptedMigrationsGracePeriodStart( + userId: String, + gracePeriodStart: Instant?, + ) { + storedV2EncryptedMigrationsGracePeriodStarts[userId] = gracePeriodStart + } + override fun getTwoFactorToken(email: String): String? = storedTwoFactorTokens[email] override fun storeTwoFactorToken(email: String, twoFactorToken: String?) { @@ -471,6 +488,13 @@ class FakeAuthDiskSource : AuthDiskSource { assertEquals(v2UpgradeToken, storedV2UpgradeTokens[userId]) } + /** + * Assert that the [gracePeriodStart] was stored successfully using the [userId]. + */ + fun assertV2EncryptedMigrationsGracePeriodStart(userId: String, gracePeriodStart: Instant?) { + assertEquals(gracePeriodStart, storedV2EncryptedMigrationsGracePeriodStarts[userId]) + } + /** * Assert that the [twoFactorToken] was stored successfully using the [email]. */ diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridgeTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridgeTest.kt index d16eaf57d0e..1e5eb4b839b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridgeTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/sdk/statebridge/SdkStateBridgeTest.kt @@ -214,6 +214,48 @@ class SdkStateBridgeTest { authDiskSource.assertV2UpgradeToken(userId = USER_ID, v2UpgradeToken = null) } + @Test + fun `setV2EncryptedMigrationsGracePeriodStart should store the grace period start`() = runTest { + stateBridge.setV2EncryptedMigrationsGracePeriodStart(value = GRACE_PERIOD_START) + + authDiskSource.assertV2EncryptedMigrationsGracePeriodStart( + userId = USER_ID, + gracePeriodStart = GRACE_PERIOD_START, + ) + } + + @Test + fun `getV2EncryptedMigrationsGracePeriodStart should return the stored grace period start`() = + runTest { + assertNull(stateBridge.getV2EncryptedMigrationsGracePeriodStart()) + + authDiskSource.storeV2EncryptedMigrationsGracePeriodStart( + userId = USER_ID, + gracePeriodStart = GRACE_PERIOD_START, + ) + + assertEquals( + GRACE_PERIOD_START, + stateBridge.getV2EncryptedMigrationsGracePeriodStart(), + ) + } + + @Test + fun `clearV2EncryptedMigrationsGracePeriodStart should clear the grace period start`() = + runTest { + authDiskSource.storeV2EncryptedMigrationsGracePeriodStart( + userId = USER_ID, + gracePeriodStart = GRACE_PERIOD_START, + ) + + stateBridge.clearV2EncryptedMigrationsGracePeriodStart() + + authDiskSource.assertV2EncryptedMigrationsGracePeriodStart( + userId = USER_ID, + gracePeriodStart = null, + ) + } + @Test fun `setAccountCryptographicState should store the account cryptographic state`() = runTest { val state = createMockWrappedAccountCryptographicState(number = 1) @@ -493,6 +535,8 @@ private val V2_UPGRADE_TOKEN_JSON: V2UpgradeTokenJson = V2UpgradeTokenJson( wrappedUserKey2 = "wrappedUserKey2", ) +private val GRACE_PERIOD_START: Instant = Instant.parse("2024-09-13T01:00:00.00Z") + private val MASTER_PASSWORD_UNLOCK_DATA: MasterPasswordUnlockData = MasterPasswordUnlockData( kdf = Kdf.Pbkdf2(iterations = 600_000u), masterKeyWrappedUserKey = "masterKeyWrappedUserKey", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c871fbc12c..a379e3cc377 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ androidxRoom = "2.8.4" androidxSecurityCrypto = "1.1.0" androidxSplash = "1.2.0" androidxWork = "2.11.2" -bitwardenSdk = "3.0.0-8495-c1b859a9" +bitwardenSdk = "3.0.0-8671-5d8ae614" crashlytics = "3.0.8" detekt = "1.23.8" firebaseBom = "34.18.0"