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:
commit
f95bc77f7b
17 files changed
+2552
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user