Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4de8bff5f2 | ||
|
|
4e423bbfb0 |
No files matched your search
@@ -1 +1,5 @@
|
||||
server/target/
|
||||
|
||||
# Gradle build outputs for the app half.
|
||||
app/build/
|
||||
.gradle/
|
||||
@@ -94,7 +94,7 @@ time the sharing paid rather than merely being argued for.
|
||||
|
||||
## What is here
|
||||
|
||||
`server/` — the `wg-app-link` crate. Five modules, each extracted only after
|
||||
`server/` — the `wg-app-link` crate. Six modules, each extracted only after
|
||||
diffing the two copies and finding nothing but a name between them.
|
||||
|
||||
- **`netif`** — `wg_address()`, which fails closed when the tunnel is down, and `local_addresses()` for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining. Both split the *lookup* from the *decision*, so the failure path can be tested on a machine that has a tunnel — every machine this runs on does.
|
||||
@@ -102,12 +102,61 @@ diffing the two copies and finding nothing but a name between them.
|
||||
- **`certs`** — the CA generated once and never replaced, the leaf reissued every start. `product` names the organisation and common name and is the whole of what is per-project. Shared because being written twice is worst here: a trust anchor built two ways can be built differently two ways, and the difference surfaces as an opaque handshake failure on a phone.
|
||||
- **`format`** — the two RON house rules. This was byte-identical in both projects, which made it the clearest thing in the evidence table and the easiest deletion.
|
||||
- **`private`** — owner-only files and directories, taken from ai-app's version, with an `append_file` alongside `create_file` because a transcript must never be truncated by being opened.
|
||||
- **`xdg`** — where each project's state lives: `config_home(product)` and `data_home(product)`, resolving the XDG variable, ignoring it unless absolute, and falling back under `$HOME`. Shared because the *reason* is shared and is not obvious from the code — the repo is a mount that resolves at different absolute paths on each side, so config inside it records paths that work on only one, and a CA private key inside it would let the untrusted side mint a leaf the pinned app trusts.
|
||||
|
||||
`app/` — the Android library, `com.example.wgapplink`. The same test applied
|
||||
to Kotlin: the four files appeared in both apps and differed only in
|
||||
comments and a product name.
|
||||
|
||||
- **`PinnedTls`** — trusts one CA and not the system store, so a genuine certificate for another host is refused as firmly as a self-signed one. The PEM is constructed with, not read, because each app generates its own at build time from the machine doing the build.
|
||||
- **`ServerStore`** — the enrollment: preferences, the `<scheme>://enroll?…` URI, and the token sealed under an Android Keystore key. Two parameters, both per-app and both load-bearing — see the warning below.
|
||||
- **`EnrollmentScanActivity`** — zxing's capture activity with the 10% framing inset and the laser decorations removed, so framing is never the user's problem.
|
||||
- **`localNetworkAllowed`** — whether `ACCESS_LOCAL_NETWORK` was granted. Android 17 made it mandatory and a denial is invisible at the socket, so without asking, a blocked app and an unreachable server produce the same timeout.
|
||||
|
||||
**Adopting the app half takes two lines and one catalog entry.** In
|
||||
`settings.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
include(":link")
|
||||
project(":link").projectDir = file("../wg-app-link/app")
|
||||
```
|
||||
|
||||
and in the version catalog, because a subproject resolves plugin versions
|
||||
from the build including it rather than from its own:
|
||||
|
||||
```toml
|
||||
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||
```
|
||||
|
||||
It also needs `androidx-core-ktx` and `zxing-embedded` in that catalog, and
|
||||
`alias(libs.plugins.androidLibrary) apply false` in the root build file.
|
||||
Consumed as a subproject rather than a published artifact so the two stay
|
||||
locked to whatever commit the submodule points at — the same arrangement
|
||||
the Rust half uses with a path dependency.
|
||||
|
||||
> **The Keystore alias is persisted. Carry the existing value over exactly.**
|
||||
> `ServerStore(scheme, keyAlias)` takes both because both are per-app, but
|
||||
> they fail differently. A wrong scheme means a scanned QR is ignored, which
|
||||
> is visible immediately. A wrong `keyAlias` means the app can no longer
|
||||
> unseal the token it already stored, so an enrolled phone silently reads as
|
||||
> not enrolled with nothing on screen to say why. ai-app's is
|
||||
> `aiapp-token-key`; dev-updater's is `dev-updater-token-key`.
|
||||
|
||||
**Not moved: the `PinnedCaCertificate.kt` generator.** It is ~40 lines of
|
||||
Gradle in each app's build file, and all three things it varies — the
|
||||
environment variable, the certificate path, and the package the constant is
|
||||
emitted into — are per-app, so sharing it means parameterising the whole of
|
||||
it and introducing a composite build that neither project has today. The
|
||||
one bug it has had is fixed identically in both copies: a PEM constant must
|
||||
start at the opening quotes, or `CertificateFactory` loses its `-----BEGIN`
|
||||
preamble sniff, tries DER, and fails at runtime with an ASN.1 decode error
|
||||
nowhere near the generator. Worth revisiting if it ever needs a second fix.
|
||||
|
||||
## What should follow, and what should not
|
||||
|
||||
**Should follow, in this order.** Each is already near-identical:
|
||||
|
||||
1. The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in *both* directions — and `applyPinnedTls` is security-critical and identical, which is the same argument `certs` won on.
|
||||
1. ~~The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`.~~ Done — see `app/` above. It paid as predicted: ai-app's `ServerConfig` had gained `localNetworkAllowed` and the KTX `edit` block while dev-updater's had not, so the two had drifted in both directions exactly as the evidence suggested.
|
||||
2. Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs.
|
||||
|
||||
**Should not.** Naming these is the point of the exercise:
|
||||
|
||||
@@ -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,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