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:
@@ -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 |
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user