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