wg-app-link: the WireGuard-and-pinned-TLS half both apps needed

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.
This commit is contained in:
iris committed 2026-08-31 20:27:27 -04:00
commit f95bc77f7b
17 files changed
+2552

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
// The app half of the link, as a library both phones' apps depend on.
//
// Consumed as a *subproject* of each app's build rather than as a published
// artifact or a composite build, so the two stay version-locked to whatever
// commit of this repo the consuming project's submodule points at -- the
// same arrangement the Rust half uses with a path dependency. Each app adds
// two lines to its `settings.gradle.kts`:
//
// include(":link")
// project(":link").projectDir = file("../wg-app-link/app")
//
// and one entry to its version catalog, since a subproject resolves plugin
// versions from the build including it:
//
// androidLibrary = { id = "com.android.library", version.ref = "agp" }
//
// Deliberately no Compose. Nothing here draws anything -- the screens that
// use it are each app's own, because they are where the two products
// actually differ.
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.ktfmt)
}
ktfmt { kotlinLangStyle() }
android {
namespace = "com.example.wgapplink"
compileSdk = 37
// minSdk matches the lower of the two consumers rather than this
// library's own preference: a library that raised it would silently
// raise theirs. No desugaring here for the same reason -- nothing in
// this module uses java.time, and requiring it would impose a build
// setting on anyone who depends on it.
defaultConfig { minSdk = 24 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
dependencies {
// SharedPreferences.edit, and the KTX extensions this code calls
// directly rather than inheriting transitively.
implementation(libs.androidx.core.ktx)
// The capture activity EnrollmentScanActivity subclasses. `api` rather
// than `implementation`: the subclass is part of this module's public
// surface, so a consumer declaring it in its manifest needs the type.
api(libs.zxing.embedded)
}
+6
View File
@@ -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,177 @@
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