fix: key stored api keys by base url

This commit is contained in:
2026-03-18 14:30:04 -04:00
parent fed1c58ae9
commit ca005a4de7
4 changed files with 140 additions and 11 deletions

View File

@@ -16,7 +16,10 @@ class PreferencesApiKeyStoreTest {
@Before @Before
fun clearStore() = runBlocking { fun clearStore() = runBlocking {
store.invalidateApiKey("setup").getOrThrow() context.getSharedPreferences("kanbn_api_key_store", android.content.Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
} }
@Test @Test
@@ -47,15 +50,56 @@ class PreferencesApiKeyStoreTest {
} }
@Test @Test
fun blankAndMalformedBaseUrlStillWork() = runBlocking { fun saveGetForDifferentBaseUrlsAreIsolated() = runBlocking {
val saveBlankResult = store.saveApiKey("", "kan_key") store.saveApiKey("https://kan.bn/", "kan_key").getOrThrow()
store.saveApiKey("https://next.kan.bn/", "next_key").getOrThrow()
val firstResult = store.getApiKey("https://kan.bn/")
val secondResult = store.getApiKey("https://next.kan.bn/")
assertEquals("kan_key", firstResult.getOrNull())
assertEquals("next_key", secondResult.getOrNull())
}
@Test
fun invalidateOneBaseUrlDoesNotRemoveOtherBaseUrlKey() = runBlocking {
store.saveApiKey("https://kan.bn/", "kan_key").getOrThrow()
store.saveApiKey("https://next.kan.bn/", "next_key").getOrThrow()
val invalidateResult = store.invalidateApiKey("https://kan.bn/")
val removedResult = store.getApiKey("https://kan.bn/")
val keptResult = store.getApiKey("https://next.kan.bn/")
assertEquals(true, invalidateResult.isSuccess)
assertNull(removedResult.getOrNull())
assertEquals("next_key", keptResult.getOrNull())
}
@Test
fun keyDerivationUsesNormalizedBaseUrl() = runBlocking {
val saveResult = store.saveApiKey(" HTTPS://KAN.BN/api/v1 ", "kan_key")
val getNormalizedResult = store.getApiKey("https://kan.bn/api/v1/")
val getEquivalentResult = store.getApiKey("https://kan.bn/api/v1")
assertEquals(true, saveResult.isSuccess)
assertEquals("kan_key", getNormalizedResult.getOrNull())
assertEquals("kan_key", getEquivalentResult.getOrNull())
}
@Test
fun malformedBaseUrlStillUsesDeterministicFallbackKeying() = runBlocking {
val saveBlankResult = store.saveApiKey("", "blank_key")
val getBlankResult = store.getApiKey(" ")
val saveMalformedResult = store.saveApiKey("not a url", "bad_url_key")
val getMalformedResult = store.getApiKey("not a url") val getMalformedResult = store.getApiKey("not a url")
val invalidateMalformedResult = store.invalidateApiKey("::://") val invalidateMalformedResult = store.invalidateApiKey("::://")
val getAfterInvalidateResult = store.getApiKey(" ") val getAfterUnrelatedInvalidateResult = store.getApiKey("not a url")
assertEquals(true, saveBlankResult.isSuccess) assertEquals(true, saveBlankResult.isSuccess)
assertEquals("kan_key", getMalformedResult.getOrNull()) assertEquals("blank_key", getBlankResult.getOrNull())
assertEquals(true, saveMalformedResult.isSuccess)
assertEquals("bad_url_key", getMalformedResult.getOrNull())
assertEquals(true, invalidateMalformedResult.isSuccess) assertEquals(true, invalidateMalformedResult.isSuccess)
assertNull(getAfterInvalidateResult.getOrNull()) assertEquals("bad_url_key", getAfterUnrelatedInvalidateResult.getOrNull())
} }
} }

View File

@@ -1,6 +1,7 @@
package space.hackenslacker.kanbn4droid.app.auth package space.hackenslacker.kanbn4droid.app.auth
import android.content.Context import android.content.Context
import java.security.MessageDigest
interface ApiKeyStore { interface ApiKeyStore {
suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit>
@@ -15,26 +16,71 @@ class PreferencesApiKeyStore(
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> { override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
return runCatching { return runCatching {
preferences.edit().putString(API_KEY_PREFERENCE_KEY, apiKey).apply() val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
preferences.edit()
.remove(LEGACY_API_KEY_PREFERENCE_KEY)
.putString(preferenceKey, apiKey)
.apply()
Unit Unit
} }
} }
override suspend fun getApiKey(baseUrl: String): Result<String?> { override suspend fun getApiKey(baseUrl: String): Result<String?> {
return runCatching { return runCatching {
preferences.getString(API_KEY_PREFERENCE_KEY, null) val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
val keyedValue = preferences.getString(preferenceKey, null)
if (keyedValue != null) {
keyedValue
} else {
val legacyValue = preferences.getString(LEGACY_API_KEY_PREFERENCE_KEY, null)
if (legacyValue != null) {
preferences.edit()
.remove(LEGACY_API_KEY_PREFERENCE_KEY)
.putString(preferenceKey, legacyValue)
.apply()
}
legacyValue
}
} }
} }
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> { override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
return runCatching { return runCatching {
preferences.edit().remove(API_KEY_PREFERENCE_KEY).apply() val preferenceKey = preferenceKeyForBaseUrl(baseUrl)
preferences.edit().remove(preferenceKey).apply()
Unit Unit
} }
} }
internal fun preferenceKeyForBaseUrl(baseUrl: String): String {
return deriveApiKeyPreferenceKey(baseUrl)
}
private companion object { private companion object {
private const val PREFERENCES_NAME = "kanbn_api_key_store" private const val PREFERENCES_NAME = "kanbn_api_key_store"
private const val API_KEY_PREFERENCE_KEY = "api_key" private const val API_KEY_PREFERENCE_KEY_PREFIX = "api_key_"
private const val LEGACY_API_KEY_PREFERENCE_KEY = "api_key"
} }
} }
internal fun deriveApiKeyPreferenceKey(baseUrl: String): String {
val normalizedBaseUrl = when (val normalized = UrlNormalizer.normalize(baseUrl)) {
is UrlValidationResult.Valid -> normalized.normalizedUrl
is UrlValidationResult.Invalid -> baseUrl.trim()
}
val digest = MessageDigest.getInstance("SHA-256")
.digest(normalizedBaseUrl.toByteArray(Charsets.UTF_8))
return "api_key_${digest.toHexString()}"
}
private fun ByteArray.toHexString(): String {
val builder = StringBuilder(size * 2)
for (byte in this) {
val value = byte.toInt() and 0xff
if (value < 0x10) {
builder.append('0')
}
builder.append(value.toString(16))
}
return builder.toString()
}

View File

@@ -1,6 +1,7 @@
package space.hackenslacker.kanbn4droid.app.auth package space.hackenslacker.kanbn4droid.app.auth
import java.net.URI import java.net.URI
import java.util.Locale
sealed interface UrlValidationResult { sealed interface UrlValidationResult {
data class Valid(val normalizedUrl: String) : UrlValidationResult data class Valid(val normalizedUrl: String) : UrlValidationResult
@@ -26,7 +27,7 @@ object UrlNormalizer {
return UrlValidationResult.Invalid("Base URL must start with http:// or https://") return UrlValidationResult.Invalid("Base URL must start with http:// or https://")
} }
val host = uri.host val host = uri.host?.lowercase(Locale.ROOT)
if (host.isNullOrBlank()) { if (host.isNullOrBlank()) {
return UrlValidationResult.Invalid("Enter a valid server URL") return UrlValidationResult.Invalid("Enter a valid server URL")
} }

View File

@@ -0,0 +1,38 @@
package space.hackenslacker.kanbn4droid.app.auth
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ApiKeyStoreKeyDerivationTest {
@Test
fun equivalentNormalizedBaseUrlsProduceSamePreferenceKey() {
val first = deriveApiKeyPreferenceKey(" HTTPS://KAN.BN/api/v1 ")
val second = deriveApiKeyPreferenceKey("https://kan.bn/api/v1/")
val third = deriveApiKeyPreferenceKey("https://kan.bn/api/v1")
assertEquals(first, second)
assertEquals(second, third)
}
@Test
fun differentNormalizedBaseUrlsProduceDifferentPreferenceKeys() {
val first = deriveApiKeyPreferenceKey("https://kan.bn/")
val second = deriveApiKeyPreferenceKey("https://next.kan.bn/")
assertNotEquals(first, second)
}
@Test
fun malformedBaseUrlsUseTrimmedDeterministicFallback() {
val first = deriveApiKeyPreferenceKey("not a url")
val second = deriveApiKeyPreferenceKey("not a url")
val trimmedEquivalent = deriveApiKeyPreferenceKey(" not a url ")
val different = deriveApiKeyPreferenceKey("::://")
assertEquals(first, second)
assertEquals(first, trimmedEquivalent)
assertNotEquals(first, different)
}
}