Take the app half from wg-app-link as well

The four Kotlin files that were the link rather than this product now come
from the submodule: the pinned TrustManager, the enrollment store and its
Keystore sealing, the QR capture activity, and the local-network permission
check. `:link` is a subproject resolved by path, so the app half is
version-locked to the same commit the Rust half already was.

What stays here is the two facts that are actually about this app, and
both are load-bearing in a way that would fail quietly if got wrong: the
`aiapp` URI scheme, and the Keystore alias `aiapp-token-key` that every
enrolled phone's token is already sealed under. A wrong alias would leave
those phones reading as not enrolled with nothing on screen to explain it,
so the value is carried over exactly and the reason is written beside it.

Call sites are unchanged. `ServerSettings` stays available unqualified as
a typealias and `applyPinnedTls()` stays an extension, so the diff is the
three files that bind the product-specific values plus two imports --
rather than every screen that happens to use a setting.

Also clears a warning the build had been printing: `setup?.id.orEmpty()`
where the compiler already knows `setup` is non-null, because `chosen`
came from that setup's own provider list.

Verified by running the build, not only by reading it: ktfmt, Kotlin
compile and Android Lint are all clean with no warnings, and the APK still
builds -- which exercises the pinned-CA generator, since that is the step
that reads the CA off this machine.

Still unpushed, per the hold until the rebuild bug is proven fixed. Note
the submodule: a checkout of this commit needs `git submodule update
--init` before `app/` or `server/` will build.
This commit is contained in:
iris committed 2026-08-28 17:57:45 -04:00
1 parent c2dfaab349
commit b6b33dc9c5
12 files changed
+41 -209

No files matched your search

@@ -2,145 +2,27 @@ package com.example.aiapp
import android.content.Context
import android.net.Uri
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.core.content.edit
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import com.example.wgapplink.ServerStore
/**
* Where the backend is and how to authenticate to it. Absent until the phone is enrolled -- by
* scanning the server's terminal QR (an `aiapp://enroll` URI the camera app hands to MainActivity)
* or by typing the fields into the settings screen.
*/
data class ServerSettings(val host: String, val port: Int, val token: String) {
val baseUrl: String
get() = "https://$host:$port"
}
private const val PREFS_NAME = "server"
private const val KEY_HOST = "host"
private const val KEY_PORT = "port"
private const val KEY_TOKEN = "token"
fun loadServerSettings(context: Context): ServerSettings? {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val host = prefs.getString(KEY_HOST, null) ?: return null
val port = prefs.getInt(KEY_PORT, 0)
val sealed = prefs.getString(KEY_TOKEN, null) ?: return null
val token = unseal(sealed) ?: return null
if (port == 0 || token.isEmpty()) return null
return ServerSettings(host, port, token)
}
fun saveServerSettings(context: Context, settings: ServerSettings) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
putString(KEY_HOST, settings.host)
putInt(KEY_PORT, settings.port)
putString(KEY_TOKEN, seal(settings.token))
}
}
typealias ServerSettings = com.example.wgapplink.ServerSettings
/**
* Parses the enrollment URI the server's QR carries:
* `aiapp://enroll?host=10.66.0.1&port=8443&token=...`. Null if any part is missing -- a malformed
* scan shouldn't clobber a working enrollment.
*/
fun parseEnrollmentUri(uri: Uri): ServerSettings? {
if (uri.scheme != "aiapp" || uri.host != "enroll") return null
val host = uri.getQueryParameter("host") ?: return null
val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null
val token = uri.getQueryParameter("token") ?: return null
if (host.isEmpty() || token.isEmpty()) return null
return ServerSettings(host, port, token)
}
// ---------------------------------------------------------------------------
// Token sealing. The token is the credential for remote code execution on
// the backend, so it is stored AES-GCM-encrypted under an Android Keystore
// key (hardware-backed where the device has it) rather than in plain
// preferences. Hand-rolled (~40 lines) instead of Jetpack's
// EncryptedSharedPreferences because that library is deprecated with no
// drop-in successor -- Google's own guidance is now "use Keystore directly".
private const val KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "aiapp-token-key"
private const val GCM_TAG_BITS = 128
/**
* The key if there is one, without making one.
* This app's enrollment, which is the whole of what is product-specific about it.
*
* The read side must never create: a sealed token with no key behind it means the key was lost (a
* device reset, or the app's data restored onto another device, where the key does not travel), and
* generating a fresh one there would leave a key nothing had ever sealed with and still fail to
* decrypt. Absent is the honest answer, and the caller reads it as "not enrolled".
* Both values are load-bearing and neither may be changed casually. The scheme is what routes a
* scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the
* token is already sealed under on every enrolled phone -- changing it would leave those phones
* reading as not enrolled, with no error to explain why.
*/
private fun existingTokenKey(): SecretKey? {
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
return keyStore.getKey(KEY_ALIAS, null) as? SecretKey
}
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
private fun tokenKey(): SecretKey {
existingTokenKey()?.let {
return it
}
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
return generator.generateKey()
}
fun loadServerSettings(context: Context): ServerSettings? = store.load(context)
/**
* Whether this app may reach a local network address at all.
*
* Android 17 made `ACCESS_LOCAL_NETWORK` mandatory for it, and a denial is invisible at the socket:
* the OS drops the traffic, so a blocked app and an unreachable server produce the same connect
* timeout. Without asking explicitly there is no way to tell those apart, and the failure message
* would blame the server or the tunnel for something neither is doing.
*/
fun localNetworkAllowed(context: Context): Boolean =
android.os.Build.VERSION.SDK_INT < 37 ||
context.checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK") ==
android.content.pm.PackageManager.PERMISSION_GRANTED
fun saveServerSettings(context: Context, settings: ServerSettings) = store.save(context, settings)
/** iv:ciphertext, both base64 -- the stored form of the token. */
private fun seal(token: String): String {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, tokenKey())
val ciphertext = cipher.doFinal(token.encodeToByteArray())
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) +
":" +
Base64.encodeToString(ciphertext, Base64.NO_WRAP)
}
/**
* Null on any failure -- e.g. the Keystore key was lost to a device reset or the app's data was
* restored onto another device, where the key never travels. The caller treats that as "not
* enrolled"; re-scanning the QR (or `--rotate-token`) is the recovery, so failing soft here is
* right.
*/
private fun unseal(sealed: String): String? =
try {
val (ivB64, dataB64) =
sealed.split(":", limit = 2).let { if (it.size != 2) return null else it[0] to it[1] }
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
existingTokenKey() ?: return null,
GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)),
)
cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString()
} catch (_: Exception) {
null
}
fun parseEnrollmentUri(uri: Uri): ServerSettings? = store.parseEnrollmentUri(uri)