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:
1 parent
c2dfaab349
commit
b6b33dc9c5
12 files changed
+41
-209
No files matched your search
@@ -128,6 +128,10 @@ androidComponents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// The link both this app and Dev Updater's need in order to reach a
|
||||||
|
// machine they were enrolled against: the pinned CA, the enrollment
|
||||||
|
// store, and the QR capture activity. See wg-app-link's README.
|
||||||
|
implementation(project(":link"))
|
||||||
// Not a library this code calls: it is what `isCoreLibraryDesugaring
|
// Not a library this code calls: it is what `isCoreLibraryDesugaring
|
||||||
// Enabled` above rewrites java.time against, so API 24 and 25 have it.
|
// Enabled` above rewrites java.time against, so API 24 and 25 have it.
|
||||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
asked for anyway. Scoped to this activity, so a genuine pin
|
asked for anyway. Scoped to this activity, so a genuine pin
|
||||||
elsewhere would still be reported. -->
|
elsewhere would still be reported. -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".EnrollmentScanActivity"
|
android:name="com.example.wgapplink.EnrollmentScanActivity"
|
||||||
android:clearTaskOnLaunch="true"
|
android:clearTaskOnLaunch="true"
|
||||||
android:screenOrientation="fullSensor"
|
android:screenOrientation="fullSensor"
|
||||||
android:stateNotNeeded="true"
|
android:stateNotNeeded="true"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.wgapplink.localNetworkAllowed
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One `when` rather than a navigation library: four screens, with the list as the root and the back
|
* One `when` rather than a navigation library: four screens, with the list as the root and the back
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import android.view.View
|
|
||||||
import com.journeyapps.barcodescanner.CaptureActivity
|
|
||||||
import com.journeyapps.barcodescanner.DecoratedBarcodeView
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The screen behind the Settings screen's "Scan QR code" button: zxing-android-embedded's capture
|
|
||||||
* activity with two of its defaults taken off, both because they put work on the person holding the
|
|
||||||
* phone.
|
|
||||||
*
|
|
||||||
* - **It decodes only the framing rectangle**, which `CameraPreview.marginFraction` insets by 10%
|
|
||||||
* from every edge and `DecoderThread` crops each frame to before the decoder sees it. A code that
|
|
||||||
* fills the viewfinder keeps decoding -- measured, not assumed -- but it does so having spent its
|
|
||||||
* quiet zone and margin for error on the crop, and anything further out is simply not looked at.
|
|
||||||
* Decoding the whole preview costs nothing and means the framing is never the user's problem.
|
|
||||||
* - **It decorates the preview** with a red laser line and the dots the detector scatters wherever
|
|
||||||
* it finds a candidate pattern. That is the library's house style; a plain preview is ours.
|
|
||||||
*
|
|
||||||
* Orientation is left to the sensor rather than pinned to landscape as the library's own manifest
|
|
||||||
* entry pins it, so the phone can be held whichever way the code is in front of it.
|
|
||||||
*/
|
|
||||||
class EnrollmentScanActivity : CaptureActivity() {
|
|
||||||
override fun initializeContent(): DecoratedBarcodeView {
|
|
||||||
val view = super.initializeContent()
|
|
||||||
view.barcodeView.marginFraction = 0.0
|
|
||||||
view.viewFinder.visibility = View.GONE
|
|
||||||
view.statusView.visibility = View.GONE
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
package com.example.aiapp
|
package com.example.aiapp
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream
|
import com.example.wgapplink.PinnedTls
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.security.KeyStore
|
|
||||||
import java.security.cert.CertificateFactory
|
|
||||||
import javax.net.ssl.HttpsURLConnection
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocketFactory
|
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
// PINNED_CA_PEM is generated at build time from the CA on the machine doing
|
// PINNED_CA_PEM is generated at build time from the CA on the machine doing
|
||||||
// the build -- see the generatePinnedCert task in build.gradle.kts. It is
|
// the build -- see the generatePinnedCert task in build.gradle.kts. It is
|
||||||
@@ -16,43 +9,10 @@ import javax.net.ssl.X509TrustManager
|
|||||||
// it must never be anywhere this repo is, and an APK should pin whatever CA
|
// it must never be anywhere this repo is, and an APK should pin whatever CA
|
||||||
// the backend it was built for actually serves.
|
// the backend it was built for actually serves.
|
||||||
//
|
//
|
||||||
// So the trust anchor follows the build machine. Built on the backend host,
|
// The pinning itself lives in wg-app-link, since dev-updater needs exactly
|
||||||
// the app trusts that host and nothing else -- not even the system store.
|
// the same thing. What stays here is the one product-specific fact -- which
|
||||||
// Built in the dev VM, it trusts that VM's throwaway CA and is good only for
|
// certificate this app pins.
|
||||||
// its emulator; never install one of those on a real phone.
|
private val pinned = PinnedTls(PINNED_CA_PEM)
|
||||||
//
|
|
||||||
// The QR enrollment deliberately carries no CA: trust lives here in the APK,
|
|
||||||
// so photographing the terminal leaks only the (rotatable) token.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trusts only [PINNED_CA_PEM], not the device's system trust store, so a real CA-issued cert for
|
|
||||||
* some other host wouldn't be accepted either. Built once and cached -- every SSE reconnect would
|
|
||||||
* otherwise redo the KeyStore/TrustManager setup from scratch.
|
|
||||||
*/
|
|
||||||
val pinnedSslSocketFactory: SSLSocketFactory by lazy {
|
|
||||||
// Trimmed because CertificateFactory only recognises PEM when the
|
|
||||||
// "-----BEGIN" preamble is the very first thing it sees; surrounding
|
|
||||||
// whitespace sends it down the DER path instead.
|
|
||||||
val caCert =
|
|
||||||
CertificateFactory.getInstance("X.509")
|
|
||||||
.generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.trim().encodeToByteArray()))
|
|
||||||
val keyStore =
|
|
||||||
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
|
||||||
load(null, null)
|
|
||||||
setCertificateEntry("ai-app-dev-ca", caCert)
|
|
||||||
}
|
|
||||||
val trustManager =
|
|
||||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
|
||||||
.apply { init(keyStore) }
|
|
||||||
.trustManagers
|
|
||||||
.filterIsInstance<X509TrustManager>()
|
|
||||||
.first()
|
|
||||||
SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustManager), null) }.socketFactory
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every request this app makes goes through this -- there is no unpinned path. */
|
/** Every request this app makes goes through this -- there is no unpinned path. */
|
||||||
fun HttpURLConnection.applyPinnedTls() {
|
fun HttpURLConnection.applyPinnedTls() = pinned.applyTo(this)
|
||||||
if (this is HttpsURLConnection) {
|
|
||||||
sslSocketFactory = pinnedSslSocketFactory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,145 +2,27 @@ package com.example.aiapp
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.security.keystore.KeyGenParameterSpec
|
import com.example.wgapplink.ServerStore
|
||||||
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 the backend is and how to authenticate to it. Absent until the phone is enrolled -- by
|
* 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)
|
* 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.
|
* or by typing the fields into the settings screen.
|
||||||
*/
|
*/
|
||||||
data class ServerSettings(val host: String, val port: Int, val token: String) {
|
typealias ServerSettings = com.example.wgapplink.ServerSettings
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the enrollment URI the server's QR carries:
|
* This app's enrollment, which is the whole of what is product-specific about it.
|
||||||
* `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.
|
|
||||||
*
|
*
|
||||||
* The read side must never create: a sealed token with no key behind it means the key was lost (a
|
* Both values are load-bearing and neither may be changed casually. The scheme is what routes a
|
||||||
* device reset, or the app's data restored onto another device, where the key does not travel), and
|
* scanned QR here rather than to Dev Updater, and the key alias names the Android Keystore key the
|
||||||
* generating a fresh one there would leave a key nothing had ever sealed with and still fail to
|
* token is already sealed under on every enrolled phone -- changing it would leave those phones
|
||||||
* decrypt. Absent is the honest answer, and the caller reads it as "not enrolled".
|
* reading as not enrolled, with no error to explain why.
|
||||||
*/
|
*/
|
||||||
private fun existingTokenKey(): SecretKey? {
|
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
|
||||||
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
|
||||||
return keyStore.getKey(KEY_ALIAS, null) as? SecretKey
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tokenKey(): SecretKey {
|
fun loadServerSettings(context: Context): ServerSettings? = store.load(context)
|
||||||
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 saveServerSettings(context: Context, settings: ServerSettings) = store.save(context, settings)
|
||||||
* 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
|
|
||||||
|
|
||||||
/** iv:ciphertext, both base64 -- the stored form of the token. */
|
fun parseEnrollmentUri(uri: Uri): ServerSettings? = store.parseEnrollmentUri(uri)
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.example.wgapplink.EnrollmentScanActivity
|
||||||
import com.google.zxing.client.android.Intents
|
import com.google.zxing.client.android.Intents
|
||||||
import com.journeyapps.barcodescanner.ScanContract
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
import com.journeyapps.barcodescanner.ScanIntentResult
|
import com.journeyapps.barcodescanner.ScanIntentResult
|
||||||
|
|||||||
@@ -286,7 +286,11 @@ fun SpawnScreen(
|
|||||||
// The id, not the label: labels are
|
// The id, not the label: labels are
|
||||||
// editable and the server resolves by
|
// editable and the server resolves by
|
||||||
// id.
|
// id.
|
||||||
setup = setup?.id.orEmpty(),
|
// Non-null here: `chosen` came from
|
||||||
|
// `setup`'s own provider list, so
|
||||||
|
// reaching this point proves there was
|
||||||
|
// a setup to take it from.
|
||||||
|
setup = setup.id,
|
||||||
provider = chosen.name,
|
provider = chosen.name,
|
||||||
title = title.trim(),
|
title = title.trim(),
|
||||||
model =
|
model =
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.androidApplication) apply false
|
alias(libs.plugins.androidApplication) apply false
|
||||||
|
alias(libs.plugins.androidLibrary) apply false
|
||||||
alias(libs.plugins.composeMultiplatform) apply false
|
alias(libs.plugins.composeMultiplatform) apply false
|
||||||
alias(libs.plugins.composeCompiler) apply false
|
alias(libs.plugins.composeCompiler) apply false
|
||||||
}
|
}
|
||||||
@@ -40,6 +40,9 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-mu
|
|||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
# For the :link subproject (wg-app-link/app), which resolves its plugins
|
||||||
|
# from the build including it rather than from its own catalog.
|
||||||
|
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt-gradle" }
|
ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt-gradle" }
|
||||||
@@ -16,3 +16,10 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
include(":androidApp")
|
include(":androidApp")
|
||||||
|
|
||||||
|
// The app half of wg-app-link, resolved by path through the submodule so
|
||||||
|
// this checkout and the crate it consumes move together -- the same
|
||||||
|
// arrangement `server/` uses for the Rust half. See that repo's README.
|
||||||
|
include(":link")
|
||||||
|
|
||||||
|
project(":link").projectDir = file("../wg-app-link/app")
|
||||||
+1
-1
Submodule wg-app-link updated: 73a32cb897...4de8bff5f2.
Reference in new issue
Block a user