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,129 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Instrumented tests for [TinkAuthCookieCipher] — the at-rest encryption `:host-registry` seals the
|
||||
* `webterm_auth` shell credential with. Real `AndroidKeyStore` + real Tink, NOT Robolectric (plan §7),
|
||||
* so these COMPILE in the build env and RUN during device QA.
|
||||
*
|
||||
* Each test uses a fresh keyset/pref/master-key namespace so a leftover key from an earlier run can
|
||||
* never make a test pass or fail spuriously.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TinkAuthCookieCipherTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var namespace: String
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
namespace = UUID.randomUUID().toString().take(8)
|
||||
}
|
||||
|
||||
private fun newCipher(suffix: String = "a") = TinkAuthCookieCipher(
|
||||
context = context,
|
||||
keysetName = "test_cookie_keyset_${namespace}_$suffix",
|
||||
prefFileName = "test_cookie_prefs_${namespace}_$suffix",
|
||||
masterKeyUri = "android-keystore://test_cookie_master_${namespace}_$suffix",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun sealed_text_round_trips_and_hides_the_plaintext() {
|
||||
// Arrange
|
||||
val cipher = newCipher()
|
||||
|
||||
// Act
|
||||
val sealed = cipher.seal(PLAINTEXT)
|
||||
|
||||
// Assert: real encryption, not an encoding — and it opens back exactly.
|
||||
assertNotEquals(PLAINTEXT, sealed)
|
||||
assertFalse("the plaintext survived into the ciphertext", sealed.contains(SECRET))
|
||||
assertEquals(PLAINTEXT, cipher.open(sealed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_new_cipher_over_the_same_keyset_opens_what_the_previous_one_sealed() {
|
||||
// Arrange: the cold-start path — process restart, same AndroidKeyStore master key.
|
||||
val sealed = newCipher().seal(PLAINTEXT)
|
||||
|
||||
// Act + Assert
|
||||
assertEquals(PLAINTEXT, newCipher().open(sealed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sealing_the_same_text_twice_yields_different_ciphertext() {
|
||||
// AEAD is randomised (fresh IV), so equal cookies do not produce an equal blob — otherwise an
|
||||
// observer of the file could tell "the credential did not change" across writes.
|
||||
val cipher = newCipher()
|
||||
|
||||
assertNotEquals(cipher.seal(PLAINTEXT), cipher.seal(PLAINTEXT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_tampered_blob_is_rejected_rather_than_half_decrypted() {
|
||||
// Arrange
|
||||
val cipher = newCipher()
|
||||
val sealed = cipher.seal(PLAINTEXT)
|
||||
val flipped = sealed.mutateOneChar()
|
||||
|
||||
// Act + Assert: authenticated encryption → throws, never returns attacker-shaped plaintext.
|
||||
assertThrows(GeneralSecurityException::class.java) { cipher.open(flipped) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_blob_that_is_not_base64_is_rejected_without_echoing_it() {
|
||||
val cipher = newCipher()
|
||||
|
||||
val error = assertThrows(GeneralSecurityException::class.java) { cipher.open("!!! not base64 !!!") }
|
||||
|
||||
assertFalse(
|
||||
"an error message must never echo the stored blob",
|
||||
error.message.orEmpty().contains("not base64 !!!"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun another_namespaces_key_cannot_open_this_blob() {
|
||||
// Arrange: two ciphers = two keysets under two master keys (the cert store is a third).
|
||||
val sealed = newCipher("a").seal(PLAINTEXT)
|
||||
|
||||
// Act + Assert: key separation is real, so one at-rest namespace cannot read another's.
|
||||
assertThrows(GeneralSecurityException::class.java) { newCipher("b").open(sealed) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toString_never_reveals_the_credential() {
|
||||
val cipher = newCipher()
|
||||
cipher.seal(PLAINTEXT)
|
||||
|
||||
assertFalse(cipher.toString().contains(SECRET))
|
||||
}
|
||||
|
||||
/** Flip one character of the base64 body so the AEAD tag no longer matches. */
|
||||
private fun String.mutateOneChar(): String {
|
||||
val index = length / 2
|
||||
val replacement = if (this[index] == 'A') 'B' else 'A'
|
||||
return substring(0, index) + replacement + substring(index + 1)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11"
|
||||
|
||||
/** Shaped like what the store actually seals: the serialized cookie list. */
|
||||
const val PLAINTEXT =
|
||||
"""[{"hostKey":"http://10.0.0.1:3000","name":"webterm_auth","value":"$SECRET"}]"""
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,6 @@ 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 java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
@@ -118,16 +114,8 @@ public class TinkEnrollmentRecordStore(
|
||||
if (!committed) throw java.io.IOException("Failed to durably clear the device enrollment record")
|
||||
}
|
||||
|
||||
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 buildAead(): Aead =
|
||||
buildAndroidKeystoreAead(appContext, keysetName, prefFileName, masterKeyUri)
|
||||
|
||||
private fun prefs(): SharedPreferences =
|
||||
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
|
||||
@@ -136,7 +124,6 @@ public class TinkEnrollmentRecordStore(
|
||||
private const val DEFAULT_KEYSET_NAME = "webterm_enroll_keyset"
|
||||
private const val DEFAULT_PREF_FILE = "webterm_enroll_record_prefs"
|
||||
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_enroll_master_key"
|
||||
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
|
||||
private const val BLOB_KEY = "enrollment_record_blob"
|
||||
private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.enrollment-record".toByteArray()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.content.Context
|
||||
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
|
||||
|
||||
/** The one AEAD primitive every at-rest blob in this module is encrypted with (plan §2/§8). */
|
||||
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
|
||||
|
||||
/**
|
||||
* Builds the Tink [Aead] for one at-rest namespace: the keyset lives in the app-private
|
||||
* `SharedPreferences` file [prefFileName] under [keysetName] and is itself encrypted by the
|
||||
* `AndroidKeyStore` master key [masterKeyUri], so the on-disk keyset is useless off this device.
|
||||
*
|
||||
* Shared by every store here ([TinkCertStore], [TinkEnrollmentRecordStore], [TinkAuthCookieCipher])
|
||||
* so the custody model is defined ONCE — a weaker key template or a forgotten master-key wrapping
|
||||
* cannot drift into one store while the others stay hardened. Each caller passes its OWN
|
||||
* keyset/file/master-key names: same mechanism, separate keys, so one namespace's blob can never be
|
||||
* opened with another's key.
|
||||
*
|
||||
* Not cached — callers hold the result in a `by lazy`, which keeps the (synchronous, disk- and
|
||||
* keystore-touching) build off the first-use path's critical section and off `Dispatchers.Main`.
|
||||
*
|
||||
* @throws java.security.GeneralSecurityException if the keyset cannot be created or unwrapped.
|
||||
*/
|
||||
internal fun buildAndroidKeystoreAead(
|
||||
appContext: Context,
|
||||
keysetName: String,
|
||||
prefFileName: String,
|
||||
masterKeyUri: String,
|
||||
): 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)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package wang.yaojia.webterm.tlsandroid
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import com.google.crypto.tink.Aead
|
||||
import java.security.GeneralSecurityException
|
||||
|
||||
/**
|
||||
* At-rest encryption for the persisted `webterm_auth` session cookie — the same custody model as
|
||||
* [TinkCertStore] (Tink AEAD under an `AndroidKeyStore`-wrapped master key), applied to the credential
|
||||
* that is strictly MORE powerful than the cert chain: it grants a full shell for the server's 30-day
|
||||
* `Max-Age` (`src/http/auth.ts`), so plan §8's "encrypted, app-private, never in a backup" rule binds
|
||||
* here at least as hard.
|
||||
*
|
||||
* ## Why this class does not implement `:host-registry`'s `AuthCookieCipher`
|
||||
* It matches that interface's shape EXACTLY ([seal] / [open]) but does not declare it: this module
|
||||
* must not depend on `:host-registry` (siblings under `:app` — `android/README.md`: *dependencies only
|
||||
* flow down; nothing points upward*), and `:host-registry` must not depend on Tink. `:app` owns the
|
||||
* 4-line adapter, which is the same bridge pattern A15 already uses for `:transport-okhttp`'s
|
||||
* `ClientIdentityProvider`:
|
||||
*
|
||||
* ```kotlin
|
||||
* val tink = TinkAuthCookieCipher(context)
|
||||
* val cipher = object : AuthCookieCipher {
|
||||
* override fun seal(plaintext: String): String = tink.seal(plaintext)
|
||||
* override fun open(sealed: String): String = tink.open(sealed)
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ## Custody
|
||||
* - Own keyset, own pref file, own master key and own AEAD associated data — a cookie blob can never
|
||||
* be opened with the cert store's key, nor swapped into its slot (and vice versa).
|
||||
* - The master key never leaves `AndroidKeyStore`, so the ciphertext is useless off this device even
|
||||
* if the app-private file is copied out.
|
||||
* - Nothing here is ever logged and [toString] cannot reveal the plaintext; a decrypt failure carries
|
||||
* only the reason, never the payload (`AuthCookieCipher`'s contract).
|
||||
*
|
||||
* Both operations throw on failure — the caller (`DataStoreAuthCookieStore`) then fails CLOSED,
|
||||
* persisting nothing rather than falling back to plaintext.
|
||||
*
|
||||
* @param context any Context; only `applicationContext` is retained.
|
||||
*/
|
||||
public class TinkAuthCookieCipher(
|
||||
context: Context,
|
||||
private val keysetName: String = DEFAULT_KEYSET_NAME,
|
||||
private val prefFileName: String = DEFAULT_PREF_FILE,
|
||||
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
|
||||
) {
|
||||
private val appContext: Context = context.applicationContext
|
||||
|
||||
// Built on first use: registers AEAD, loads-or-creates the AndroidKeystore-wrapped keyset.
|
||||
private val aead: Aead by lazy {
|
||||
buildAndroidKeystoreAead(appContext, keysetName, prefFileName, masterKeyUri)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt [plaintext] into a Base64 blob safe to store in (plaintext) Preferences DataStore.
|
||||
*
|
||||
* @throws GeneralSecurityException if the keyset/master key is unavailable — the caller then
|
||||
* persists NOTHING (fail closed).
|
||||
*/
|
||||
public fun seal(plaintext: String): String {
|
||||
val ciphertext = aead.encrypt(plaintext.toByteArray(Charsets.UTF_8), ASSOCIATED_DATA)
|
||||
return Base64.encodeToString(ciphertext, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt what [seal] produced. AEAD, so a tampered, truncated or foreign blob throws instead of
|
||||
* yielding attacker-shaped bytes.
|
||||
*
|
||||
* @throws GeneralSecurityException if [sealed] is not an intact blob this device's key can open.
|
||||
*/
|
||||
public fun open(sealed: String): String {
|
||||
val ciphertext = try {
|
||||
Base64.decode(sealed, Base64.NO_WRAP)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Deliberately does not echo `sealed` — it is the credential's ciphertext.
|
||||
throw GeneralSecurityException("Sealed auth-cookie blob was not valid base64", e)
|
||||
}
|
||||
return String(aead.decrypt(ciphertext, ASSOCIATED_DATA), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/** Redacted by construction: this object's whole purpose is a credential. */
|
||||
override fun toString(): String = "TinkAuthCookieCipher(keyset=$keysetName)"
|
||||
|
||||
public companion object {
|
||||
private const val DEFAULT_KEYSET_NAME = "webterm_auth_cookie_keyset"
|
||||
private const val DEFAULT_PREF_FILE = "webterm_auth_cookie_prefs"
|
||||
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_auth_cookie_master_key"
|
||||
|
||||
/**
|
||||
* AEAD associated data — binds the ciphertext to the auth-cookie context, so a blob lifted
|
||||
* from (or into) another Tink slot in this app fails to decrypt. Not secret.
|
||||
*/
|
||||
private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.auth-cookie".toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,6 @@ 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
|
||||
|
||||
/**
|
||||
* At-rest storage for the encrypted device-identity "live pointer" ([StoredIdentityMetadata]).
|
||||
@@ -37,9 +33,10 @@ public interface CertStore {
|
||||
* - NOT stored: the private key (non-exportable in AndroidKeyStore — the one key home), the raw
|
||||
* `.p12` bytes, and the passphrase.
|
||||
*
|
||||
* The keyset + ciphertext live in an app-private `SharedPreferences` file (uninstall-wiped, excluded
|
||||
* from cloud backup by the app manifest); the Tink keyset itself is encrypted by the AndroidKeystore
|
||||
* master key so the on-disk keyset is useless off this device.
|
||||
* The keyset + ciphertext live in an app-private `SharedPreferences` file (uninstall-wiped, and kept out
|
||||
* of Android Auto Backup by `android:allowBackup="false"` in the app manifest — verify that attribute
|
||||
* before trusting this sentence); the Tink keyset itself is encrypted by the AndroidKeystore master key,
|
||||
* so even a copied-out file is useless off this device.
|
||||
*/
|
||||
public class TinkCertStore(
|
||||
context: Context,
|
||||
@@ -105,16 +102,8 @@ public class TinkCertStore(
|
||||
}
|
||||
}
|
||||
|
||||
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 buildAead(): Aead =
|
||||
buildAndroidKeystoreAead(appContext, keysetName, prefFileName, masterKeyUri)
|
||||
|
||||
private fun prefs(): SharedPreferences =
|
||||
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
|
||||
@@ -123,7 +112,6 @@ public class TinkCertStore(
|
||||
private const val DEFAULT_KEYSET_NAME = "webterm_cert_keyset"
|
||||
private const val DEFAULT_PREF_FILE = "webterm_client_tls_prefs"
|
||||
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_cert_master_key"
|
||||
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
|
||||
private const val BLOB_KEY = "cert_chain_blob"
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user