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.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -27,6 +33,14 @@ import javax.inject.Singleton
|
||||
* in-memory state changes synchronously so the next interceptor read
|
||||
* sees the new value immediately; the DAO write coroutine catches up
|
||||
* 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
|
||||
// app_preferences — see entity comment). It legitimately owns one
|
||||
@@ -39,6 +53,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class AuthStore @Inject constructor(
|
||||
private val dao: AuthSessionDao,
|
||||
private val vault: SessionVault,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val sessionCookieState = MutableStateFlow<String?>(null)
|
||||
@@ -64,10 +79,20 @@ class AuthStore @Inject constructor(
|
||||
|
||||
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 {
|
||||
scope.launch {
|
||||
dao.observe().collect { row ->
|
||||
sessionCookieState.value = row?.sessionCookie
|
||||
baseUrlState.value = row?.baseUrl ?: DEFAULT_BASE_URL
|
||||
userJsonState.value = row?.userJson
|
||||
themeModeState.value = row?.themeMode
|
||||
@@ -85,9 +110,50 @@ class AuthStore @Inject constructor(
|
||||
}.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?) {
|
||||
cookieTouched = true
|
||||
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) {
|
||||
@@ -121,7 +187,7 @@ class AuthStore @Inject constructor(
|
||||
scope.launch { persistDiagnosticsOptOut(value) }
|
||||
}
|
||||
|
||||
private suspend fun persistCookie(value: String?) {
|
||||
private suspend fun persistLegacyCookie(value: String?) {
|
||||
if (dao.get() == null) {
|
||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
||||
} else {
|
||||
@@ -179,7 +245,9 @@ class AuthStore @Inject constructor(
|
||||
|
||||
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||
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,
|
||||
userJson = userJsonState.value,
|
||||
themeMode = themeModeState.value,
|
||||
@@ -193,6 +261,10 @@ class AuthStore @Inject constructor(
|
||||
|
||||
companion object {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* persisted auth state. Sits on top of AuthSessionDao directly rather
|
||||
* than AuthStore's StateFlow because the StateFlow defaults to null
|
||||
* until Room's first async emission — we need a definitive answer
|
||||
* before drawing any nav graph.
|
||||
* persisted auth state. Reads the row from AuthSessionDao directly, and
|
||||
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
|
||||
* StateFlows default to null until their async load lands, and we need a
|
||||
* 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)
|
||||
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
|
||||
@@ -31,6 +32,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class AuthGateViewModel @Inject constructor(
|
||||
private val dao: AuthSessionDao,
|
||||
private val authStore: AuthStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<Any?>(null)
|
||||
@@ -38,12 +40,13 @@ class AuthGateViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
authStore.awaitSessionHydrated()
|
||||
val signedIn = !authStore.sessionCookie.value.isNullOrEmpty()
|
||||
val row = dao.get()
|
||||
internal.value = when {
|
||||
row == null -> ServerUrl
|
||||
row.baseUrl == AuthStore.DEFAULT_BASE_URL && row.sessionCookie.isNullOrEmpty() ->
|
||||
ServerUrl
|
||||
row.sessionCookie.isNullOrEmpty() -> Login
|
||||
row.baseUrl == AuthStore.DEFAULT_BASE_URL && !signedIn -> ServerUrl
|
||||
!signedIn -> Login
|
||||
else -> Home
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
@@ -43,7 +44,7 @@ class AuthCookieInterceptorTest {
|
||||
coEvery { setSessionCookie(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.
|
||||
// AuthCookieInterceptor scopes its attach + clear behavior to
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.auth.FakeSessionVault
|
||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
@@ -38,7 +39,7 @@ class BaseUrlInterceptorTest {
|
||||
coEvery { setSessionCookie(any()) } returns Unit
|
||||
coEvery { setBaseUrl(any()) } returns Unit
|
||||
}
|
||||
authStore = AuthStore(dao, TestScope(UnconfinedTestDispatcher()))
|
||||
authStore = AuthStore(dao, FakeSessionVault(), TestScope(UnconfinedTestDispatcher()))
|
||||
}
|
||||
|
||||
@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