The Rust crate and the Android half of one arrangement: a server that binds the tunnel interface and nothing else, certificates it generates and keeps outside any shared checkout, enrolment that carries a token and the CA to a phone, and a client that trusts exactly that certificate and no other. Extracted because ai-app and dev-updater had written all of it twice and the two copies had already drifted -- one of them carried a bug the other did not. History before this point was squashed away; it was a running record of that extraction and of a personal machine's addresses, and neither is worth keeping in a public repository.
178 lines
8.0 KiB
Kotlin
178 lines
8.0 KiB
Kotlin
package com.example.wgapplink
|
|
|
|
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
|
|
|
|
/**
|
|
* Where a backend is and how to authenticate to it. Absent until the phone is enrolled, which
|
|
* happens by scanning the QR the server prints to its terminal.
|
|
*/
|
|
data class ServerSettings(val host: String, val port: Int, val token: String) {
|
|
val baseUrl: String
|
|
get() = "https://$host:$port"
|
|
}
|
|
|
|
/**
|
|
* Reads and writes one app's enrollment.
|
|
*
|
|
* Two things are per-app and both are passed in rather than derived, because getting either wrong
|
|
* is silent rather than loud:
|
|
* - [scheme] is what routes a scanned QR back to the right app (`aiapp`, `devupdater`). Two apps
|
|
* accepting the same scheme would each offer to handle the other's enrollment.
|
|
* - [keyAlias] names the Android Keystore key the token is sealed under. It is **persisted**, so an
|
|
* app that changes it stops being able to unseal what it already stored, and the phone silently
|
|
* reads as not enrolled. Existing values must be carried over exactly when adopting this class.
|
|
*/
|
|
class ServerStore(private val scheme: String, private val keyAlias: String) {
|
|
|
|
fun load(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 save(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))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses the enrollment URI the QR carries, e.g.
|
|
* `aiapp://enroll?host=10.66.0.1&port=8443&token=...`.
|
|
*
|
|
* Null if any part is missing, so a malformed or foreign scan cannot clobber a working
|
|
* enrollment with a half-filled one.
|
|
*/
|
|
fun parseEnrollmentUri(uri: Uri): ServerSettings? {
|
|
if (uri.scheme != scheme || 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 authenticates requests to a server whose API is
|
|
// remote code execution, so it is stored AES-GCM-encrypted under an Android
|
|
// Keystore key (hardware-backed where the device has one) rather than in
|
|
// plain preferences. Hand-rolled rather than Jetpack's
|
|
// EncryptedSharedPreferences, which is deprecated with no drop-in
|
|
// successor -- Google's own guidance is now to use Keystore directly.
|
|
|
|
/**
|
|
* The key if there is one, without making one.
|
|
*
|
|
* 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 Keystore keys do 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".
|
|
*/
|
|
private fun existingTokenKey(): SecretKey? {
|
|
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
|
return keyStore.getKey(keyAlias, null) as? SecretKey
|
|
}
|
|
|
|
private fun tokenKey(): SecretKey {
|
|
existingTokenKey()?.let {
|
|
return it
|
|
}
|
|
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
|
generator.init(
|
|
KeyGenParameterSpec.Builder(
|
|
keyAlias,
|
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
|
)
|
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
.build()
|
|
)
|
|
return generator.generateKey()
|
|
}
|
|
|
|
/** `iv:ciphertext`, both base64 -- the stored form of the token. */
|
|
private fun seal(token: String): String {
|
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
|
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, including the lost-key case above. The caller treats that as "not
|
|
* enrolled"; re-scanning the QR, or rotating the token server-side, is the recovery -- so
|
|
* failing soft here is right rather than lenient.
|
|
*/
|
|
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(TRANSFORMATION)
|
|
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
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 shown
|
|
* would blame the server or the tunnel for something neither is doing.
|
|
*
|
|
* **The permission is still required when the server is reached through WireGuard, and the
|
|
* platform's own documentation says otherwise.** Android's Local Network Definition describes a
|
|
* local network as one that "utilizes a broadcast-capable network interface, such as Wi-Fi or
|
|
* Ethernet, but excludes cellular (WWAN) or VPN connections" — read straight, a tunnelled 10.66.0.1
|
|
* is excluded and needs nothing. Measured on a real device on 2026-08-28: it is not excluded, and
|
|
* without the permission the traffic is dropped. Do not remove the permission on the strength of
|
|
* that paragraph.
|
|
*
|
|
* **Neither project can catch this in an emulator.** The API 36 images both are tested against do
|
|
* not enforce the permission at all, so removing it passes every local test and fails only on a
|
|
* phone. That asymmetry is the reason this note is here rather than in a commit message.
|
|
*/
|
|
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
|
|
|
|
// Both apps store under the same preferences name, which does not collide:
|
|
// SharedPreferences are per-application, so "server" means this app's server.
|
|
private const val PREFS_NAME = "server"
|
|
private const val KEY_HOST = "host"
|
|
private const val KEY_PORT = "port"
|
|
private const val KEY_TOKEN = "token"
|
|
|
|
private const val KEYSTORE = "AndroidKeyStore"
|
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
|
private const val GCM_TAG_BITS = 128
|