From 34e5cad02a866873d0a42605dfb96899b5767264 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 21 Oct 2026 18:50:57 +0200 Subject: [PATCH] Android: send X-Kvitt-App app key on every server request (D-126) The reverse proxy closes requests from outside the home LAN that lack X-Kvitt-App: . The key is the build property kvitt.appKey (-Pkvitt.appKey, else android/local.properties, else empty = no header), validated as 16-128 printable ASCII without whitespace, exposed as BuildConfig.APP_KEY and passed via AppBuildInfo.appKey to KvittApi (every call, /health and other unauthenticated calls included) and the Coil picture client (PictureAuth). AppBuildInfo.toString hides the key. Tests use a made-up key; README documents the property. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- android/README.md | 10 ++++ android/app/build.gradle.kts | 17 +++++- .../hagstedt/kvitt/di/AppBuildInfoModule.kt | 4 +- .../se/hagstedt/kvitt/data/api/KvittApi.kt | 17 +++++- .../se/hagstedt/kvitt/data/api/PictureAuth.kt | 8 ++- .../se/hagstedt/kvitt/data/di/DataModule.kt | 20 ++++++- .../kotlin/se/hagstedt/kvitt/data/Harness.kt | 9 ++- .../hagstedt/kvitt/data/api/KvittApiTest.kt | 40 +++++++++++++ .../kvitt/data/api/PictureAuthTest.kt | 58 +++++++++++++++++++ 9 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/PictureAuthTest.kt diff --git a/android/README.md b/android/README.md index 5ee58d9..4fee71a 100644 --- a/android/README.md +++ b/android/README.md @@ -13,6 +13,16 @@ prefills (AC-2.7). Gradle takes `-Pkvitt.publicHost=…` first (`infra/publish-a placeholder `kvitt.example.invalid`, so a release built without it cannot talk to a real host. Debug builds pair with the address typed on S-1. Tests and goldens never depend on it: they use fixed hosts. +**App key (D-126).** Outside the home LAN the reverse proxy closes every request that does not carry the header +`X-Kvitt-App: `; the server never reads it, and the per-device bearer token stays the authentication. The key is +the build property `kvitt.appKey`: `:app` exposes it as `BuildConfig.APP_KEY`, passed through `AppBuildInfo.appKey` to +`KvittApi` and the picture client (`PictureAuth`), which send it on every request to the server, `/health` and the other +unauthenticated calls included. Gradle takes `-Pkvitt.appKey=…` first, else the key `kvitt.appKey` in +`android/local.properties`, where the owner sets it (git-ignored, never committed), else empty: no header, which is +what debug builds against the dev server need. A key that is set must be 16 to 128 printable ASCII characters without +whitespace, or the build fails (the message names its length, never the value). It is a secret: never logged, never in +a test, golden or report; tests use a made-up key. + ## Modules | Module | Plugin (build-logic) | Notes | diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7d3a58b..8a2211c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -32,6 +32,19 @@ require(Regex("[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+" "kvitt.publicHost must be a host name such as kvitt.duckdns.org (no scheme, port or path), was '$publicHost'" } +// D-126: the app key for the reverse proxy (SPEC §8.5, §14.3.1). Every request to the server carries X-Kvitt-App: ; +// outside the home LAN the proxy closes any request without it. From -Pkvitt.appKey, else the key kvitt.appKey in +// android/local.properties (the owner sets it there; never committed), else empty: no header (debug builds against the +// dev server need none). A secret: never logged, and the failure below names its length only, never its value. +val appKey: String = + providers.gradleProperty("kvitt.appKey").orNull?.trim()?.takeIf { it.isNotEmpty() } + ?: localProperties.getProperty("kvitt.appKey")?.trim()?.takeIf { it.isNotEmpty() } + ?: "" +require(appKey.isEmpty() || Regex("[!-~]{16,128}").matches(appKey)) { + "kvitt.appKey must be 16 to 128 printable ASCII characters without whitespace (D-126); the value given has " + + "${appKey.length} characters" +} + // SPEC §10.5.3 (D-082): the pinned NPM root (D-120) replaces the system anchors for kvitt.publicHost only when this is true. val pinnedRoot = providers.gradleProperty("kvitt.tls.pinnedRoot").orElse("false").get().toBoolean() @@ -65,6 +78,7 @@ android { versionCode = providers.gradleProperty("kvitt.versionCode").orElse("1").get().toInt() versionName = providers.gradleProperty("kvitt.versionName").orElse("dev").get() buildConfigField("String", "PUBLIC_HOST", "\"$publicHost\"") + buildConfigField("String", "APP_KEY", "\"${appKey.replace("\\", "\\\\").replace("\"", "\\\"")}\"") manifestPlaceholders["publicHost"] = publicHost } // SPEC G-8 and AC-19.1: the app is English (fallback) and Swedish only; library translations of other languages @@ -72,7 +86,8 @@ android { androidResources { localeFilters += listOf("en", "sv") } - // BuildConfig carries versionCode, versionName and DEBUG into AppBuildInfo (:data, D-041), and PUBLIC_HOST (D-121). + // BuildConfig carries versionCode, versionName, DEBUG and APP_KEY (D-126) into AppBuildInfo (:data, D-041), and + // PUBLIC_HOST (D-121). buildFeatures { buildConfig = true } diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppBuildInfoModule.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppBuildInfoModule.kt index 282eb80..1ed9f5b 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppBuildInfoModule.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppBuildInfoModule.kt @@ -10,7 +10,8 @@ import se.hagstedt.kvitt.link.RELEASE_SERVER_URL import javax.inject.Singleton /** - * Both variants: the app's version (§10.6), build type and release server ([RELEASE_SERVER_URL], D-121, D-122) for + * Both variants: the app's version (§10.6), build type, release server ([RELEASE_SERVER_URL], D-121, D-122) and app key + * for the reverse proxy (`BuildConfig.APP_KEY` from `kvitt.appKey`, empty when the build has none, D-126) for * `:data`'s `DataModule`, which Hilt installs in debug and release alike (it binds no `:core:model` interface, D-041). */ @Module @@ -22,5 +23,6 @@ object AppBuildInfoModule { versionName = BuildConfig.VERSION_NAME, debug = BuildConfig.DEBUG, releaseServerUrl = RELEASE_SERVER_URL, + appKey = BuildConfig.APP_KEY, ) } diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt index 50de49b..5aff74b 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/KvittApi.kt @@ -35,15 +35,19 @@ interface ApiClientInfo { /** The `versionName`, for `X-Kvitt-App-Version-Name` (schema 3, S-20). */ val appVersionName: String + /** The app key for the reverse proxy, for `X-Kvitt-App` (D-126); empty sends no header. A secret: never logged. */ + val appKey: String + /** The device locale as a BCP 47 tag, for `Accept-Language`. */ fun acceptLanguage(): String } /** * The Ktor client of every endpoint the app calls (§14.3.4). Every request carries `X-Kvitt-Protocol: 1`, - * `X-Kvitt-App-Version`, `X-Kvitt-App-Version-Name` and `Accept-Language`, and authenticated calls `Authorization: Bearer` (§14.3.1). The token is - * only ever in that header, never in a URL or a log (§8.2). Errors map to the §14.3.2 codes; 401 and 426 are reported - * to [events] so the session and the sync engine react in one place. + * `X-Kvitt-App-Version`, `X-Kvitt-App-Version-Name` and `Accept-Language`, `X-Kvitt-App` when the build has an app key + * (D-126: the reverse proxy's gate, which the server never reads), and authenticated calls `Authorization: Bearer` + * (§14.3.1). The token and the app key are only ever in their headers, never in a URL or a log (§8.2). Errors map to + * the §14.3.2 codes; 401 and 426 are reported to [events] so the session and the sync engine react in one place. */ class KvittApi( private val http: HttpClient, @@ -188,6 +192,7 @@ class KvittApi( header(APP_VERSION_HEADER, info.appVersionCode.toString()) versionNameHeader(info.appVersionName)?.let { header(APP_VERSION_NAME_HEADER, it) } header(HttpHeaders.AcceptLanguage, info.acceptLanguage()) + appKeyHeader(info.appKey)?.let { header(APP_KEY_HEADER, it) } } private suspend fun failure(response: HttpResponse, authenticated: Boolean): ApiResult.Failure { @@ -243,6 +248,12 @@ class KvittApi( fun versionNameHeader(name: String): String? = name.trim().takeIf { it.length in 1..VERSION_NAME_MAX && it.all { c -> c in ' '..'~' } } const val SERVER_VERSION_HEADER = "X-Kvitt-Server-Version" + + /** The reverse proxy's gate (D-126); the server never reads it and it is not in `contracts/openapi.yaml`. */ + const val APP_KEY_HEADER = "X-Kvitt-App" + + /** The header value, or null when the build has no app key (empty: the header is not sent). */ + fun appKeyHeader(key: String): String? = key.takeIf { it.isNotEmpty() } } } diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/PictureAuth.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/PictureAuth.kt index 57db78a..0e3f98c 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/PictureAuth.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/api/PictureAuth.kt @@ -4,23 +4,29 @@ import io.ktor.client.plugins.api.ClientPlugin import io.ktor.client.plugins.api.createClientPlugin import io.ktor.http.HttpHeaders -/** The Coil client's plugin: the §14.3.1 headers and the bearer token, only on requests to the paired server. */ +/** + * The Coil client's plugin: the §14.3.1 headers, the app key (`X-Kvitt-App`, D-126, when the build has one) and the + * bearer token, only on requests to the paired server. + */ class PictureAuthConfig { var target: suspend () -> ApiTarget? = { null } var appVersionCode: Int = 0 var appVersionName: String = "" + var appKey: String = "" } val PictureAuth: ClientPlugin = createClientPlugin("KvittPictureAuth", ::PictureAuthConfig) { val target = pluginConfig.target val versionCode = pluginConfig.appVersionCode val versionName = KvittApi.versionNameHeader(pluginConfig.appVersionName) + val appKey = KvittApi.appKeyHeader(pluginConfig.appKey) onRequest { request, _ -> val t = target() ?: return@onRequest if (!request.url.buildString().startsWith(t.baseUrl.trimEnd('/') + "/")) return@onRequest request.headers.append(KvittApi.PROTOCOL_HEADER, KvittApi.PROTOCOL_VERSION) request.headers.append(KvittApi.APP_VERSION_HEADER, versionCode.toString()) versionName?.let { request.headers.append(KvittApi.APP_VERSION_NAME_HEADER, it) } + appKey?.let { request.headers.append(KvittApi.APP_KEY_HEADER, it) } t.token?.let { request.headers.append(HttpHeaders.Authorization, "Bearer $it") } } } diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt index 551f8db..ac6d058 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt @@ -88,10 +88,22 @@ import javax.inject.Qualifier import javax.inject.Singleton /** - * What `:app` provides: its version (§10.6), build type (AC-2.8, AC-16.5) and release server `https://` - * (`RELEASE_SERVER_URL`, from the Gradle property `kvitt.publicHost`, D-121), which S-22 shows before pairing (D-122). + * What `:app` provides: its version (§10.6), build type (AC-2.8, AC-16.5), release server `https://` + * (`RELEASE_SERVER_URL`, from the Gradle property `kvitt.publicHost`, D-121), which S-22 shows before pairing (D-122), + * and the app key of the reverse proxy (`BuildConfig.APP_KEY`, from the Gradle property `kvitt.appKey`; empty when the + * build has none, D-126). The key is a secret: [toString] leaves it out. */ -data class AppBuildInfo(val versionCode: Int, val versionName: String, val debug: Boolean, val releaseServerUrl: String) +data class AppBuildInfo( + val versionCode: Int, + val versionName: String, + val debug: Boolean, + val releaseServerUrl: String, + val appKey: String, +) { + override fun toString(): String = + "AppBuildInfo(versionCode=$versionCode, versionName=$versionName, debug=$debug, releaseServerUrl=$releaseServerUrl, " + + "appKey=${if (appKey.isEmpty()) "none" else "set"})" +} @Qualifier @Retention(AnnotationRetention.BINARY) @@ -157,6 +169,7 @@ object DataModule { target = { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } } appVersionCode = build.versionCode appVersionName = build.versionName + appKey = build.appKey } } return Pictures.imageLoader(context, client) { base.value } @@ -182,6 +195,7 @@ object DataModule { info = object : ApiClientInfo { override val appVersionCode: Int = build.versionCode override val appVersionName: String = build.versionName + override val appKey: String = build.appKey override fun acceptLanguage(): String = Locale.getDefault().toLanguageTag() }, events = session, diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt index 9ef1cfe..320c9e3 100644 --- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/Harness.kt @@ -108,9 +108,10 @@ class FakeSyncWork : SyncWork { /** * Everything `:data` needs, on an in-memory Room database (framework driver under Robolectric), DataStore files in a - * temporary directory and a [FakeServer] behind Ktor's MockEngine. + * temporary directory and a [FakeServer] behind Ktor's MockEngine. [appKey] is the build's app key (D-126); empty is a + * build without one. */ -class Harness { +class Harness(val appKey: String = APP_KEY) { val clock = TestClock() val ids = SequentialIds() val server = FakeServer(clock) @@ -145,6 +146,7 @@ class Harness { object : ApiClientInfo { override val appVersionCode = APP_VERSION override val appVersionName = APP_VERSION_NAME + override val appKey = this@Harness.appKey override fun acceptLanguage() = "sv-SE" }, session, @@ -188,6 +190,9 @@ class Harness { companion object { const val APP_VERSION = 41 const val APP_VERSION_NAME = "1.1" + + /** A made-up app key (D-126); never a real one. */ + const val APP_KEY = "test-app-key-0123456789" const val BASE_URL = "https://kvitt.test" const val TOKEN = "kvt_testtoken0000000000000000000000000000000000" const val ME = "0192a000-0000-7000-8000-000000000001" diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/KvittApiTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/KvittApiTest.kt index 34bd147..2727190 100644 --- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/KvittApiTest.kt +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/KvittApiTest.kt @@ -42,11 +42,51 @@ class KvittApiTest { assertEquals(Harness.APP_VERSION.toString(), r.headers[KvittApi.APP_VERSION_HEADER]) assertEquals(Harness.APP_VERSION_NAME, r.headers[KvittApi.APP_VERSION_NAME_HEADER]) assertEquals("sv-SE", r.headers[HttpHeaders.AcceptLanguage]) + assertEquals(Harness.APP_KEY, r.headers[KvittApi.APP_KEY_HEADER]) assertEquals("Bearer ${Harness.TOKEN}", r.headers[HttpHeaders.Authorization]) assertFalse("the token is never in a URL", r.url.toString().contains(Harness.TOKEN)) + assertFalse("the app key is never in a URL", r.url.toString().contains(Harness.APP_KEY)) } } + @Test + fun unauthenticatedCallsCarryTheAppKey() = runSync { + h.api.health() + h.api.apkLatest() + h.api.acceptInvite("https://other.test", AcceptInviteRequest("7K3MQX9D2HPA", "Galaxy", null)) + assertEquals(3, h.requests.size) + for (r in h.requests) { + assertNull(r.headers[HttpHeaders.Authorization]) + assertEquals(Harness.APP_KEY, r.headers[KvittApi.APP_KEY_HEADER]) + } + } + + @Test + fun aBuildWithoutAnAppKeySendsNoAppKeyHeader() = runSync { + val bare = Harness(appKey = "").ready() + try { + bare.addExpense("ICA") + bare.engine.sync() + bare.api.health() + bare.api.acceptInvite("https://other.test", AcceptInviteRequest("7K3MQX9D2HPA", "Galaxy", null)) + assertTrue(bare.requests.size >= 4) + assertTrue(bare.requests.any { it.headers[HttpHeaders.Authorization] != null }) + assertTrue(bare.requests.any { it.headers[HttpHeaders.Authorization] == null }) + for (r in bare.requests) { + assertFalse(r.headers.contains(KvittApi.APP_KEY_HEADER)) + assertEquals("1", r.headers[KvittApi.PROTOCOL_HEADER]) + } + } finally { + bare.close() + } + } + + @Test + fun appKeyHeaderIsSentOnlyWhenTheBuildHasAKey() { + assertEquals(Harness.APP_KEY, KvittApi.appKeyHeader(Harness.APP_KEY)) + assertNull(KvittApi.appKeyHeader("")) + } + @Test fun versionNameHeaderIsSentOnlyWhenTheServerWouldStoreIt() { assertEquals("1.1", KvittApi.versionNameHeader(" 1.1 ")) diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/PictureAuthTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/PictureAuthTest.kt new file mode 100644 index 0000000..2da8757 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/api/PictureAuthTest.kt @@ -0,0 +1,58 @@ +package se.hagstedt.kvitt.data.api + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondOk +import io.ktor.client.request.HttpRequestData +import io.ktor.client.request.get +import io.ktor.http.HttpHeaders +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import se.hagstedt.kvitt.data.Harness +import se.hagstedt.kvitt.data.runSync + +/** The Coil client's headers (§14.3.1, D-126): only on requests to the paired server. */ +class PictureAuthTest { + private val requests = mutableListOf() + + private val engine = MockEngine { request -> + requests += request + respondOk() + } + + private fun client(appKey: String) = HttpClient(engine) { + install(PictureAuth) { + target = { ApiTarget(Harness.BASE_URL, Harness.TOKEN) } + appVersionCode = Harness.APP_VERSION + appVersionName = Harness.APP_VERSION_NAME + this.appKey = appKey + } + } + + @Test + fun picturesFromThePairedServerCarryTheAppKeyAndTheToken() = runSync { + client(Harness.APP_KEY).get("${Harness.BASE_URL}/users/${Harness.ME}/picture") + val r = requests.single() + assertEquals(Harness.APP_KEY, r.headers[KvittApi.APP_KEY_HEADER]) + assertEquals("1", r.headers[KvittApi.PROTOCOL_HEADER]) + assertEquals("Bearer ${Harness.TOKEN}", r.headers[HttpHeaders.Authorization]) + } + + @Test + fun aBuildWithoutAnAppKeySendsNoAppKeyHeader() = runSync { + client("").get("${Harness.BASE_URL}/users/${Harness.ME}/picture") + val r = requests.single() + assertFalse(r.headers.contains(KvittApi.APP_KEY_HEADER)) + assertEquals("Bearer ${Harness.TOKEN}", r.headers[HttpHeaders.Authorization]) + } + + @Test + fun otherHostsGetNeitherTheAppKeyNorTheToken() = runSync { + client(Harness.APP_KEY).get("https://elsewhere.test/picture.jpg") + val r = requests.single() + assertNull(r.headers[KvittApi.APP_KEY_HEADER]) + assertNull(r.headers[HttpHeaders.Authorization]) + } +}