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