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
+10
View File
@@ -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: <key>`; 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 |
+16 -1
View File
@@ -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: <key>;
// 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
}
@@ -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,
)
}
@@ -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])
}
}