feat(android): access-token support — same frozen contract as iOS

Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:27 +02:00
parent a5fa843f00
commit 9114630c3a
42 changed files with 2419 additions and 51 deletions

View File

@@ -45,6 +45,10 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.androidx.datastore.preferences)
// Tink AEAD encrypts the per-host access-token blob at rest under an AndroidKeystore-wrapped
// master key (B5 / ios-completion §1.1). A SEPARATE keyset from :client-tls-android's cert store —
// different secrets, different lifecycles, and no module edge to the TLS half.
implementation(libs.tink.android)
testImplementation(libs.bundles.unit.test)
testRuntimeOnly(libs.junit.platform.launcher)

View File

@@ -0,0 +1,107 @@
package wang.yaojia.webterm.hostregistry
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.HostEndpoint
import java.util.UUID
/**
* Instrumented (device) contract tests for [TinkAccessTokenStore] — Tink's AEAD + the
* AndroidKeystore-wrapped master key need a real device Keystore, so these run on hardware (no
* emulator in the build env → compiled here, executed in device QA, plan §7 / DEVICE_QA_CHECKLIST).
*
* They pin the same contract the JVM [InMemoryAccessTokenStoreTest] pins for the double, PLUS the two
* things only the real store can prove: the token **survives a restart** (a fresh store instance
* decrypts the blob) and the **at-rest bytes are ciphertext**, never the token in the clear.
*/
@RunWith(AndroidJUnit4::class)
class TinkAccessTokenStoreTest {
private companion object {
const val TOKEN = "0123456789abcdefTOKEN"
}
private val context: Context get() = ApplicationProvider.getApplicationContext()
/** A store on a per-test keyset/pref-file so tests never share state. */
private fun newStore(suffix: String): TinkAccessTokenStore = TinkAccessTokenStore(
context = context,
keysetName = "test_token_keyset_$suffix",
prefFileName = "test_token_prefs_$suffix",
masterKeyUri = "android-keystore://test_token_master_$suffix",
)
private fun endpoint(url: String = "http://10.0.0.5:3000"): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url))
@Test
fun put_then_tokenFor_returns_the_token() = runBlocking {
val store = newStore(UUID.randomUUID().toString())
assertTrue(store.put(endpoint(), TOKEN))
assertEquals(TOKEN, store.tokenFor(endpoint()))
}
@Test
fun a_fresh_store_instance_decrypts_the_persisted_token() = runBlocking {
val suffix = UUID.randomUUID().toString()
newStore(suffix).put(endpoint(), TOKEN)
// A new instance = a cold app start: no in-memory cache, must decrypt from disk.
assertEquals(TOKEN, newStore(suffix).tokenFor(endpoint()))
}
@Test
fun the_token_is_never_stored_in_the_clear() = runBlocking {
val suffix = UUID.randomUUID().toString()
newStore(suffix).put(endpoint(), TOKEN)
val raw = context
.getSharedPreferences("test_token_prefs_$suffix", Context.MODE_PRIVATE)
.all
.values
.joinToString(" ") { it.toString() }
assertFalse("the at-rest blob must be ciphertext", raw.contains(TOKEN))
}
@Test
fun remove_drops_the_token_durably() = runBlocking {
val suffix = UUID.randomUUID().toString()
val store = newStore(suffix)
store.put(endpoint(), TOKEN)
store.remove(endpoint())
assertNull(store.tokenFor(endpoint()))
assertNull("a removed token must not come back on a cold start", newStore(suffix).tokenFor(endpoint()))
}
@Test
fun a_malformed_token_is_rejected_without_touching_storage() = runBlocking {
val suffix = UUID.randomUUID().toString()
val store = newStore(suffix)
assertFalse(store.put(endpoint(), "short"))
assertNull(store.tokenFor(endpoint()))
}
@Test
fun tokens_are_keyed_per_host() = runBlocking {
val store = newStore(UUID.randomUUID().toString())
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertNull(store.tokenFor(endpoint("http://10.0.0.9:3000")))
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
}
}

View File

@@ -0,0 +1,70 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.AccessTokenSource
import wang.yaojia.webterm.wire.HostEndpoint
/**
* Per-host storage for the optional shared access token (`WEBTERM_TOKEN`, ios-completion §1.1).
*
* It IS an [AccessTokenSource], so the very same instance that the pairing screen writes to is what
* `:api-client` (every REST route) and `:transport-okhttp` (the WS upgrade) read from — one store, no
* adapter, no cache to fall out of sync.
*
* ### Keying: the canonical origin, not the raw base URL
* The key is [HostEndpoint.originHeader] — already single-point-derived, lower-cased, default-port
* normalized. So `http://box:3000`, `HTTP://box:3000/` and `http://box:3000/some/path` are ONE host
* (they are literally the same server, and the token is the server's), while a different port or host
* is a different key. Storing under the raw `baseUrl` would silently lose the token when the same
* server was re-paired from a URL with a trailing slash.
*
* ### Security contract (non-negotiable)
* Implementations MUST keep the token device-only (Android-Keystore-wrapped, excluded from backup),
* MUST validate through [AccessTokenRule] before persisting, and MUST NEVER log it.
*/
public interface AccessTokenStore : AccessTokenSource {
/** The token stored for [endpoint]'s canonical origin, or null. Never logs. */
override suspend fun tokenFor(endpoint: HostEndpoint): String?
/**
* Store [token] for [endpoint] (replacing any previous one). Returns **false** and stores nothing
* when the token fails [AccessTokenRule] — a value the server could never accept must not be
* persisted (nor round-tripped to disk) at all.
*/
public suspend fun put(endpoint: HostEndpoint, token: String): Boolean
/** Drop [endpoint]'s token. Idempotent — removing an unknown host is a no-op. */
public suspend fun remove(endpoint: HostEndpoint)
}
/**
* In-memory [AccessTokenStore]. Lives in `main` (not `test`) on purpose — it doubles for the encrypted
* store in this module's contract tests AND in the `:app` pairing-ViewModel tests (mirrors
* [InMemoryHostStore]).
*
* A [Mutex] serializes the read-modify-write; the state is an immutable map replaced wholesale on every
* change (never mutated in place).
*/
public class InMemoryAccessTokenStore(
initial: Map<String, String> = emptyMap(),
) : AccessTokenStore {
private val mutex = Mutex()
// Defensive copy so a mutable map handed to the ctor cannot alias our state.
private var tokensByOrigin: Map<String, String> = initial.toMap()
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
mutex.withLock { tokensByOrigin[endpoint.originHeader] }
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
val normalized = AccessTokenRule.normalize(token) ?: return false
mutex.withLock { tokensByOrigin = tokensByOrigin + (endpoint.originHeader to normalized) }
return true
}
override suspend fun remove(endpoint: HostEndpoint) {
mutex.withLock { tokensByOrigin = tokensByOrigin - endpoint.originHeader }
}
}

View File

@@ -0,0 +1,153 @@
package wang.yaojia.webterm.hostregistry
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeyTemplates
import com.google.crypto.tink.RegistryConfiguration
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import wang.yaojia.webterm.wire.AccessTokenRule
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
/**
* The production [AccessTokenStore]: per-host access tokens AEAD-encrypted with Tink under an
* **AndroidKeystore-wrapped master key** (ios-completion §1.1 storage rule — the Android analogue of
* iOS Keychain `…AfterFirstUnlockThisDeviceOnly` + no `kSecAttrSynchronizable`).
*
* What that buys, concretely:
* - the master key lives in the Keystore (`android-keystore://…`) and is **non-exportable**, so the
* on-disk ciphertext is useless on any other device — device-only, exactly like the cert store;
* - the blob sits in an app-private `SharedPreferences` file (uninstall-wiped) and the app manifest
* sets `android:allowBackup="false"`, so it is **never** in a cloud/adb backup;
* - nothing here logs, and the token never enters a URL (see `AuthCookie`).
*
* Deliberately a SEPARATE keyset/pref-file/master-key from `:client-tls-android`'s `TinkCertStore`:
* different secrets with different lifecycles must not share an AEAD keyset (and this module must not
* gain a dependency on the TLS module). The ~20 lines of Tink setup are the price of that isolation.
*
* All disk + crypto work hops to [io] (never `Main`); the decrypted map is cached in memory after the
* first read so the per-request [tokenFor] on the WS-dial / REST path is a cheap map lookup, and a
* [Mutex] serializes the read-modify-write of a mutation. A blob that fails to decrypt/decode (tamper,
* version skew, a wiped Keystore key after a device restore) degrades to "no tokens" — the user
* re-enters the token — rather than throwing on every request.
*/
public class TinkAccessTokenStore(
context: Context,
private val io: CoroutineDispatcher = Dispatchers.IO,
private val keysetName: String = DEFAULT_KEYSET_NAME,
private val prefFileName: String = DEFAULT_PREF_FILE,
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
) : AccessTokenStore {
private val appContext: Context = context.applicationContext
private val mutex = Mutex()
/** Decrypted `origin → token` map; null until the first read. Replaced wholesale, never mutated. */
private var cache: Map<String, String>? = null
// Built on first use (registers AEAD, loads-or-creates the Keystore-wrapped keyset) — slow, so it
// only ever happens inside a withContext(io) block.
private val aead: Aead by lazy { buildAead() }
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
loadTokens()[endpoint.originHeader]
/**
* Validate → encrypt → durably persist → publish to the cache, in that order: a token the server
* could never accept returns false without touching disk, and the in-memory view is only updated
* after the write is on disk (so a failed write can never leave a "stored" token that vanishes on
* the next launch).
*
* @throws IOException when the durable write fails (infrastructure failure, distinct from `false`,
* which means the token itself was malformed).
*/
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
val normalized = AccessTokenRule.normalize(token) ?: return false
mutex.withLock {
val updated = readThrough() + (endpoint.originHeader to normalized)
persist(updated)
cache = updated
}
return true
}
override suspend fun remove(endpoint: HostEndpoint) {
mutex.withLock {
val current = readThrough()
if (!current.containsKey(endpoint.originHeader)) return // idempotent, no needless write
val updated = current - endpoint.originHeader
persist(updated)
cache = updated
}
}
private suspend fun loadTokens(): Map<String, String> =
cache ?: mutex.withLock {
val loaded = readThrough()
cache = loaded
loaded
}
/** Decrypt the stored blob (or the cache when warm). MUST be called with [mutex] held. */
private suspend fun readThrough(): Map<String, String> =
cache ?: withContext(io) { decodeBlob(prefs().getString(BLOB_KEY, null)) }
private suspend fun persist(tokens: Map<String, String>) {
withContext(io) {
val plaintext = JSON.encodeToString(TOKEN_MAP_SERIALIZER, tokens).encodeToByteArray()
val ciphertext = aead.encrypt(plaintext, ASSOCIATED_DATA)
val committed = prefs().edit()
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
.commit() // synchronous + reports failure (as TinkCertStore); apply() would hide it
if (!committed) throw IOException("failed to durably persist the access-token blob")
}
}
/** Any decrypt/decode failure ⇒ "no tokens" (never crash a request path on a corrupt blob). */
private fun decodeBlob(encoded: String?): Map<String, String> {
if (encoded == null) return emptyMap()
return runCatching {
val ciphertext = Base64.decode(encoded, Base64.NO_WRAP)
val plaintext = aead.decrypt(ciphertext, ASSOCIATED_DATA)
JSON.decodeFromString(TOKEN_MAP_SERIALIZER, plaintext.decodeToString())
}.getOrDefault(emptyMap())
}
private fun buildAead(): Aead {
AeadConfig.register()
val keysetHandle = AndroidKeysetManager.Builder()
.withSharedPref(appContext, keysetName, prefFileName)
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
.withMasterKeyUri(masterKeyUri)
.build()
.keysetHandle
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
}
private fun prefs(): SharedPreferences =
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
public companion object {
private const val DEFAULT_KEYSET_NAME = "webterm_access_token_keyset"
private const val DEFAULT_PREF_FILE = "webterm_access_token_prefs"
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_access_token_master_key"
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
private const val BLOB_KEY = "access_tokens_blob"
/** AEAD associated data — binds the ciphertext to this context so a lifted blob won't decrypt. */
private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.access-tokens".toByteArray()
private val JSON = Json { ignoreUnknownKeys = true }
private val TOKEN_MAP_SERIALIZER = MapSerializer(String.serializer(), String.serializer())
}
}

View File

@@ -0,0 +1,118 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
/**
* B5 · the per-host access-token store contract (ios-completion §1.1), exercised through the pure
* in-memory double (the Tink/AndroidKeyStore-backed implementation is instrumented → device QA):
* - a token is keyed by the endpoint's CANONICAL origin, so the same server dialed with a trailing
* slash / different case / a path resolves to the SAME token, and a different host never does;
* - a malformed token is REJECTED at the boundary and never stored;
* - `remove` is idempotent, and the store doubles as the `AccessTokenSource` the transports consume.
*/
class InMemoryAccessTokenStoreTest {
private companion object {
const val TOKEN = "0123456789abcdefTOKEN"
const val OTHER_TOKEN = "fedcba9876543210OTHER"
}
private fun endpoint(url: String): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
@Test
fun `a stored token is returned for the same host`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
assertTrue(store.put(host, TOKEN))
assertEquals(TOKEN, store.tokenFor(host))
}
@Test
fun `an unknown host has no token`() = runTest {
val store = InMemoryAccessTokenStore()
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertNull(store.tokenFor(endpoint("http://10.0.0.6:3000")))
}
@Test
fun `the key is the canonical origin so the same server matches however it was dialed`() = runTest {
val store = InMemoryAccessTokenStore()
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
assertEquals(TOKEN, store.tokenFor(endpoint("HTTP://10.0.0.5:3000")))
}
@Test
fun `a different port is a different host`() = runTest {
val store = InMemoryAccessTokenStore()
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertNull(store.tokenFor(endpoint("http://10.0.0.5:3001")))
}
@Test
fun `putting again replaces the token for that host only`() = runTest {
val store = InMemoryAccessTokenStore()
val a = endpoint("http://10.0.0.5:3000")
val b = endpoint("http://10.0.0.6:3000")
store.put(a, TOKEN)
store.put(b, OTHER_TOKEN)
store.put(a, OTHER_TOKEN)
assertEquals(OTHER_TOKEN, store.tokenFor(a))
assertEquals(OTHER_TOKEN, store.tokenFor(b))
}
@Test
fun `a malformed token is rejected and never stored`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
assertFalse(store.put(host, "short"), "below the server's 16-char minimum")
assertFalse(store.put(host, "has spaces in it here"), "outside the server charset")
assertNull(store.tokenFor(host), "a rejected token must not reach storage")
}
@Test
fun `a whitespace-padded token is trimmed once on the way in`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
store.put(host, " $TOKEN\n")
assertEquals(TOKEN, store.tokenFor(host))
}
@Test
fun `remove drops the token and is idempotent`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
store.put(host, TOKEN)
store.remove(host)
store.remove(host)
assertNull(store.tokenFor(host))
}
@Test
fun `the store is usable directly as the transports' AccessTokenSource`() = runTest {
val store: wang.yaojia.webterm.wire.AccessTokenSource = InMemoryAccessTokenStore(
initial = mapOf(endpoint("http://10.0.0.5:3000").originHeader to TOKEN),
)
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000")))
}
}