Take the app half too, which was duplicated the same way
Four Kotlin files appeared in both apps. Diffed with the product names normalised, EnrollmentScanActivity was 31 lines each differing in six -- all of them comments -- and PinnedCert was 58 against 59 with identical TrustManager logic. That is the same evidence that moved the Rust half. Two parameters, both per-app, and they fail differently. A wrong URI scheme means a scanned QR is ignored, which is visible at once. A wrong Keystore alias means the app cannot unseal the token it already stored, so an enrolled phone reads as not enrolled and nothing says why -- so both existing values are recorded in the README rather than left to be rediscovered. The certificate alias went the other way and became a constant: it names an entry in a KeyStore that exists only in memory for the length of one lazy block, so nothing ever reads it back and having two of them said nothing. The drift was in both directions, as the evidence predicted: ai-app had gained localNetworkAllowed -- which matters because Android 17's ACCESS_LOCAL_NETWORK denial is invisible at the socket, and without it a blocked app and an unreachable server produce the same timeout -- and had moved to the KTX `edit` block. dev-updater had neither, and gets both. No Compose, deliberately. Nothing here draws anything; the screens are each app's own because that is where the two products actually differ. The PinnedCaCertificate generator did not move, and the README says why: all three things it varies are per-app, so sharing it means a composite build neither project has. Its one historical bug is already fixed identically in both copies.
This commit is contained in:
1 parent
73a32cb897
commit
4e423bbfb0
201 files changed
+3148
-2
No files matched your search
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Nothing is declared here. EnrollmentScanActivity is registered by each
|
||||
consuming app's own manifest, because the activity's label, theme and
|
||||
parent are the app's business and merging a fixed one would take that
|
||||
choice away. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.wgapplink
|
||||
|
||||
import android.view.View
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
import com.journeyapps.barcodescanner.DecoratedBarcodeView
|
||||
|
||||
/**
|
||||
* The screen behind an app'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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.example.wgapplink
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
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
|
||||
|
||||
/**
|
||||
* TLS that trusts one CA and nothing else -- not the device's system trust store, so a genuine
|
||||
* CA-issued certificate for some other host is refused just as firmly as a self-signed one.
|
||||
*
|
||||
* The certificate is passed in rather than read from anywhere, because each app generates its own
|
||||
* at build time from the CA on the machine doing the build. That is what makes the trust anchor
|
||||
* follow the build: an APK built on the backend host trusts that host, and one built in a dev VM
|
||||
* trusts that VM's throwaway CA and is good only for its emulator. Neither app should ever have a
|
||||
* second anchor to get wrong, which is also why enrollment carries no CA -- photographing the
|
||||
* terminal leaks only the rotatable token.
|
||||
*
|
||||
* Hold one of these per app and reuse it: the [socketFactory] is built lazily and then kept, since
|
||||
* every reconnect would otherwise redo the KeyStore and TrustManager setup from scratch.
|
||||
*/
|
||||
class PinnedTls(private val caPem: String) {
|
||||
|
||||
val socketFactory: 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, which fails at
|
||||
// runtime with an ASN.1 decode error nowhere near the cause.
|
||||
val caCert =
|
||||
CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(caPem.trim().encodeToByteArray()))
|
||||
val keyStore =
|
||||
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||
load(null, null)
|
||||
// The alias names an entry in a KeyStore that exists only in
|
||||
// memory for the length of this block, so it is arbitrary and
|
||||
// deliberately not per-app -- nothing reads it back.
|
||||
setCertificateEntry(PINNED_CA_ALIAS, caCert)
|
||||
}
|
||||
val trustManager =
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
.apply { init(keyStore) }
|
||||
.trustManagers
|
||||
.filterIsInstance<X509TrustManager>()
|
||||
.first()
|
||||
SSLContext.getInstance("TLS")
|
||||
.apply { init(null, arrayOf(trustManager), null) }
|
||||
.socketFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins [connection], which must be every connection the app makes -- there is no unpinned path
|
||||
* in either project, and adding one would quietly undo the whole arrangement.
|
||||
*
|
||||
* A plain HTTP connection is left alone rather than refused, since dev-updater serves its
|
||||
* bootstrap download over one deliberately.
|
||||
*/
|
||||
fun applyTo(connection: HttpURLConnection) {
|
||||
if (connection is HttpsURLConnection) {
|
||||
connection.sslSocketFactory = socketFactory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val PINNED_CA_ALIAS = "pinned-ca"
|
||||
@@ -0,0 +1,165 @@
|
||||
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.
|
||||
*/
|
||||
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
|
||||
Reference in new issue
Block a user