From 6de8d4136d1fadee1a3c5a2b25851b97fed52794 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 10:29:32 -0400 Subject: [PATCH] feat(android): keep the session cookie in Keystore-encrypted storage (M462 #4985) 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 --- .../fabledsword/minstrel/auth/AuthStore.kt | 80 +++++++++- .../fabledsword/minstrel/auth/SessionVault.kt | 147 ++++++++++++++++++ .../minstrel/auth/ui/AuthGateViewModel.kt | 17 +- .../minstrel/api/AuthCookieInterceptorTest.kt | 3 +- .../minstrel/api/BaseUrlInterceptorTest.kt | 3 +- .../auth/AuthStoreSessionVaultTest.kt | 111 +++++++++++++ .../minstrel/auth/FakeSessionVault.kt | 23 +++ .../minstrel/auth/SealedBoxTest.kt | 48 ++++++ 8 files changed, 419 insertions(+), 13 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/auth/SessionVault.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/auth/AuthStoreSessionVaultTest.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/auth/FakeSessionVault.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/auth/SealedBoxTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt index f4aeabc1..9ff7bdad 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt @@ -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(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 = 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 } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/SessionVault.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/SessionVault.kt new file mode 100644 index 00000000..72e7a236 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/SessionVault.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt index f4bd2fa8..0ccd82d5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/ui/AuthGateViewModel.kt @@ -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(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 } } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt index 039e8a8a..5b91d267 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/AuthCookieInterceptorTest.kt @@ -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 diff --git a/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt index a5771f9a..d818c9e8 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/api/BaseUrlInterceptorTest.kt @@ -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 diff --git a/android/app/src/test/java/com/fabledsword/minstrel/auth/AuthStoreSessionVaultTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/auth/AuthStoreSessionVaultTest.kt new file mode 100644 index 00000000..2980d7d8 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/auth/AuthStoreSessionVaultTest.kt @@ -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().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) + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/auth/FakeSessionVault.kt b/android/app/src/test/java/com/fabledsword/minstrel/auth/FakeSessionVault.kt new file mode 100644 index 00000000..58f2d1b5 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/auth/FakeSessionVault.kt @@ -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 + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/auth/SealedBoxTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/auth/SealedBoxTest.kt new file mode 100644 index 00000000..3f6001f8 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/auth/SealedBoxTest.kt @@ -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 { 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 { SealedBox.open(newKey(), sealed) } + } +}