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 java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec /** * 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)) .apply() } /** * 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 private fun tokenKey(): SecretKey { val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) } (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.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() } /** 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, tokenKey(), GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)), ) cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString() } catch (_: Exception) { null }