fix(android): make the terminal actually usable on a device

The client built to an APK and 617 JVM tests passed, but nothing had ever
run on hardware, and three defects made it unusable the moment it did. All
three were invisible to the JVM suite because none of those tests
instantiate an Android View.

1. Every key press crashed. `RemoteTerminalView` bound the forked emulator
   but never installed a `TerminalViewClient`, and the stock Termux view
   dereferences `mClient` with no null guard on the first line of the paths
   a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
   offset 0, `onKeyUp`, `onKeyPreIme`). Installing any client is not enough:
   returning false continues into `mTermSession.write()` at offset 150, and
   `mTermSession` is null forever because `TerminalSession` is final and
   forking a process is what this app must not do. So the client consumes
   the key itself and `KeyRouting` has no defer-to-stock branch at all —
   the crash is unrepresentable, not merely avoided. Termux's `KeyHandler`
   stays the authority for the key tables, so DECCKM still emits `ESC O A`.

2. The PTY never learned the real size. Nothing called
   `RemoteTerminalSession.updateSize`, and the stock fallback is dead
   (`TerminalView.updateSize` early-returns without a session), so every
   full-screen TUI rendered into 80x24. `TerminalResizeDriver` now drives
   it from real cell metrics — read through the public `getFontWidth()` /
   `getFontLineSpacing()` accessors rather than reflection, which would
   break silently under R8, or a re-measured Paint, which would drift from
   what the renderer actually draws (plan risk R5).

3. Bare-LAN connections were impossible. `network_security_config.xml` was
   a self-labelled STUB denying all cleartext while `HostEndpoint` accepts
   http and derives ws://. The format has no CIDR syntax, so the allowlist
   its own comment promised cannot be written; cleartext is permitted in
   base-config, the tunnel domain keeps a TLS block, trust anchors are
   pinned to system-only, and the guard moves to the §5.4 pairing tiers.

Adversarial review then found three more, and one it got wrong:

  - Swipe-scrolling inside an alternate-screen TUI still crashed. The touch
    path bypasses `TerminalViewClient` entirely: `doScroll` turns a scroll
    into `handleKeyCode` whenever the alternate buffer is active, and that
    dereferences `mTermSession`. Claude Code is an alternate-screen TUI, so
    this was a crash during normal use on the app's main screen.
    `TerminalScrollGesture` claims the vertical drag first and reproduces
    all three `doScroll` branches, closing the fling runnable and
    `onGenericMotionEvent` with it.
  - `autofill()` dereferences `mTermSession` unguarded while the view
    advertises itself autofillable, so any password manager crashed it.
    The subtree is excluded from the autofill structure.
  - The review asked for stock text selection to be restored. It cannot be:
    the ActionMode's own Copy and Paste handlers dereference the absent
    session, so a reachable selection UI has an NPE on its Copy button.
    Long press stays consumed and copy-out is recorded as a feature gap.

Also: the WEBTERM_TOKEN cookie is carried on REST and the WS upgrade and
sealed with Tink AEAD under an AndroidKeyStore key (fail-closed — a seal
failure persists nothing rather than falling back to plaintext), and
`allowBackup=false` keeps a 30-day shell credential off Google Drive.

Verified: ./gradlew test :app:assembleDebug -> 757 JVM tests, 0 failures.
Nothing here is device-verified yet; that is the next step.
This commit is contained in:
Yaojia Wang
2026-07-30 08:59:01 +02:00
parent c3613c2fae
commit 3e5cfdc1cf
48 changed files with 5719 additions and 85 deletions

View File

@@ -0,0 +1,217 @@
package wang.yaojia.webterm.hostregistry
import android.content.Context
import android.util.Base64
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
/**
* Instrumented (device) contract tests for [DataStoreAuthCookieStore] — the real
* Preferences-DataStore-backed store needs a Context, so these run on a device (no emulator in the
* build env → compiled here, executed in device QA, plan §7). They mirror the JVM
* [InMemoryAuthCookieStoreTest] contract and add the storage-only properties: the blob survives a
* genuine cold start, a record that expired while at rest is never handed back, and — the security
* one — the credential never appears in the DataStore FILE on disk, sealed or fail-closed.
*
* The at-rest encryption is driven through a reversible test cipher: this module has no Tink
* dependency by design (see [AuthCookieCipher]). The real `AndroidKeyStore`-backed cipher is tested
* instrumented in `:client-tls-android` (`TinkAuthCookieCipherTest`).
*
* ⚠ Every DataStore is created inside [onFile], which CANCELS the store's scope on the way out.
* `datastore-core` throws `IllegalStateException("There are multiple DataStores active for the same
* file")` if a second instance touches a file the first still holds, so a cold-start test MUST release
* the first one before opening the second.
*/
@RunWith(AndroidJUnit4::class)
class DataStoreAuthCookieStoreTest {
private val hostA = "http://10.0.0.1:3000"
private val hostB = "https://tunnel.example:443"
private fun context(): Context = ApplicationProvider.getApplicationContext()
private fun fileFor(name: String): File = context().preferencesDataStoreFile(name)
/**
* Opens a DataStore over [name], runs [block] against a store wired to [cipher] and [now], then
* cancels the DataStore's scope so the file is released for the next cold start.
*/
private fun <T> onFile(
name: String,
cipher: AuthCookieCipher = ReversibleTestCipher(),
now: () -> Long = { NOW },
block: suspend (AuthCookieStore) -> T,
): T = runBlocking {
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val dataStore: DataStore<Preferences> = PreferenceDataStoreFactory.create(
scope = scope,
produceFile = { fileFor(name) },
)
try {
block(
DataStoreAuthCookieStore(
dataStore = dataStore,
cipher = cipher,
onCipherFailure = {},
now = now,
),
)
} finally {
scope.cancel()
scope.coroutineContext[Job]!!.join()
}
}
private fun record(hostKey: String, name: String = "webterm_auth", expiresAt: Long = FAR_FUTURE) =
AuthCookieRecord(
hostKey = hostKey,
name = name,
value = SECRET,
domain = "10.0.0.1",
path = "/",
expiresAtEpochMillis = expiresAt,
httpOnly = true,
)
@Test
fun upsert_persists_and_loadForHost_reads_back() {
val a = record(hostA)
onFile(newFileName()) { store ->
assertEquals(listOf(a), store.upsert(a))
assertEquals(listOf(a), store.loadForHost(hostA))
assertEquals(emptyList<AuthCookieRecord>(), store.loadForHost(hostB))
}
}
@Test
fun the_blob_survives_a_recreated_store() {
// The cold-start path: a NEW store over the SAME (released) file must see the cookie.
val file = newFileName()
onFile(file) { it.upsert(record(hostA)) }
assertEquals(1, onFile(file) { it.loadForHost(hostA) }.size)
}
@Test
fun the_credential_never_appears_in_the_datastore_file() {
// Arrange + Act: write the cookie through the real DataStore, then read the FILE back.
val file = newFileName()
onFile(file) { it.upsert(record(hostA)) }
// Assert: the bytes on disk hold no recoverable form of the credential. This is the property
// that makes a rooted device, an `adb` pull, a cloud backup or a D2D transfer harmless.
val bytes = fileFor(file).readBytes()
assertTrue("the DataStore file was never written", bytes.isNotEmpty())
val onDisk = String(bytes, Charsets.ISO_8859_1)
assertFalse("the raw credential is on disk", onDisk.contains(SECRET))
assertFalse(
"the credential is on disk merely Base64'd",
onDisk.contains(Base64.encodeToString(SECRET.toByteArray(), Base64.NO_WRAP)),
)
}
@Test
fun remove_unknown_is_noop_and_removeHost_clears_one_host_only() {
onFile(newFileName()) { store ->
store.upsert(record(hostA))
store.upsert(record(hostB))
assertEquals(2, store.remove(record("http://nope:1").id).size)
assertEquals(listOf(hostB), store.removeHost(hostA).map { it.hostKey })
}
}
@Test
fun replaceHost_swaps_that_hosts_cookies_wholesale() {
onFile(newFileName()) { store ->
store.upsert(record(hostA, name = "stale"))
store.upsert(record(hostB))
val updated = store.replaceHost(hostA, listOf(record(hostA, name = "fresh")))
assertEquals(listOf("fresh"), updated.filter { it.hostKey == hostA }.map { it.name })
assertEquals(1, updated.count { it.hostKey == hostB })
}
}
@Test
fun a_record_that_expired_at_rest_is_never_returned() {
// Arrange: written while live, read back after the clock moved past its expiry.
val file = newFileName()
val expiresAt = 1_800_000_000_000L
onFile(file, now = { expiresAt - 1 }) { it.upsert(record(hostA, expiresAt = expiresAt)) }
// Act
val afterExpiry = onFile(file, now = { expiresAt + 1 }) { it.loadAll() }
// Assert
assertTrue("a stale shell credential must never be resurrected", afterExpiry.isEmpty())
}
@Test
fun nothing_is_persisted_when_the_cipher_is_unavailable() {
// Arrange + Act: the fail-closed path — a device whose keystore cannot seal.
val file = newFileName()
val updated = onFile(file, cipher = ReversibleTestCipher(failSeal = true)) {
it.upsert(record(hostA))
}
// Assert: usable in memory, absent from disk — never a plaintext fallback.
assertEquals(1, updated.size)
val onDisk = fileFor(file)
assertFalse(
"the credential was written despite the cipher failing",
onDisk.exists() && String(onDisk.readBytes(), Charsets.ISO_8859_1).contains(SECRET),
)
assertTrue(onFile(file) { it.loadAll() }.isEmpty())
}
/**
* Reversible stand-in for the Tink AEAD (XOR + Base64) — enough to exercise the store's
* seal/open/fail-closed paths without a Tink dependency, and confined to the test source set so it
* can never be wired into the app. The key is fixed so a cold-start store opens what the previous
* one sealed.
*/
private class ReversibleTestCipher(
private val failSeal: Boolean = false,
private val key: Byte = 0x2C,
) : AuthCookieCipher {
override fun seal(plaintext: String): String {
if (failSeal) throw IllegalStateException("test cipher: keystore unavailable")
return Base64.encodeToString(plaintext.toByteArray().xored(), Base64.NO_WRAP)
}
override fun open(sealed: String): String =
String(Base64.decode(sealed, Base64.NO_WRAP).xored())
private fun ByteArray.xored(): ByteArray =
ByteArray(size) { (this[it].toInt() xor key.toInt()).toByte() }
}
private companion object {
/** Distinctive enough that finding it on disk is unambiguous. */
const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11"
const val NOW = 1_700_000_000_000L
const val FAR_FUTURE = 4_000_000_000_000L
fun newFileName(): String = "cookies-${UUID.randomUUID()}"
}
}

View File

@@ -0,0 +1,47 @@
package wang.yaojia.webterm.hostregistry
/**
* At-rest encryption seam for the persisted `webterm_auth` cookie.
*
* WHY A SEAM AND NOT A CONCRETE CIPHER: the stored cookie is a **full-shell credential** and plan §8
* already requires the far-less-powerful device-cert chain to be Tink-AEAD encrypted under an
* `AndroidKeyStore` master key. The same treatment is therefore mandatory here — but the Tink +
* AndroidKeyStore implementation lives in `:client-tls-android`, and `:host-registry` must NOT gain
* that dependency (`android/README.md`: *dependencies only flow down; nothing points upward* — the two
* are siblings under `:app`). So the operation contract is declared HERE and implemented THERE, with
* `:app` bridging the two — exactly the precedent `:transport-okhttp`'s `ClientIdentityProvider` set
* for the mTLS material.
*
* ── Contract for implementations (security-load-bearing) ──────────────────────────────────────────
* - [seal] must produce a value the plaintext **cannot** be recovered from without a key that never
* leaves the device (i.e. real authenticated encryption — an encoding such as Base64 is NOT a
* cipher and is a contract violation).
* - [open] must be **authenticated**: a tampered, truncated or foreign blob must throw, never return
* attacker-shaped plaintext.
* - Failure is signalled by throwing. Both directions may fail (keystore unavailable, key lost after
* a restore-to-new-device, tamper) and [DataStoreAuthCookieStore] fails CLOSED on either — a seal
* failure drops persistence rather than falling back to plaintext.
* - Neither the plaintext nor any part of it may appear in an exception message, a log line or
* `toString()`. The plaintext handed to [seal] contains the credential.
*
* There is deliberately **no** in-`main` reversible/no-op implementation (unlike [InMemoryHostStore]
* and [InMemoryAuthCookieStore], which are harmless): a pass-through "cipher" reachable from DI would
* silently reinstate plaintext persistence. Test doubles live in test source sets only. When no real
* cipher can be built, bind [InMemoryAuthCookieStore] instead — memory-only persistence is the
* fail-closed answer (the user re-authenticates after a restart).
*/
public interface AuthCookieCipher {
/**
* Encrypt [plaintext] into an opaque, storable string.
*
* @throws Exception if encryption is unavailable — the caller then persists NOTHING.
*/
public fun seal(plaintext: String): String
/**
* Decrypt what [seal] produced.
*
* @throws Exception if [sealed] is not an intact blob this device's key can open.
*/
public fun open(sealed: String): String
}

View File

@@ -0,0 +1,86 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* At-rest DTO for an [AuthCookieRecord]. Separate from the domain type so the storage format can
* drift independently, and so nothing but this file ever serialises the credential.
*
* [toString] is redacted here too: a decode failure or a debug print of the intermediate list must
* not spill the cookie value.
*/
@Serializable
internal data class PersistedAuthCookie(
val hostKey: String,
val name: String,
val value: String,
val domain: String,
val path: String,
val expiresAtEpochMillis: Long,
val secure: Boolean = false,
val httpOnly: Boolean = false,
val hostOnly: Boolean = true,
) {
override fun toString(): String = "PersistedAuthCookie(hostKey=$hostKey, name=$name, value=<redacted>)"
}
/**
* Pure JSON codec for the persisted auth-cookie list (JVM-testable without a Context, mirrors
* [HostCodec]). Encode is total; decode is defensive — cookie records are UNTRUSTED at rest:
* - a corrupt/undecodable blob → empty list (start clean, never crash);
* - a record with a blank `hostKey`, `name` or `domain` → dropped (a record with no host scope
* could otherwise be replayed to the wrong host);
* - a record that expired while at rest → dropped, so a stale shell credential is never
* resurrected after a long background or a clock jump.
*/
internal object AuthCookieCodec {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
fun encode(records: List<AuthCookieRecord>): String =
json.encodeToString(records.map { it.toPersisted() })
fun decode(raw: String?, nowEpochMillis: Long): List<AuthCookieRecord> {
if (raw.isNullOrBlank()) return emptyList()
val persisted = try {
json.decodeFromString<List<PersistedAuthCookie>>(raw)
} catch (_: Exception) {
return emptyList() // corrupted blob at rest → start clean, never crash
}
return persisted.mapNotNull { it.toRecordOrNull() }.droppingExpired(nowEpochMillis)
}
private fun AuthCookieRecord.toPersisted(): PersistedAuthCookie =
PersistedAuthCookie(
hostKey = hostKey,
name = name,
value = value,
domain = domain,
path = path,
expiresAtEpochMillis = expiresAtEpochMillis,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
)
private fun PersistedAuthCookie.toRecordOrNull(): AuthCookieRecord? {
if (hostKey.isBlank() || name.isBlank() || domain.isBlank()) return null
return AuthCookieRecord(
hostKey = hostKey,
name = name,
value = value,
domain = domain,
path = path,
expiresAtEpochMillis = expiresAtEpochMillis,
secure = secure,
httpOnly = httpOnly,
hostOnly = hostOnly,
)
}
}

View File

@@ -0,0 +1,51 @@
package wang.yaojia.webterm.hostregistry
/**
* One persisted session cookie, scoped to exactly one paired host.
*
* Background: when the server runs with `WEBTERM_TOKEN` set (`src/http/auth.ts`), pairing yields a
* `webterm_auth` cookie and every later remote HTTP route — plus the WS upgrade — requires it.
* `:transport-okhttp`'s `AuthCookieJar` owns the live cookie semantics; this module only makes the
* cookie survive a process restart.
*
* - [hostKey] is the isolation key produced by `authCookieHostKey` (`scheme://host:port`). It is the
* reason a record can never be replayed to a different host: reads go through [forHost].
* - [expiresAtEpochMillis] is ABSOLUTE wall-clock milliseconds, never a `Max-Age` duration —
* persisting a duration would restart the credential's lifetime on every load.
* - [value] is a **shell credential**. [toString] is redacted so it can never land in a log, a crash
* report or a debugger dump of a surrounding data class.
*/
public data class AuthCookieRecord(
val hostKey: String,
val name: String,
val value: String,
val domain: String,
val path: String,
val expiresAtEpochMillis: Long,
val secure: Boolean = false,
val httpOnly: Boolean = false,
val hostOnly: Boolean = true,
) {
/** Storage identity: our host scope plus RFC 6265's (domain, path, name) cookie key. */
val id: AuthCookieId
get() = AuthCookieId(hostKey = hostKey, domain = domain, path = path, name = name)
/** True iff the record is still live at [nowEpochMillis] (expiry is inclusive → already dead). */
public fun isLiveAt(nowEpochMillis: Long): Boolean = expiresAtEpochMillis > nowEpochMillis
override fun toString(): String =
"AuthCookieRecord(hostKey=$hostKey, name=$name, value=<redacted>, domain=$domain, path=$path, " +
"expiresAtEpochMillis=$expiresAtEpochMillis, secure=$secure, httpOnly=$httpOnly, hostOnly=$hostOnly)"
}
/**
* The composite identity of a stored cookie. A value class rather than a joined string on purpose:
* cookie names and paths may legally contain the separator characters a string key would need, and a
* collision there would let one record silently overwrite another.
*/
public data class AuthCookieId(
val hostKey: String,
val domain: String,
val path: String,
val name: String,
)

View File

@@ -0,0 +1,74 @@
package wang.yaojia.webterm.hostregistry
/**
* Durable per-host storage for the `webterm_auth` session cookie (plan §3 :host-registry, server
* `src/http/auth.ts`). Implementations: [DataStoreAuthCookieStore] (real) and
* [InMemoryAuthCookieStore] (in-`main` double, exactly like [InMemoryHostStore]).
*
* Same immutable style as [HostStore]: every mutation RETURNS the new collection instead of mutating
* shared state, upsert preserves position, and an unknown id is an explicit no-op (never a throw).
*
* Host isolation is part of the contract, not a convention: [loadForHost] must never return a record
* filed under a different `hostKey`, and [replaceHost] must never file another host's record under
* the host being replaced. The stored value is a shell credential.
*/
public interface AuthCookieStore {
/** Every stored cookie across all hosts, in stored (insertion) order. */
public suspend fun loadAll(): List<AuthCookieRecord>
/** Only [hostKey]'s cookies — the cold-start hydration read. Unknown host → empty list. */
public suspend fun loadForHost(hostKey: String): List<AuthCookieRecord>
/** Insert, or replace the record with the same [AuthCookieRecord.id] in place. Returns the new list. */
public suspend fun upsert(record: AuthCookieRecord): List<AuthCookieRecord>
/** Remove one record. Unknown [id] is a no-op returning the unchanged collection. */
public suspend fun remove(id: AuthCookieId): List<AuthCookieRecord>
/** Forget everything stored for [hostKey] (unpair / sign-out). Other hosts are untouched. */
public suspend fun removeHost(hostKey: String): List<AuthCookieRecord>
/**
* Swap [hostKey]'s cookies for [records] wholesale — the write the OkHttp cookie jar drives
* ("this is host X's cookie set now"). An empty [records] clears the host. Records whose
* `hostKey` differs are DROPPED rather than trusted.
*/
public suspend fun replaceHost(hostKey: String, records: List<AuthCookieRecord>): List<AuthCookieRecord>
}
// ── Pure immutable transforms shared by every AuthCookieStore (DRY, mirrors HostStore.kt) ─────────
// Never mutate the receiver — always return a fresh list.
/** Insert [record], or replace the entry with the same id IN PLACE (position preserved). */
internal fun List<AuthCookieRecord>.upserting(record: AuthCookieRecord): List<AuthCookieRecord> =
if (any { it.id == record.id }) {
map { if (it.id == record.id) record else it }
} else {
this + record
}
/** A new list without the record whose id equals [id]. Unknown id → unchanged contents (no-op). */
internal fun List<AuthCookieRecord>.removing(id: AuthCookieId): List<AuthCookieRecord> =
filter { it.id != id }
/** Only the records belonging to [hostKey] — THE cross-host leak guard on the read path. */
internal fun List<AuthCookieRecord>.forHost(hostKey: String): List<AuthCookieRecord> =
filter { it.hostKey == hostKey }
/** A new list with every record of [hostKey] removed; other hosts untouched. */
internal fun List<AuthCookieRecord>.removingHost(hostKey: String): List<AuthCookieRecord> =
filter { it.hostKey != hostKey }
/**
* A new list where [hostKey]'s records are exactly [records] (appended after the surviving records
* of other hosts). Records carrying a different `hostKey` are dropped — the write path's half of the
* cross-host leak guard.
*/
internal fun List<AuthCookieRecord>.replacingHost(
hostKey: String,
records: List<AuthCookieRecord>,
): List<AuthCookieRecord> = removingHost(hostKey) + records.forHost(hostKey)
/** A new list without records that already expired at [nowEpochMillis]. */
internal fun List<AuthCookieRecord>.droppingExpired(nowEpochMillis: Long): List<AuthCookieRecord> =
filter { it.isLiveAt(nowEpochMillis) }

View File

@@ -0,0 +1,123 @@
package wang.yaojia.webterm.hostregistry
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
/**
* Preferences-DataStore-backed [AuthCookieStore]. The whole cookie list is ONE JSON string
* ([AuthCookieCodec]) which is then **[AuthCookieCipher.seal]ed** before it is written, so the single
* stored value under [SEALED_COOKIES_KEY] is authenticated ciphertext, never the credential.
*
* ## Storage posture (plan §8)
* The stored `webterm_auth` cookie is a **full-shell credential** the server keeps alive for 30 days
* (`Max-Age=2592000`, `src/http/auth.ts`). Preferences DataStore is app-private but plaintext, and
* app-private is not a secrecy boundary against a rooted device, an `adb` pull of a debuggable build,
* a cloud backup or a device-to-device transfer. Plan §8 already requires the far weaker device-cert
* chain to be Tink-AEAD encrypted under an `AndroidKeyStore` master key, so this blob gets the SAME
* mechanism, injected through the [AuthCookieCipher] seam (`:client-tls-android` implements it; see
* that interface for why it is a seam and not a direct dependency). `android:allowBackup="false"` in
* the app manifest is the second, independent layer.
*
* ## Fail CLOSED, never fall back to plaintext
* If the cipher cannot seal (keystore unavailable, key lost after a restore onto a new device) the
* write persists **nothing** and additionally DELETES any previously stored blob, then reports the
* failure through [onCipherFailure]. The cost is that the user re-authenticates after a restart; the
* alternative — writing the credential in the clear — is the exact thing this class exists to prevent.
* A blob that fails to open is treated as "no stored cookies" for the same reason.
*
* The [DataStore] is injected (constructed from a Context in `:app` DI) so this class has no
* Android-framework surface of its own; the on-device behaviour is verified instrumented (androidTest)
* and the at-rest properties on the JVM (`DataStoreAuthCookieStoreCryptoTest`).
*
* @param cipher at-rest encryption for the serialized cookie list. Required — there is deliberately
* no default, so no wiring can end up persisting plaintext by omission.
* @param onCipherFailure invoked when a seal/open fails, i.e. when a credential was dropped instead of
* stored or restored. Never handed the cookie value. Callers must not log the [Throwable]'s payload
* beyond its type/message (the [AuthCookieCipher] contract keeps plaintext out of both), and must not
* throw — it runs inside the DataStore write transaction, so a throwing reporter would abort the write.
* @param now wall-clock source, injected so expiry-at-rest is testable.
*/
public class DataStoreAuthCookieStore(
private val dataStore: DataStore<Preferences>,
private val cipher: AuthCookieCipher,
private val onCipherFailure: (Throwable) -> Unit,
private val now: () -> Long = System::currentTimeMillis,
) : AuthCookieStore {
override suspend fun loadAll(): List<AuthCookieRecord> = decode(dataStore.data.first()[SEALED_COOKIES_KEY])
override suspend fun loadForHost(hostKey: String): List<AuthCookieRecord> =
loadAll().forHost(hostKey)
override suspend fun upsert(record: AuthCookieRecord): List<AuthCookieRecord> =
writeTransform { it.upserting(record) }
override suspend fun remove(id: AuthCookieId): List<AuthCookieRecord> =
writeTransform { it.removing(id) }
override suspend fun removeHost(hostKey: String): List<AuthCookieRecord> =
writeTransform { it.removingHost(hostKey) }
override suspend fun replaceHost(
hostKey: String,
records: List<AuthCookieRecord>,
): List<AuthCookieRecord> = writeTransform { it.replacingHost(hostKey, records) }
private suspend fun writeTransform(
transform: (List<AuthCookieRecord>) -> List<AuthCookieRecord>,
): List<AuthCookieRecord> {
lateinit var updated: List<AuthCookieRecord>
dataStore.edit { prefs ->
// Decode drops expired records, so every write also garbage-collects dead credentials.
updated = transform(decode(prefs[SEALED_COOKIES_KEY]))
val sealed = if (updated.isEmpty()) null else seal(AuthCookieCodec.encode(updated))
// sealed == null means either "nothing left to store" or "sealing failed" — both write
// NOTHING and drop the previous blob, so no unencrypted or superseded credential lingers.
if (sealed == null) prefs.remove(SEALED_COOKIES_KEY) else prefs[SEALED_COOKIES_KEY] = sealed
prefs.purgeLegacyPlaintext()
}
return updated
}
/** Open + decode, dropping anything expired. An unopenable blob reads as "no cookies" (fail closed). */
private fun decode(sealed: String?): List<AuthCookieRecord> {
if (sealed.isNullOrBlank()) return emptyList()
val json = try {
cipher.open(sealed)
} catch (e: Exception) {
onCipherFailure(e)
return emptyList()
}
return AuthCookieCodec.decode(json, now())
}
/** Encrypt, or null when the cipher is unavailable — the caller then persists nothing. */
private fun seal(json: String): String? =
try {
cipher.seal(json)
} catch (e: Exception) {
onCipherFailure(e)
null
}
/**
* Belt-and-braces: an early build of this store wrote the cookie list to [LEGACY_PLAINTEXT_KEY]
* unencrypted. That key is never read, and is removed by the first write, so a plaintext credential
* cannot outlive the upgrade on a device that ran such a build.
*/
private fun MutablePreferences.purgeLegacyPlaintext() {
remove(LEGACY_PLAINTEXT_KEY) // absent → no-op, and DataStore skips the write when nothing changed
}
private companion object {
/** The one stored value: `AuthCookieCipher.seal(AuthCookieCodec.encode(records))`. */
val SEALED_COOKIES_KEY = stringPreferencesKey("authCookiesSealed")
/** Pre-encryption key — write-purged, never read. Do not reuse this name. */
val LEGACY_PLAINTEXT_KEY = stringPreferencesKey("authCookies")
}
}

View File

@@ -0,0 +1,48 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* In-memory [AuthCookieStore]. Lives in `main` (not `test`) on purpose — it stands in for the
* DataStore store in this module's contract tests AND in the AW4 wiring/ViewModel tests, exactly
* like [InMemoryHostStore].
*
* A [Mutex] serialises each read-modify-write; the state is a value snapshot replaced wholesale on
* every change (no in-place mutation of a shared reference).
*/
public class InMemoryAuthCookieStore(
initial: List<AuthCookieRecord> = emptyList(),
) : AuthCookieStore {
private val mutex = Mutex()
// Defensive copy so an external mutable list handed to the ctor can't alias our state.
private var records: List<AuthCookieRecord> = initial.toList()
override suspend fun loadAll(): List<AuthCookieRecord> = mutex.withLock { records }
override suspend fun loadForHost(hostKey: String): List<AuthCookieRecord> =
mutex.withLock { records.forHost(hostKey) }
override suspend fun upsert(record: AuthCookieRecord): List<AuthCookieRecord> =
write { it.upserting(record) }
override suspend fun remove(id: AuthCookieId): List<AuthCookieRecord> =
write { it.removing(id) }
override suspend fun removeHost(hostKey: String): List<AuthCookieRecord> =
write { it.removingHost(hostKey) }
override suspend fun replaceHost(
hostKey: String,
records: List<AuthCookieRecord>,
): List<AuthCookieRecord> = write { it.replacingHost(hostKey, records) }
private suspend fun write(
transform: (List<AuthCookieRecord>) -> List<AuthCookieRecord>,
): List<AuthCookieRecord> = mutex.withLock {
val updated = transform(records)
records = updated
updated
}
}

View File

@@ -0,0 +1,98 @@
package wang.yaojia.webterm.hostregistry
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* At-rest encoding for the auth-cookie list. Cookie records are UNTRUSTED at rest (same posture as
* `HostCodec`): a corrupt blob starts clean, a structurally invalid record is dropped, and an
* already-expired record is dropped on read so a stale shell credential is never resurrected after
* a long background.
*/
class AuthCookieCodecTest {
private fun record(
hostKey: String = "http://192.168.1.5:3000",
name: String = "webterm_auth",
expiresAtEpochMillis: Long = FAR_FUTURE,
) = AuthCookieRecord(
hostKey = hostKey,
name = name,
value = TOKEN,
domain = "192.168.1.5",
path = "/",
expiresAtEpochMillis = expiresAtEpochMillis,
secure = false,
httpOnly = true,
hostOnly = true,
)
@Test
fun roundTripsEveryFieldOfALiveRecord() {
// Arrange
val original = listOf(record(), record(hostKey = "https://tunnel.example:443", name = "other"))
// Act
val decoded = AuthCookieCodec.decode(AuthCookieCodec.encode(original), NOW)
// Assert
assertEquals(original, decoded)
}
@Test
fun decodingACorruptBlobStartsClean() {
assertEquals(emptyList<AuthCookieRecord>(), AuthCookieCodec.decode("{not json", NOW))
}
@Test
fun decodingAnAbsentOrBlankBlobIsEmpty() {
assertEquals(emptyList<AuthCookieRecord>(), AuthCookieCodec.decode(null, NOW))
assertEquals(emptyList<AuthCookieRecord>(), AuthCookieCodec.decode(" ", NOW))
}
@Test
fun dropsARecordWithABlankHostKeyOrName() {
// Arrange: a record with no host key could otherwise be replayed to ANY host.
val raw = AuthCookieCodec.encode(listOf(record(hostKey = ""), record(name = ""), record()))
// Act
val decoded = AuthCookieCodec.decode(raw, NOW)
// Assert
assertEquals(1, decoded.size)
assertEquals("http://192.168.1.5:3000", decoded.single().hostKey)
}
@Test
fun dropsARecordThatExpiredWhileAtRest() {
// Arrange
val raw = AuthCookieCodec.encode(listOf(record(expiresAtEpochMillis = NOW), record(name = "live")))
// Act
val decoded = AuthCookieCodec.decode(raw, NOW)
// Assert
assertEquals(listOf("live"), decoded.map { it.name })
}
@Test
fun theEncodedBlobIsTheOnlyPlaceTheCredentialAppears() {
// Arrange
val records = listOf(record())
// Act
val rendered = records.toString() + records.single().toString() + AuthCookieCodec.toString()
// Assert: the blob must hold the value (it IS the persisted credential), but no toString may.
assertTrue(AuthCookieCodec.encode(records).contains(TOKEN))
assertFalse(rendered.contains(TOKEN), "credential leaked into a toString path: $rendered")
}
private companion object {
const val TOKEN = "s3cr3t-webterm-token-value"
const val NOW = 1_800_000_000_000L
const val FAR_FUTURE = 4_000_000_000_000L
}
}

View File

@@ -0,0 +1,172 @@
package wang.yaojia.webterm.hostregistry
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The pure immutable list transforms behind every [AuthCookieStore] — same discipline as the
* `HostStore` transforms: never mutate the receiver, upsert preserves position, an unknown id is
* an explicit no-op. Host isolation is a transform-level property here: `forHost` NEVER returns a
* record filed under a different host key (a shell credential — a cross-host leak is a security
* bug), and `removingHost` never touches another host's records.
*/
class AuthCookieStoreTransformsTest {
private val hostA = "http://192.168.1.5:3000"
private val hostB = "https://h7fd8.terminal.yaojia.wang:443"
private fun record(
hostKey: String,
name: String = "webterm_auth",
value: String = "token-$hostKey",
path: String = "/",
expiresAtEpochMillis: Long = FAR_FUTURE,
) = AuthCookieRecord(
hostKey = hostKey,
name = name,
value = value,
domain = hostKey.substringAfter("://").substringBefore(":"),
path = path,
expiresAtEpochMillis = expiresAtEpochMillis,
)
@Test
fun upsertingAppendsAnUnknownRecordAndLeavesTheReceiverUntouched() {
// Arrange
val original = listOf(record(hostA))
// Act
val updated = original.upserting(record(hostB))
// Assert
assertEquals(1, original.size, "the receiver must never be mutated")
assertEquals(listOf(hostA, hostB), updated.map { it.hostKey })
assertNotSame(original, updated)
}
@Test
fun upsertingReplacesTheSameIdInPlacePreservingPosition() {
// Arrange
val original = listOf(record(hostA, value = "old"), record(hostB))
// Act
val updated = original.upserting(record(hostA, value = "new"))
// Assert
assertEquals(listOf(hostA, hostB), updated.map { it.hostKey })
assertEquals("new", updated.first().value)
assertEquals("old", original.first().value)
}
@Test
fun aDifferentCookieNameOnTheSameHostIsASeparateRecord() {
// Arrange
val original = listOf(record(hostA, name = "webterm_auth"))
// Act
val updated = original.upserting(record(hostA, name = "other"))
// Assert: identity is (hostKey, domain, path, name) — RFC 6265's key plus our host scope.
assertEquals(2, updated.size)
}
@Test
fun removingAnUnknownIdIsANoOp() {
// Arrange
val original = listOf(record(hostA))
// Act
val updated = original.removing(record(hostB).id)
// Assert
assertEquals(original, updated)
}
@Test
fun removingDropsOnlyTheMatchingRecord() {
// Arrange
val target = record(hostA)
val original = listOf(target, record(hostB))
// Act
val updated = original.removing(target.id)
// Assert
assertEquals(listOf(hostB), updated.map { it.hostKey })
assertEquals(2, original.size)
}
@Test
fun forHostReturnsOnlyThatHostsRecords() {
// Arrange
val original = listOf(record(hostA), record(hostB), record(hostA, name = "second"))
// Act
val forA = original.forHost(hostA)
// Assert
assertTrue(forA.all { it.hostKey == hostA }, "a host's cookies must never include another host's")
assertEquals(2, forA.size)
}
@Test
fun removingHostClearsOneHostAndLeavesTheOtherIntact() {
// Arrange
val original = listOf(record(hostA), record(hostB), record(hostA, name = "second"))
// Act
val updated = original.removingHost(hostA)
// Assert
assertEquals(listOf(hostB), updated.map { it.hostKey })
}
@Test
fun replacingHostSwapsOneHostsRecordsWholesale() {
// Arrange
val original = listOf(record(hostA, value = "stale"), record(hostB))
// Act
val updated = original.replacingHost(hostA, listOf(record(hostA, value = "fresh")))
// Assert
assertEquals(setOf(hostA, hostB), updated.map { it.hostKey }.toSet())
assertEquals("fresh", updated.single { it.hostKey == hostA }.value)
}
@Test
fun replacingHostRejectsRecordsFiledUnderADifferentHost() {
// Arrange: a mis-wired caller must not be able to smuggle host B's cookie under host A.
val original = listOf(record(hostB))
// Act
val updated = original.replacingHost(hostA, listOf(record(hostB, value = "smuggled")))
// Assert
assertFalse(
updated.any { it.hostKey == hostB && it.value == "smuggled" },
"replacingHost must drop records whose hostKey != the host being replaced",
)
}
@Test
fun droppingExpiredKeepsOnlyLiveRecords() {
// Arrange
val live = record(hostA, expiresAtEpochMillis = NOW + 1)
val expired = record(hostB, expiresAtEpochMillis = NOW)
// Act
val updated = listOf(live, expired).droppingExpired(NOW)
// Assert: expiry is inclusive — at the instant of expiry the cookie is already dead.
assertEquals(listOf(live), updated)
}
private companion object {
const val NOW = 1_800_000_000_000L
const val FAR_FUTURE = 4_000_000_000_000L
}
}

View File

@@ -0,0 +1,292 @@
package wang.yaojia.webterm.hostregistry
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.preferencesOf
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
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.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
/**
* The AT-REST posture of [DataStoreAuthCookieStore] — what actually lands in the backing store.
*
* The persisted `webterm_auth` cookie is a **full-shell credential** with a 30-day server-side
* `Max-Age` (`src/http/auth.ts`), so plan §8's "encrypted, never in the clear" rule for the far
* weaker device-cert chain applies to it at least as strongly. These are the tests that would go red
* if the store ever wrote the value where a rooted device, an `adb` pull of a debug build, a cloud
* backup or a device-to-device transfer could read it.
*
* These run on the JVM (no Context) against a recording [DataStore] double, so the exact stored
* `Preferences` can be inspected byte-for-byte. The real Tink/AndroidKeyStore cipher is exercised
* instrumented in `:client-tls-android` (`TinkAuthCookieCipherTest`), and the DataStore file itself is
* checked on-device in `DataStoreAuthCookieStoreTest`.
*/
@DisplayName("DataStoreAuthCookieStore — at-rest encryption")
class DataStoreAuthCookieStoreCryptoTest {
private val hostA = "http://192.168.1.5:3000"
private val hostB = "https://tunnel.example:443"
private fun record(
hostKey: String = hostA,
name: String = "webterm_auth",
value: String = SECRET,
expiresAt: Long = FAR_FUTURE,
) = AuthCookieRecord(
hostKey = hostKey,
name = name,
value = value,
domain = "192.168.1.5",
path = "/",
expiresAtEpochMillis = expiresAt,
httpOnly = true,
)
// ── The properties that matter ────────────────────────────────────────────────────────────────
@Test
@DisplayName("the cookie value never lands in the backing store in any recoverable form")
fun theCookieValueIsNeverStoredRecoverably() = runTest {
// Arrange
val dataStore = RecordingPreferencesDataStore()
val store = newStore(dataStore)
// Act
store.upsert(record())
// Assert: nothing anywhere in the persisted Preferences reveals the credential — not raw, not
// Base64'd, not hex, not percent-encoded (a "cipher" that merely re-encodes must fail here).
val onDisk = dataStore.dump()
assertTrue(onDisk.isNotEmpty(), "the store wrote nothing at all — the round-trip test guards that")
recoverableFormsOf(SECRET).forEach { form ->
assertFalse(
onDisk.contains(form),
"a shell credential leaked into the backing store as <$form>: $onDisk",
)
}
}
@Test
@DisplayName("what is stored is the sealed blob, and it round-trips back through the cipher")
fun theSealedBlobRoundTrips() = runTest {
// Arrange
val dataStore = RecordingPreferencesDataStore()
val cipher = ReversibleTestCipher()
val a = record()
val b = record(hostKey = hostB, value = "other-host-token")
// Act
newStore(dataStore, cipher).upsert(a)
newStore(dataStore, cipher).upsert(b)
val reloaded = newStore(dataStore, cipher)
// Assert: a cold-start store over the same bytes sees both cookies, each on its own host.
assertEquals(listOf(a, b), reloaded.loadAll())
assertEquals(listOf(a), reloaded.loadForHost(hostA))
// ...and the ciphertext is an encryption OF the codec's JSON, not a per-field mangling.
assertEquals(AuthCookieCodec.encode(listOf(a, b)), cipher.open(dataStore.sealedBlob()!!))
}
@Test
@DisplayName("fail closed: when the cipher cannot seal, NOTHING is persisted (no plaintext fallback)")
fun aSealFailurePersistsNothing() = runTest {
// Arrange
val dataStore = RecordingPreferencesDataStore()
val failures = mutableListOf<Throwable>()
val store = newStore(
dataStore,
ReversibleTestCipher(failSeal = true),
onCipherFailure = { failures += it },
)
// Act
val updated = store.upsert(record())
// Assert: the in-memory answer is still correct (this process keeps working)...
assertEquals(listOf(record()), updated)
// ...but the credential never reached the disk, and the failure was reported, not swallowed.
assertEquals(emptyMap<Preferences.Key<*>, Any>(), dataStore.current().asMap())
assertEquals(1, failures.size, "a dropped persistence must be reported to the wiring")
}
@Test
@DisplayName("fail closed: a previously sealed blob is DELETED when sealing starts failing")
fun aSealFailureAlsoDropsTheStaleBlob() = runTest {
// Arrange: a good cipher persisted host A's cookie...
val dataStore = RecordingPreferencesDataStore()
val cipher = ReversibleTestCipher()
newStore(dataStore, cipher).upsert(record())
assertTrue(dataStore.sealedBlob() != null, "arrange failed: nothing was sealed")
// Act: ...then the keystore breaks (open still works, seal does not) and the set changes.
newStore(dataStore, ReversibleTestCipher(failSeal = true, failOpen = false, key = cipher.key))
.replaceHost(hostA, listOf(record(value = "rotated")))
// Assert: the superseded blob is gone rather than left behind as a resurrectable credential.
assertEquals(null, dataStore.sealedBlob())
}
@Test
@DisplayName("an unreadable blob (tamper / key lost with the device) reads as no cookies")
fun anUnreadableBlobYieldsNoCookies() = runTest {
// Arrange: sealed by a cipher whose key this store does not have.
val dataStore = RecordingPreferencesDataStore()
newStore(dataStore, ReversibleTestCipher(key = 0x5A)).upsert(record())
val failures = mutableListOf<Throwable>()
val store = newStore(
dataStore,
ReversibleTestCipher(failOpen = true),
onCipherFailure = { failures += it },
)
// Act
val loaded = store.loadAll()
// Assert
assertEquals(emptyList<AuthCookieRecord>(), loaded)
assertEquals(1, failures.size, "an unopenable credential blob must be reported")
}
@Test
@DisplayName("a legacy PLAINTEXT blob is never read, and is deleted by the next write")
fun aLegacyPlaintextBlobIsPurged() = runTest {
// Arrange: what a pre-encryption build would have left behind under the old key.
val legacyKey = stringPreferencesKey("authCookies")
val dataStore = RecordingPreferencesDataStore(
preferencesOf(legacyKey to AuthCookieCodec.encode(listOf(record()))),
)
val store = newStore(dataStore)
// Act + Assert: never read...
assertEquals(emptyList<AuthCookieRecord>(), store.loadAll())
// ...and the plaintext is gone after one write, so it cannot outlive the upgrade.
store.upsert(record(value = "fresh"))
assertEquals(null, dataStore.current()[legacyKey])
assertFalse(dataStore.dump().contains(SECRET), "the legacy plaintext survived: ${dataStore.dump()}")
}
@Test
@DisplayName("clearing the last cookie removes the blob instead of storing an empty one")
fun clearingRemovesTheBlob() = runTest {
// Arrange
val dataStore = RecordingPreferencesDataStore()
val cipher = ReversibleTestCipher()
val store = newStore(dataStore, cipher)
store.upsert(record())
// Act
store.removeHost(hostA)
// Assert
assertEquals(null, dataStore.sealedBlob())
}
@Test
@DisplayName("a record that expired at rest is dropped on read and garbage-collected on write")
fun expiryAtRestStillApplies() = runTest {
// Arrange: sealed while live, read back after the clock passed its expiry.
val dataStore = RecordingPreferencesDataStore()
val cipher = ReversibleTestCipher()
val expiresAt = 1_800_000_000_000L
newStore(dataStore, cipher, now = { expiresAt - 1 }).upsert(record(expiresAt = expiresAt))
// Act
val afterExpiry = newStore(dataStore, cipher, now = { expiresAt + 1 })
// Assert
assertEquals(emptyList<AuthCookieRecord>(), afterExpiry.loadAll())
assertEquals(
listOf(hostB),
afterExpiry.upsert(record(hostKey = hostB)).map { it.hostKey },
"a write must also garbage-collect the expired credential, not just hide it on read",
)
}
// ── Doubles ──────────────────────────────────────────────────────────────────────────────────
private fun newStore(
dataStore: DataStore<Preferences>,
cipher: AuthCookieCipher = ReversibleTestCipher(),
now: () -> Long = { NOW },
onCipherFailure: (Throwable) -> Unit = {},
) = DataStoreAuthCookieStore(
dataStore = dataStore,
cipher = cipher,
onCipherFailure = onCipherFailure,
now = now,
)
/**
* In-memory [DataStore] that keeps the exact [Preferences] the store wrote, so a test can inspect
* every key and value. Not a cipher test double — the real DataStore is byte-identical in what it
* is HANDED, which is the only thing these tests assert about.
*/
private class RecordingPreferencesDataStore(
initial: Preferences = emptyPreferences(),
) : DataStore<Preferences> {
private val state = MutableStateFlow(initial)
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
val updated = transform(state.value)
state.value = updated
return updated
}
fun current(): Preferences = state.value
fun sealedBlob(): String? = state.value[stringPreferencesKey("authCookiesSealed")]
/** Every key AND value as one haystack — catches a leak under ANY key, not just the known one. */
fun dump(): String = state.value.asMap().entries.joinToString(";") { "${it.key.name}=${it.value}" }
}
/**
* A reversible stand-in for the real Tink AEAD: XOR under a byte key, then Base64. Strong enough
* for what these tests assert (the stored form is neither the plaintext nor an encoding of it, and
* a different key cannot open it) and weak on purpose — it lives in the TEST source set so it can
* never be wired into the app. [failSeal]/[failOpen] drive the fail-closed paths.
*/
private class ReversibleTestCipher(
private val failSeal: Boolean = false,
private val failOpen: Boolean = false,
val key: Byte = 0x2C,
) : AuthCookieCipher {
override fun seal(plaintext: String): String {
if (failSeal) throw IllegalStateException("test cipher: keystore unavailable")
return java.util.Base64.getEncoder().encodeToString(plaintext.toByteArray().xored())
}
override fun open(sealed: String): String {
if (failOpen) throw IllegalStateException("test cipher: blob failed authentication")
return String(java.util.Base64.getDecoder().decode(sealed).xored())
}
private fun ByteArray.xored(): ByteArray = ByteArray(size) { (this[it].toInt() xor key.toInt()).toByte() }
}
private companion object {
/** Distinctive so a leak is unambiguous, and long enough that a partial match is meaningful. */
const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11"
const val NOW = 1_700_000_000_000L
const val FAR_FUTURE = 4_000_000_000_000L
/** Every encoding a naive "protection" might leave the credential recoverable through. */
fun recoverableFormsOf(secret: String): List<String> = listOf(
secret,
java.util.Base64.getEncoder().encodeToString(secret.toByteArray()),
java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(secret.toByteArray()),
secret.toByteArray().joinToString("") { "%02x".format(it) },
java.net.URLEncoder.encode(secret, "UTF-8"),
)
}
}

View File

@@ -0,0 +1,124 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The [AuthCookieStore] contract, exercised through the in-memory double that ships in `main`
* (it doubles for the DataStore store in the AW4 wiring tests, exactly like [InMemoryHostStore]).
*/
class InMemoryAuthCookieStoreTest {
private val hostA = "http://192.168.1.5:3000"
private val hostB = "https://tunnel.example:443"
private fun record(hostKey: String, name: String = "webterm_auth", value: String = "v") =
AuthCookieRecord(
hostKey = hostKey,
name = name,
value = value,
domain = "example",
path = "/",
expiresAtEpochMillis = FAR_FUTURE,
)
@Test
fun upsertReturnsTheNewListAndIsReadableBack() = runTest {
// Arrange
val store = InMemoryAuthCookieStore()
// Act
val afterFirst = store.upsert(record(hostA))
val afterSecond = store.upsert(record(hostB))
// Assert
assertEquals(1, afterFirst.size)
assertEquals(listOf(hostA, hostB), afterSecond.map { it.hostKey })
assertEquals(afterSecond, store.loadAll())
}
@Test
fun upsertReplacesTheSameRecordInPlace() = runTest {
// Arrange
val store = InMemoryAuthCookieStore(listOf(record(hostA, value = "old"), record(hostB)))
// Act
val updated = store.upsert(record(hostA, value = "new"))
// Assert
assertEquals(listOf(hostA, hostB), updated.map { it.hostKey })
assertEquals("new", updated.first().value)
}
@Test
fun loadForHostNeverLeaksAnotherHostsCookie() = runTest {
// Arrange
val store = InMemoryAuthCookieStore(listOf(record(hostA), record(hostB)))
// Act
val forA = store.loadForHost(hostA)
// Assert
assertEquals(1, forA.size)
assertTrue(forA.all { it.hostKey == hostA })
}
@Test
fun removeAnUnknownIdIsANoOp() = runTest {
// Arrange
val store = InMemoryAuthCookieStore(listOf(record(hostA)))
// Act
val updated = store.remove(record(hostB).id)
// Assert
assertEquals(1, updated.size)
assertEquals(updated, store.loadAll())
}
@Test
fun removeHostClearsEveryCookieForThatHostOnly() = runTest {
// Arrange
val store = InMemoryAuthCookieStore(
listOf(record(hostA), record(hostA, name = "second"), record(hostB)),
)
// Act
val updated = store.removeHost(hostA)
// Assert
assertEquals(listOf(hostB), updated.map { it.hostKey })
assertEquals(emptyList<AuthCookieRecord>(), store.loadForHost(hostA))
}
@Test
fun replaceHostSwapsThatHostsCookiesWholesale() = runTest {
// Arrange: this is the write the OkHttp cookie jar drives — "here is host A's cookie set now".
val store = InMemoryAuthCookieStore(listOf(record(hostA, value = "stale"), record(hostB)))
// Act
val updated = store.replaceHost(hostA, listOf(record(hostA, value = "fresh")))
// Assert
assertEquals("fresh", updated.single { it.hostKey == hostA }.value)
assertEquals(1, updated.count { it.hostKey == hostB })
}
@Test
fun replaceHostWithAnEmptyListClearsThatHost() = runTest {
// Arrange: the server expired the cookie (Max-Age=0) → the jar reports an empty set.
val store = InMemoryAuthCookieStore(listOf(record(hostA), record(hostB)))
// Act
val updated = store.replaceHost(hostA, emptyList())
// Assert
assertEquals(listOf(hostB), updated.map { it.hostKey })
}
private companion object {
const val FAR_FUTURE = 4_000_000_000_000L
}
}