feat(android): keep the session cookie in Keystore-encrypted storage (M462 #4985)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 4m29s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 4m29s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
The session cookie is a bearer credential, and it sat in plain text in the Room auth_session row. It now lives in a SessionVault: AES-256-GCM under a key held in the Android Keystore, with only the ciphertext in a private prefs file. A copy of the app's files no longer yields a usable session. Platform APIs only, no new dependency (androidx.security-crypto is deprecated). Nobody is signed out by the upgrade. On first launch AuthStore moves a cookie still in the row into the vault and clears the column. If the Keystore can't be used on a device, the cookie stays in the row as before rather than being lost. A sign-in or 401 that lands during the move wins over the value it read, and the move never throws. The auth gate now waits for this before choosing Login or Home, with a 10s deadline so a wedged Keystore can't leave the start screen spinning. Tests: AuthStoreSessionVaultTest (upgrade move, vault-only load, Keystore fallback, sign-in/out, hydration race) and SealedBoxTest (round trip, fresh IV, tamper and wrong-key rejection). The real Keystore path needs a device; the first launch after updating is that check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,17 @@ import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
|||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -27,6 +33,14 @@ import javax.inject.Singleton
|
|||||||
* in-memory state changes synchronously so the next interceptor read
|
* in-memory state changes synchronously so the next interceptor read
|
||||||
* sees the new value immediately; the DAO write coroutine catches up
|
* sees the new value immediately; the DAO write coroutine catches up
|
||||||
* shortly after.
|
* shortly after.
|
||||||
|
*
|
||||||
|
* **The session cookie is the exception** (M462 #4985): it is persisted
|
||||||
|
* through [SessionVault] (Keystore-encrypted), not the Room row. On the
|
||||||
|
* first launch after the upgrade, a cookie still in the row is moved into
|
||||||
|
* the vault and the column cleared, so nobody is signed out by the change.
|
||||||
|
* If the Keystore cannot be used on a device, the cookie stays in the row
|
||||||
|
* as before rather than being lost. [awaitSessionHydrated] lets a caller
|
||||||
|
* that needs a definitive answer (the auth gate) wait for this.
|
||||||
*/
|
*/
|
||||||
// AuthStore is the single-row facade over auth_session (de-facto
|
// AuthStore is the single-row facade over auth_session (de-facto
|
||||||
// app_preferences — see entity comment). It legitimately owns one
|
// app_preferences — see entity comment). It legitimately owns one
|
||||||
@@ -39,6 +53,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class AuthStore @Inject constructor(
|
class AuthStore @Inject constructor(
|
||||||
private val dao: AuthSessionDao,
|
private val dao: AuthSessionDao,
|
||||||
|
private val vault: SessionVault,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private val sessionCookieState = MutableStateFlow<String?>(null)
|
private val sessionCookieState = MutableStateFlow<String?>(null)
|
||||||
@@ -64,10 +79,20 @@ class AuthStore @Inject constructor(
|
|||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
// Serialises every cookie persist with the one-time hydration, so a
|
||||||
|
// sign-in or a 401 that lands while hydration runs is never overwritten
|
||||||
|
// by the stale value hydration read.
|
||||||
|
private val cookieLock = Mutex()
|
||||||
|
|
||||||
|
// Set by setSessionCookie. Once something has written the cookie this
|
||||||
|
// process, that value wins over whatever hydration finds on disk.
|
||||||
|
@Volatile private var cookieTouched = false
|
||||||
|
|
||||||
|
private val cookieHydration: Deferred<Unit> = scope.async { hydrateSessionCookie() }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
dao.observe().collect { row ->
|
dao.observe().collect { row ->
|
||||||
sessionCookieState.value = row?.sessionCookie
|
|
||||||
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
||||||
userJsonState.value = row?.userJson
|
userJsonState.value = row?.userJson
|
||||||
themeModeState.value = row?.themeMode
|
themeModeState.value = row?.themeMode
|
||||||
@@ -85,9 +110,50 @@ class AuthStore @Inject constructor(
|
|||||||
}.getOrDefault(CacheSettings.DEFAULT)
|
}.getOrDefault(CacheSettings.DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until the stored session cookie has been loaded into
|
||||||
|
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
||||||
|
* Keystore must not leave the start screen spinning). Returns false on
|
||||||
|
* the deadline; the caller then decides from whatever has loaded, and a
|
||||||
|
* late hydration still lands in [sessionCookie].
|
||||||
|
*/
|
||||||
|
suspend fun awaitSessionHydrated(): Boolean {
|
||||||
|
val done = withTimeoutOrNull(HYDRATION_DEADLINE_MS) { cookieHydration.await() } != null
|
||||||
|
if (!done) Timber.w("auth store: session hydration passed its deadline; deciding without it")
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
fun setSessionCookie(value: String?) {
|
fun setSessionCookie(value: String?) {
|
||||||
|
cookieTouched = true
|
||||||
sessionCookieState.value = value
|
sessionCookieState.value = value
|
||||||
scope.launch { persistCookie(value) }
|
scope.launch { cookieLock.withLock { storeCookie(value) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun hydrateSessionCookie() = cookieLock.withLock {
|
||||||
|
val legacy = runCatching { dao.get()?.sessionCookie }.getOrNull()
|
||||||
|
if (cookieTouched) return@withLock
|
||||||
|
// A cookie in the row is the newer one when both exist: the row is
|
||||||
|
// only written when the vault failed, and an install upgrading from
|
||||||
|
// before the vault has nothing in the vault yet.
|
||||||
|
val cookie = legacy ?: vault.read()
|
||||||
|
// Best-effort: if moving it fails, the session still loads this time
|
||||||
|
// and the move is retried on the next launch. Hydration must never
|
||||||
|
// throw, or awaitSessionHydrated would leave the auth gate stuck.
|
||||||
|
if (legacy != null) {
|
||||||
|
runCatching { storeCookie(legacy) }
|
||||||
|
.onFailure { Timber.w(it, "auth store: could not move the session cookie into the vault") }
|
||||||
|
}
|
||||||
|
sessionCookieState.value = cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vault first; the Room row only when the Keystore is unusable, so a
|
||||||
|
// broken Keystore degrades to the old storage rather than a sign-out.
|
||||||
|
private suspend fun storeCookie(value: String?) {
|
||||||
|
if (vault.write(value)) {
|
||||||
|
if (dao.get()?.sessionCookie != null) dao.setSessionCookie(null)
|
||||||
|
} else {
|
||||||
|
persistLegacyCookie(value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBaseUrl(value: String) {
|
fun setBaseUrl(value: String) {
|
||||||
@@ -121,7 +187,7 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistDiagnosticsOptOut(value) }
|
scope.launch { persistDiagnosticsOptOut(value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun persistCookie(value: String?) {
|
private suspend fun persistLegacyCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||||
} else {
|
} else {
|
||||||
@@ -179,7 +245,9 @@ class AuthStore @Inject constructor(
|
|||||||
|
|
||||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
id = ROW_ID,
|
id = ROW_ID,
|
||||||
sessionCookie = sessionCookieState.value,
|
// Never copied into the row: the cookie lives in the vault, and
|
||||||
|
// persistLegacyCookie sets it explicitly on the fallback path.
|
||||||
|
sessionCookie = null,
|
||||||
baseUrl = baseUrlState.value,
|
baseUrl = baseUrlState.value,
|
||||||
userJson = userJsonState.value,
|
userJson = userJsonState.value,
|
||||||
themeMode = themeModeState.value,
|
themeMode = themeModeState.value,
|
||||||
@@ -193,6 +261,10 @@ class AuthStore @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
|
const val DEFAULT_BASE_URL: String = "http://localhost:8080"
|
||||||
|
|
||||||
|
// Generous on purpose: hydration is one local row read and one
|
||||||
|
// Keystore decrypt, normally milliseconds. This only bounds "never".
|
||||||
|
const val HYDRATION_DEADLINE_MS: Long = 10_000
|
||||||
private const val ROW_ID = 0
|
private const val ROW_ID = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the session cookie lives at rest (M462 #4985).
|
||||||
|
*
|
||||||
|
* The cookie is a bearer credential: anyone holding it is signed in as the
|
||||||
|
* user until the server expires or revokes it. It used to sit in plain text
|
||||||
|
* in the Room `auth_session` row, readable from any copy of the app's data
|
||||||
|
* directory (a rooted device, an adb backup of a debuggable build, a
|
||||||
|
* forensic image). Now only ciphertext is stored, under an AES key that
|
||||||
|
* lives in the Android Keystore and never leaves it, so a copy of the
|
||||||
|
* files alone yields nothing usable.
|
||||||
|
*/
|
||||||
|
interface SessionVault {
|
||||||
|
/** The stored cookie, or null when none is stored or it can't be decrypted. */
|
||||||
|
fun read(): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores [value], or clears the stored cookie when null. Returns false
|
||||||
|
* when the Keystore could not be used, so the caller can fall back
|
||||||
|
* rather than lose the session.
|
||||||
|
*/
|
||||||
|
fun write(value: String?): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-GCM sealing of a short string, framed as base64(iv || ciphertext+tag).
|
||||||
|
* Kept apart from the Keystore so the framing can be unit-tested on the JVM
|
||||||
|
* with an ordinary key; the Android Keystore has no JVM implementation.
|
||||||
|
*/
|
||||||
|
internal object SealedBox {
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val TAG_BITS = 128
|
||||||
|
private const val IV_BYTES = 12
|
||||||
|
|
||||||
|
// Binds a sealed value to its purpose: a blob sealed for something else
|
||||||
|
// under the same key will not open as a session cookie.
|
||||||
|
private val AAD = "minstrel-session-cookie-v1".toByteArray(Charsets.UTF_8)
|
||||||
|
|
||||||
|
fun seal(key: SecretKey, plaintext: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
// No IV passed: the provider generates a fresh random one. Keystore
|
||||||
|
// keys refuse a caller-chosen IV for encryption by default.
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
|
cipher.updateAAD(AAD)
|
||||||
|
val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
|
return Base64.getEncoder().encodeToString(sealed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(key: SecretKey, sealed: String): String {
|
||||||
|
val bytes = Base64.getDecoder().decode(sealed)
|
||||||
|
require(bytes.size > IV_BYTES) { "sealed value too short" }
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES))
|
||||||
|
cipher.updateAAD(AAD)
|
||||||
|
return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [SessionVault] backed by a Keystore AES key and a private prefs file that
|
||||||
|
* holds only the sealed value.
|
||||||
|
*
|
||||||
|
* A value that will not open (the key was wiped by a factory-reset of the
|
||||||
|
* Keystore, or the file was restored onto another device; app backup is off,
|
||||||
|
* but a vendor transfer tool may still copy files) is discarded and reported
|
||||||
|
* as absent. The user signs in again, which is the right outcome for a
|
||||||
|
* credential that no longer verifies.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class KeystoreSessionVault @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : SessionVault {
|
||||||
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
override fun read(): String? {
|
||||||
|
val sealed = prefs.getString(KEY_COOKIE, null) ?: return null
|
||||||
|
return runCatching { SealedBox.open(key(), sealed) }
|
||||||
|
.onFailure {
|
||||||
|
Timber.w(it, "session vault: stored cookie would not decrypt; discarding it")
|
||||||
|
prefs.edit().remove(KEY_COOKIE).commit()
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun write(value: String?): Boolean = runCatching {
|
||||||
|
val editor = prefs.edit()
|
||||||
|
if (value == null) {
|
||||||
|
editor.remove(KEY_COOKIE)
|
||||||
|
} else {
|
||||||
|
editor.putString(KEY_COOKIE, SealedBox.seal(key(), value))
|
||||||
|
}
|
||||||
|
editor.commit()
|
||||||
|
}.onFailure {
|
||||||
|
Timber.w(it, "session vault: Keystore unavailable; cookie not stored in the vault")
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private fun key(): SecretKey {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||||
|
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||||
|
generator.init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(KEY_BITS)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
return generator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
const val KEY_ALIAS = "minstrel_session_cookie"
|
||||||
|
const val KEY_BITS = 256
|
||||||
|
const val PREFS_NAME = "session_vault"
|
||||||
|
const val KEY_COOKIE = "sealed_cookie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class SessionVaultModule {
|
||||||
|
@Binds
|
||||||
|
abstract fun bindSessionVault(impl: KeystoreSessionVault): SessionVault
|
||||||
|
}
|
||||||
@@ -16,10 +16,11 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the initial startDestination for the root NavHost based on
|
* Computes the initial startDestination for the root NavHost based on
|
||||||
* persisted auth state. Sits on top of AuthSessionDao directly rather
|
* persisted auth state. Reads the row from AuthSessionDao directly, and
|
||||||
* than AuthStore's StateFlow because the StateFlow defaults to null
|
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
|
||||||
* until Room's first async emission — we need a definitive answer
|
* StateFlows default to null until their async load lands, and we need a
|
||||||
* before drawing any nav graph.
|
* definitive answer before drawing any nav graph. The cookie is no longer
|
||||||
|
* in the row (it lives in the Keystore-backed SessionVault, #4985).
|
||||||
*
|
*
|
||||||
* - no row at all → ServerUrl (first launch)
|
* - no row at all → ServerUrl (first launch)
|
||||||
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
||||||
@@ -31,6 +32,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AuthGateViewModel @Inject constructor(
|
class AuthGateViewModel @Inject constructor(
|
||||||
private val dao: AuthSessionDao,
|
private val dao: AuthSessionDao,
|
||||||
|
private val authStore: AuthStore,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<Any?>(null)
|
private val internal = MutableStateFlow<Any?>(null)
|
||||||
@@ -38,12 +40,13 @@ class AuthGateViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
authStore.awaitSessionHydrated()
|
||||||
|
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
|
||||||
val row = dao.get()
|
val row = dao.get()
|
||||||
internal.value = when {
|
internal.value = when {
|
||||||
row == null -> ServerUrl
|
row == null -> ServerUrl
|
||||||
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
|
row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl
|
||||||
ServerUrl
|
!signedIn -> Login
|
||||||
row.sessionCookie.isNullOrEmpty() -> Login
|
|
||||||
else -> Home
|
else -> Home
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.api
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -43,7 +44,7 @@ class AuthCookieInterceptorTest {
|
|||||||
coEvery { setSessionCookie(any()) } returns Unit
|
coEvery { setSessionCookie(any()) } returns Unit
|
||||||
coEvery { setBaseUrl(any()) } returns Unit
|
coEvery { setBaseUrl(any()) } returns Unit
|
||||||
}
|
}
|
||||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher()))
|
||||||
|
|
||||||
// BaseUrlInterceptor rewrites placeholder.invalid → mock server.
|
// BaseUrlInterceptor rewrites placeholder.invalid → mock server.
|
||||||
// AuthCookieInterceptor scopes its attach + clear behavior to
|
// AuthCookieInterceptor scopes its attach + clear behavior to
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.minstrel.api
|
package com.fabledsword.minstrel.api
|
||||||
|
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -38,7 +39,7 @@ class BaseUrlInterceptorTest {
|
|||||||
coEvery { setSessionCookie(any()) } returns Unit
|
coEvery { setSessionCookie(any()) } returns Unit
|
||||||
coEvery { setBaseUrl(any()) } returns Unit
|
coEvery { setBaseUrl(any()) } returns Unit
|
||||||
}
|
}
|
||||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The session cookie moved out of the Room row into a Keystore-backed vault
|
||||||
|
* (M462 #4985). What matters is that nobody is signed out by it: an install
|
||||||
|
* upgrading with a cookie in the row keeps it, a device whose Keystore will
|
||||||
|
* not work keeps the old storage, and a sign-in or 401 that lands while the
|
||||||
|
* one-time move runs is never overwritten by the value it read.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class AuthStoreSessionVaultTest {
|
||||||
|
/** A DAO whose single row holds [legacyCookie] in its sessionCookie column. */
|
||||||
|
private class RowDao(var legacyCookie: String?) {
|
||||||
|
val dao: AuthSessionDao = mockk {
|
||||||
|
every { observe() } returns flowOf(null)
|
||||||
|
coEvery { get() } answers {
|
||||||
|
AuthSessionEntity(baseUrl = "http://music.local", sessionCookie = legacyCookie)
|
||||||
|
}
|
||||||
|
coEvery { upsert(any()) } answers { legacyCookie = firstArg<AuthSessionEntity>().sessionCookie }
|
||||||
|
coEvery { setSessionCookie(any()) } answers { legacyCookie = firstArg() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an upgrading install keeps its session, moved out of the row into the vault`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = "session=abc")
|
||||||
|
val vault = FakeSessionVault()
|
||||||
|
|
||||||
|
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||||
|
store.awaitSessionHydrated()
|
||||||
|
|
||||||
|
assertEquals("session=abc", store.sessionCookie.value)
|
||||||
|
assertEquals("session=abc", vault.stored)
|
||||||
|
assertNull(row.legacyCookie, "the plain-text copy must be cleared from the row")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cookie already in the vault is loaded without touching the row`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = null)
|
||||||
|
val vault = FakeSessionVault(stored = "session=xyz")
|
||||||
|
|
||||||
|
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||||
|
store.awaitSessionHydrated()
|
||||||
|
|
||||||
|
assertEquals("session=xyz", store.sessionCookie.value)
|
||||||
|
assertEquals(0, vault.writes)
|
||||||
|
coVerify(exactly = 0) { row.dao.setSessionCookie(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when the Keystore will not work the cookie stays in the row instead of being lost`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = "session=abc")
|
||||||
|
val vault = FakeSessionVault(available = false)
|
||||||
|
|
||||||
|
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||||
|
store.awaitSessionHydrated()
|
||||||
|
assertEquals("session=abc", store.sessionCookie.value)
|
||||||
|
assertEquals("session=abc", row.legacyCookie)
|
||||||
|
|
||||||
|
store.setSessionCookie("session=new")
|
||||||
|
assertEquals("session=new", row.legacyCookie, "fallback writes go to the row")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sign-in and sign-out write the vault, and never the row`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = null)
|
||||||
|
val vault = FakeSessionVault()
|
||||||
|
val store = AuthStore(row.dao, vault, TestScope(UnconfinedTestDispatcher(testScheduler)))
|
||||||
|
store.awaitSessionHydrated()
|
||||||
|
|
||||||
|
store.setSessionCookie("session=new")
|
||||||
|
assertEquals("session=new", vault.stored)
|
||||||
|
assertNull(row.legacyCookie)
|
||||||
|
|
||||||
|
store.setSessionCookie(null)
|
||||||
|
assertNull(vault.stored)
|
||||||
|
assertNull(store.sessionCookie.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a sign-out that lands before hydration finishes is not undone by it`() = runTest {
|
||||||
|
val row = RowDao(legacyCookie = "session=stale")
|
||||||
|
val vault = FakeSessionVault()
|
||||||
|
val scope = TestScope(StandardTestDispatcher(testScheduler))
|
||||||
|
|
||||||
|
// Hydration is queued but has not run; a 401 clears the session first.
|
||||||
|
val store = AuthStore(row.dao, vault, scope)
|
||||||
|
store.setSessionCookie(null)
|
||||||
|
scope.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(store.sessionCookie.value, "hydration must not resurrect the stale cookie")
|
||||||
|
assertNull(vault.stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [SessionVault] for JVM tests: the Android Keystore has no JVM
|
||||||
|
* implementation. [available] = false stands in for a device whose Keystore
|
||||||
|
* refuses to work, so callers' fallback paths can be exercised.
|
||||||
|
*/
|
||||||
|
class FakeSessionVault(
|
||||||
|
var stored: String? = null,
|
||||||
|
var available: Boolean = true,
|
||||||
|
) : SessionVault {
|
||||||
|
var writes = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun read(): String? = if (available) stored else null
|
||||||
|
|
||||||
|
override fun write(value: String?): Boolean {
|
||||||
|
if (!available) return false
|
||||||
|
writes++
|
||||||
|
stored = value
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.fabledsword.minstrel.auth
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sealing half of the session vault, with an ordinary JVM AES key in
|
||||||
|
* place of the Keystore one (the Keystore has no JVM implementation).
|
||||||
|
*/
|
||||||
|
class SealedBoxTest {
|
||||||
|
private fun newKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `round-trips a cookie`() {
|
||||||
|
val key = newKey()
|
||||||
|
assertEquals("session=abc", SealedBox.open(key, SealedBox.seal(key, "session=abc")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the stored form does not contain the cookie, and differs every time`() {
|
||||||
|
val key = newKey()
|
||||||
|
val first = SealedBox.seal(key, "session=abc")
|
||||||
|
val second = SealedBox.seal(key, "session=abc")
|
||||||
|
assertNotEquals(first, second, "a fresh IV per seal")
|
||||||
|
val decoded = String(Base64.getDecoder().decode(first), Charsets.ISO_8859_1)
|
||||||
|
assertFalse(decoded.contains("session=abc"), "plaintext visible in the sealed value")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tampered value does not open`() {
|
||||||
|
val key = newKey()
|
||||||
|
val bytes = Base64.getDecoder().decode(SealedBox.seal(key, "session=abc"))
|
||||||
|
bytes[bytes.size - 1] = (bytes[bytes.size - 1].toInt() xor 1).toByte()
|
||||||
|
assertThrows<Exception> { SealedBox.open(key, Base64.getEncoder().encodeToString(bytes)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a value sealed under another key does not open`() {
|
||||||
|
val sealed = SealedBox.seal(newKey(), "session=abc")
|
||||||
|
assertThrows<Exception> { SealedBox.open(newKey(), sealed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user