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: <key>. 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 18:50:57 +02:00
parent d9e189abd5
commit 34e5cad02a
9 changed files with 172 additions and 11 deletions
@@ -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() }
}
}
@@ -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<PictureAuthConfig> = 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") }
}
}
@@ -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://<host>`
* (`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://<host>`
* (`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,
@@ -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"
@@ -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 "))
@@ -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<HttpRequestData>()
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])
}
}