diff --git a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/auth/PreferencesApiKeyStoreTest.kt b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/auth/PreferencesApiKeyStoreTest.kt index 082003f..2dfa7b1 100644 --- a/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/auth/PreferencesApiKeyStoreTest.kt +++ b/app/src/androidTest/java/space/hackenslacker/kanbn4droid/app/auth/PreferencesApiKeyStoreTest.kt @@ -16,7 +16,10 @@ class PreferencesApiKeyStoreTest { @Before fun clearStore() = runBlocking { - store.invalidateApiKey("setup").getOrThrow() + context.getSharedPreferences("kanbn_api_key_store", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() } @Test @@ -47,15 +50,56 @@ class PreferencesApiKeyStoreTest { } @Test - fun blankAndMalformedBaseUrlStillWork() = runBlocking { - val saveBlankResult = store.saveApiKey("", "kan_key") + fun saveGetForDifferentBaseUrlsAreIsolated() = runBlocking { + 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 invalidateMalformedResult = store.invalidateApiKey("::://") - val getAfterInvalidateResult = store.getApiKey(" ") + val getAfterUnrelatedInvalidateResult = store.getApiKey("not a url") 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) - assertNull(getAfterInvalidateResult.getOrNull()) + assertEquals("bad_url_key", getAfterUnrelatedInvalidateResult.getOrNull()) } } diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStore.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStore.kt index 07505cd..96d635b 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStore.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStore.kt @@ -1,6 +1,7 @@ package space.hackenslacker.kanbn4droid.app.auth import android.content.Context +import java.security.MessageDigest interface ApiKeyStore { suspend fun saveApiKey(baseUrl: String, apiKey: String): Result @@ -15,26 +16,71 @@ class PreferencesApiKeyStore( override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result { 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 } } override suspend fun getApiKey(baseUrl: String): Result { 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 { return runCatching { - preferences.edit().remove(API_KEY_PREFERENCE_KEY).apply() + val preferenceKey = preferenceKeyForBaseUrl(baseUrl) + preferences.edit().remove(preferenceKey).apply() Unit } } + internal fun preferenceKeyForBaseUrl(baseUrl: String): String { + return deriveApiKeyPreferenceKey(baseUrl) + } + private companion object { 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() +} diff --git a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizer.kt b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizer.kt index f495688..3eaab93 100644 --- a/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizer.kt +++ b/app/src/main/java/space/hackenslacker/kanbn4droid/app/auth/UrlNormalizer.kt @@ -1,6 +1,7 @@ package space.hackenslacker.kanbn4droid.app.auth import java.net.URI +import java.util.Locale sealed interface 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://") } - val host = uri.host + val host = uri.host?.lowercase(Locale.ROOT) if (host.isNullOrBlank()) { return UrlValidationResult.Invalid("Enter a valid server URL") } diff --git a/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStoreKeyDerivationTest.kt b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStoreKeyDerivationTest.kt new file mode 100644 index 0000000..ca02c6b --- /dev/null +++ b/app/src/test/java/space/hackenslacker/kanbn4droid/app/auth/ApiKeyStoreKeyDerivationTest.kt @@ -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) + } +}