From f95bc77f7bf55a57ba6c051b06f11d30ac68c55e Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 20:27:27 -0400 Subject: [PATCH] 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. --- .gitignore | 5 + README.md | 222 +++++ app/build.gradle.kts | 52 ++ app/src/main/AndroidManifest.xml | 6 + .../wgapplink/EnrollmentScanActivity.kt | 30 + .../kotlin/com/example/wgapplink/PinnedTls.kt | 70 ++ .../com/example/wgapplink/ServerConfig.kt | 177 ++++ run-tests.sh | 5 + server/Cargo.lock | 837 ++++++++++++++++++ server/Cargo.toml | 30 + server/src/certs.rs | 239 +++++ server/src/enroll.rs | 146 +++ server/src/format.rs | 246 +++++ server/src/lib.rs | 35 + server/src/netif.rs | 180 ++++ server/src/private.rs | 178 ++++ server/src/xdg.rs | 94 ++ 17 files changed, 2552 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/com/example/wgapplink/EnrollmentScanActivity.kt create mode 100644 app/src/main/kotlin/com/example/wgapplink/PinnedTls.kt create mode 100644 app/src/main/kotlin/com/example/wgapplink/ServerConfig.kt create mode 100755 run-tests.sh create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/src/certs.rs create mode 100644 server/src/enroll.rs create mode 100644 server/src/format.rs create mode 100644 server/src/lib.rs create mode 100644 server/src/netif.rs create mode 100644 server/src/private.rs create mode 100644 server/src/xdg.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24b879a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +server/target/ + +# Gradle build outputs for the app half. +app/build/ +.gradle/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1f5eb0 --- /dev/null +++ b/README.md @@ -0,0 +1,222 @@ +# wg-app-link + +The private link between a phone and a machine you run, extracted from the +two projects that had each written it. + +`dev-updater` serves locally-built APKs to a phone. `ai-app` runs model +sessions for one. They have nothing in common above the waterline — and +underneath they are the same program: a server bound to a WireGuard +interface so it is not on the LAN, presenting a certificate from a CA the +app pins, answering only requests carrying a bearer token that was enrolled +by scanning a QR code off the terminal, and keeping its state in +owner-only files outside the repo. + +That link was written twice. This is it written once. + +> **Status: proposal with a working core.** Nothing has been removed from +> either project yet. The Rust modules here are real, tested and +> lint-clean; the rest of this file is the case for what should follow and +> what should not. + +## The evidence + +Measured, not estimated — `difflib` over the two working trees on +2026-08-28: + +| file | dev-updater | ai-app | identical | +|---|---|---|---| +| `EnrollmentScanActivity.kt` | 32 lines | 32 lines | **98%** | +| `ServerConfig.kt` | 139 | 122 | **89%** | +| `auth.rs` | 250 | 230 | **83%** | +| `PinnedCert.kt` | 60 | 59 | **75%** | +| `certs.rs` | 218 | 199 | **70%** | +| RON `format` module | — | — | **byte-identical** | + +The percentages understate it, because the differences are almost entirely +a product name and a type parameter: + +- **`EnrollmentScanActivity.kt`** differs by its `package` line. That is all. +- **`PinnedCert.kt`** differs by `package` and one string, `"dev-updater-dev-ca"` against `"ai-app-dev-ca"`. +- **`certs.rs`** differs by the organisation name in the certificate, and by ai-app having factored the file-mode handling into a `private` module that dev-updater still has inline in two places. +- **`auth.rs`** differs by the state type the middleware is generic over (`AppState` against `SessionManager`), and by dev-updater keeping `print_enrollment` in `auth.rs` where ai-app keeps the same function in `main.rs`. +- **`wg_address` and `local_addresses`** are the same logic in both, in files that are otherwise 8% alike. + +ai-app's own `Cargo.toml` already says the quiet part: RON is used there +because it is *"the same choice, and the same house rules, as the sibling +dev-updater project's config."* + +## Why this is worth doing, in one observation + +**The two copies have each drifted into holding an improvement the other +lacks.** Not hypothetically — as of the day this was written: + +| improvement | in dev-updater | in ai-app | +|---|---|---| +| `private::` module owning every file mode | no, inline twice | yes | +| `SharedPreferences.edit { }` instead of the deprecated builder | no | yes | +| `existingTokenKey()` — reads the key without creating one, so a stored blob with no key means "not enrolled" rather than leaving a stray key behind | yes | no | +| `hasLocalNetworkPermission` — tells a denied permission apart from an unreachable server, which are identical at the socket | yes | no | +| Catppuccin theme with colour-by-consequence buttons | yes | no, ad-hoc `Color(0xFF…)` per screen | + +Every one of those is a fix somebody made once, in one repo, that the other +will either never get or get by being written a third time. That is the +cost this repo removes, and it is already being paid. + +## It found a bug in all three before shipping a line + +`private` was lifted from ai-app because that copy was the better one. Two +sessions then read it as a module rather than as scattered helpers, and +found that `OpenOptions::mode` applies **only when a file is created** — so +opening one that already exists keeps whatever mode it had. + +Both projects rewrite existing files holding private material. `certs.rs` +reissues the TLS leaf on every start, so a `leaf-key.pem` that had ever +existed world-readable would have stayed that way for the rest of its life, +with every start looking like it was setting the mode. The config's temp +file is the other: normally fresh, but one left by a crashed save is +reopened with its old mode and then renamed over the file holding the +enrolled token hashes. + +It was latent in both — the modes on both machines were checked, and were +correct — but it was present three times: dev-updater, ai-app, and this +crate's first commit. + +The reason nobody caught it is the argument for this repo, and it is not +about duplication. The correct reasoning was already written down, in a +comment, three functions above the code that needed it: `create_dir` sets +the mode a second time and explains exactly why. It stayed invisible for as +long as nobody had cause to read those functions as a set. Two readers +looking at one module found it in under an hour. + +Fixed here (`67ac924`), in ai-app (`d0b6b66`), and in dev-updater +(`bdaade4`), which also adopted the `private` module wholesale — the first +time the sharing paid rather than merely being argued for. + +## What is here + +`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. +- **`enroll`** — token generation, hex-SHA-256 storage, constant-time comparison, the `://enroll?…` URI, and the terminal QR. The URI scheme is the parameter, because it is what routes a scan back to the right app. +- **`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, plus `write`, which renders a value and replaces a file with it atomically and owner-only. 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 `://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`.~~ 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.~~ Done — `format::write`, since it is the RON house rules and the owner-only rules used together. The subtlety it now states once is that the temp file is *not always new*: a save killed partway leaves one behind, and reopening it keeps whatever mode it had, which is then renamed over the file holding the enrolled token hashes. There is a test for exactly that, because it cannot happen on a machine where nothing has ever crashed mid-save. + +**Should not.** Naming these is the point of the exercise: + +- **The auth middleware itself.** It is generic over each project's state type. Share the primitives (`enroll`), let each keep the six lines that wire them to its own state — the alternative is a trait that exists only to let one function be shared. +- **The HTTP clients.** dev-updater's `DownloadServer.kt` and ai-app's `Api.kt` are 14% alike and 135 against 494 lines. They have diverged because they are genuinely different programs. A shared *pinned transport* underneath them may be worth it later; a shared API is not. +- **Config schemas.** Shared house rules, separate contents. +- **Anything above the link.** Projects, builds, sessions, providers. If a third project would not want it, it does not belong here. + +## Two decisions worth stating, rather than inheriting + +**`WG_INTERFACE` is a constant, not a parameter.** For these two projects a +second answer would mean two ideas of what "the tunnel" is, and there is +one tunnel. That reasoning is about *these* projects rather than about the +world, so it is the first thing a third user should expect to change. + +**`local_addresses` puts `10.0.2.2` in every certificate.** It is the alias +an Android emulator reaches its host by, and not an address the machine +owns — so this is a SAN for something that is not this host, present so a +debug build can reach a server running beside it. Inherited from ai-app +and correct there; written down here because promoting it makes it every +future project's default rather than one project's choice. + +## A review heuristic this repo keeps proving + +**When you find a rule stated somewhere, grep for its siblings.** Three +separate bugs today were the correct rule already written down, applied to +one member of a set and not the others: + +- `create_dir` set its mode a second time and explained why; `create_file` + and `append_file`, three functions below, did not. +- ai-app explained one ssh failure and not its sibling. +- dev-updater's acceptance gate compared components by *declaration*, and + `BuildState::matches` — which meant the same thing — compared them by + full equality, so writing a measured field replaced a running build's + state. + +None was found by a test. All three were found by a second reader looking +at code as a set, which is the thing this crate is actually buying. + +## The name + +`wg-app-link`: the link between an app and the machine it talks to, over +WireGuard. + +Two earlier names were worse. `wg-server-app` names the two projects this +came out of rather than what it is, which stops being useful the moment a +third one uses it. `wg-link` was worse still, and not merely vague — +`wg` and `ip link` are both real network tools, so it reads as something +that manages a WireGuard interface. `app` is what rules that reading out. + +The `wg-` prefix does slightly oversell WireGuard's share of the code: +only `netif` is about the tunnel, while `enroll`, `private` and the +certificate work are not. It earns its place anyway, because binding the +tunnel is the architectural premise the rest follows from — it is why +there is no LAN exposure to defend, and why a plain-HTTP bootstrap port is +safe to offer at all. + +## Testing + +`./run-tests.sh`. The crate is warning-clean under `cargo clippy +--all-targets` and formatted with plain `cargo fmt` at its defaults, per +the house rules. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..ab35fea --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a9798b4 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/kotlin/com/example/wgapplink/EnrollmentScanActivity.kt b/app/src/main/kotlin/com/example/wgapplink/EnrollmentScanActivity.kt new file mode 100644 index 0000000..6f5d646 --- /dev/null +++ b/app/src/main/kotlin/com/example/wgapplink/EnrollmentScanActivity.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/example/wgapplink/PinnedTls.kt b/app/src/main/kotlin/com/example/wgapplink/PinnedTls.kt new file mode 100644 index 0000000..f87631d --- /dev/null +++ b/app/src/main/kotlin/com/example/wgapplink/PinnedTls.kt @@ -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() + .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" diff --git a/app/src/main/kotlin/com/example/wgapplink/ServerConfig.kt b/app/src/main/kotlin/com/example/wgapplink/ServerConfig.kt new file mode 100644 index 0000000..dfbc525 --- /dev/null +++ b/app/src/main/kotlin/com/example/wgapplink/ServerConfig.kt @@ -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 diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..23e4515 --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Runs this crate's tests. Extra arguments are forwarded to `cargo test`. +set -eu +cd "$(dirname "$0")/server" +exec cargo test "$@" diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..3a3c7ef --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,837 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wg-app-link" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.23.1", + "if-addrs", + "qrcode", + "rand", + "rcgen", + "ron", + "serde", + "sha2", + "subtle", + "tempfile", + "tracing", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..205a111 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "wg-app-link" +version = "0.1.0" +edition = "2024" +description = "The private link between a phone and a machine you run: WireGuard binding, a self-signed CA the app pins, and QR enrollment of a bearer token." + +[dependencies] +anyhow = "1" +# The config format both projects use, and the two house rules they share. +ron = "0.12.2" +serde = { version = "1", features = ["derive"] } +# Enumerating this machine's addresses, and finding the tunnel's. +if-addrs = "0.15" +# Token auth: hash for storage, constant-time compare for verification, +# CSPRNG-backed generation, base64url for the enrollment string. +sha2 = "0.11" +subtle = "2" +rand = "0.10" +base64 = "0.23" +# Renders the enrollment QR straight to the terminal; no image output. +qrcode = { version = "0.14", default-features = false } +tracing = "0.1" +# Generates the CA and leaf in process, so one place decides the +# extensions, the file modes, and which addresses the leaf covers. +# x509-parser so the issuer is read back from the CA actually on disk +# rather than reconstructed from parameters that may since have changed. +rcgen = { version = "0.14", features = ["pem", "x509-parser"] } + +[dev-dependencies] +tempfile = "3" diff --git a/server/src/certs.rs b/server/src/certs.rs new file mode 100644 index 0000000..719f27e --- /dev/null +++ b/server/src/certs.rs @@ -0,0 +1,239 @@ +//! The TLS certificates a server presents, generated in process on first +//! start, from a CA the app pins. +//! +//! Shared because it existed twice and differed by an organisation name. +//! It is also the piece where being written twice is worst: a trust +//! anchor built two ways can be built differently two ways, and the +//! difference would surface as an opaque handshake failure on a phone. +//! +//! `product` names the certificate's organisation and common name, and is +//! the whole of what is per-project. +//! +//! There used to be a `gen-dev-cert.sh` calling openssl, which meant a +//! setup step to remember, a second place for the "which SANs?" answer to +//! live, and a dependency on whatever openssl was installed. Doing it here +//! means the server can simply ensure its own certificates exist, with the +//! file modes and extensions it wants, and with the address it is actually +//! about to bind already in the leaf. +//! +//! The split that matters is between the two: +//! +//! - The **CA** is generated once and then left alone. The updater app +//! pins it, so replacing it strands every installed copy -- recovery is +//! a reinstall over the plain-HTTP bootstrap port. It is the one thing +//! here that is a one-way door. +//! - The **leaf** is cheap and reissued on every start, signed by that +//! same unchanged CA. Nothing pins it, so covering a new address is just +//! a restart rather than anything the phone has to be told about. +//! +//! Everything is written owner-only into a directory outside the repo (see +//! `config_home`): a CA private key readable by another machine is one it +//! can sign with, and a certificate signed by a pinned CA is accepted +//! without question. + +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use rcgen::{ + BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, +}; + +/// Where the leaf lives, for handing to the TLS listener. +pub struct Certificates { + pub leaf_cert: PathBuf, + pub leaf_key: PathBuf, + /// True when the CA was created just now, i.e. anything already + /// installed pins the wrong one and has to be reinstalled. + pub ca_is_new: bool, +} + +/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what +/// is missing. Safe to call on every start. +pub fn ensure(product: &str, dir: &Path, addresses: &[IpAddr]) -> Result { + crate::private::create_dir(dir)?; + + let ca_cert_path = dir.join("ca.pem"); + let ca_key_path = dir.join("ca-key.pem"); + let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file(); + + let (ca_pem, ca_key_pem) = if ca_is_new { + let (pem, key) = generate_ca(product)?; + crate::private::write_file(&ca_key_path, key.as_bytes())?; + crate::private::write_file(&ca_cert_path, pem.as_bytes())?; + tracing::info!("generated a new CA in {}", dir.display()); + (pem, key) + } else { + ( + std::fs::read_to_string(&ca_cert_path) + .with_context(|| format!("read {}", ca_cert_path.display()))?, + std::fs::read_to_string(&ca_key_path) + .with_context(|| format!("read {}", ca_key_path.display()))?, + ) + }; + + let (leaf_pem, leaf_key_pem) = generate_leaf(product, &ca_pem, &ca_key_pem, addresses)?; + let leaf_cert = dir.join("leaf.pem"); + let leaf_key = dir.join("leaf-key.pem"); + crate::private::write_file(&leaf_key, leaf_key_pem.as_bytes())?; + crate::private::write_file(&leaf_cert, leaf_pem.as_bytes())?; + + Ok(Certificates { + leaf_cert, + leaf_key, + ca_is_new, + }) +} + +fn generate_ca(product: &str) -> Result<(String, String)> { + let key = KeyPair::generate().context("generate CA key")?; + let mut params = CertificateParams::default(); + params + .distinguished_name + .push(DnType::OrganizationName, format!("{product} dev")); + params + .distinguished_name + .push(DnType::CommonName, format!("{product} dev CA")); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + // Explicit, because strict verifiers reject a CA without them -- and + // that rejection surfaces as an opaque handshake failure on a phone. + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let certificate = params.self_signed(&key).context("self-sign CA")?; + Ok((certificate.pem(), key.serialize_pem())) +} + +fn generate_leaf( + product: &str, + ca_pem: &str, + ca_key_pem: &str, + addresses: &[IpAddr], +) -> Result<(String, String)> { + let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?; + let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?; + + let key = KeyPair::generate().context("generate leaf key")?; + let mut params = CertificateParams::default(); + params + .distinguished_name + .push(DnType::OrganizationName, format!("{product} dev")); + params.distinguished_name.push( + DnType::CommonName, + addresses + .first() + .map(|a| a.to_string()) + .unwrap_or_else(|| product.to_string()), + ); + params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect(); + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.use_authority_key_identifier_extension = true; + let certificate = params.signed_by(&key, &issuer).context("sign leaf")?; + Ok((certificate.pem(), key.serialize_pem())) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use super::*; + + fn decode_pem(pem: &str) -> Vec { + use base64::Engine; + let body: String = pem + .lines() + .filter(|line| !line.starts_with("-----")) + .collect(); + base64::engine::general_purpose::STANDARD + .decode(body) + .expect("base64") + } + + fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) + } + + fn addresses() -> Vec { + vec![ + "192.168.1.5".parse().unwrap(), + "127.0.0.1".parse().unwrap(), + ] + } + + #[test] + fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() { + let dir = tempfile::tempdir().expect("tempdir"); + + let first = ensure("demo", dir.path(), &addresses()).expect("generate"); + assert!(first.ca_is_new); + let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca"); + let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf"); + assert!(ca.starts_with("-----BEGIN CERTIFICATE-----")); + + let second = ensure("demo", dir.path(), &addresses()).expect("regenerate"); + // The CA is the pinned one: replacing it strands every installed + // app, so it must survive a restart untouched. + assert!(!second.ca_is_new); + assert_eq!( + ca, + std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca") + ); + // The leaf is not pinned, and is reissued so a new address is a + // restart away rather than a reinstall. + assert_ne!( + leaf, + std::fs::read_to_string(&second.leaf_cert).expect("leaf") + ); + } + + /// Private keys are rewritten on every start, which is the case that + /// makes the mode matter: `private` narrows a file that already + /// exists, and this is the caller that depends on it. + #[test] + fn everything_is_owner_only_even_on_the_second_start() { + let dir = tempfile::tempdir().expect("tempdir"); + ensure("demo", dir.path(), &addresses()).expect("generate"); + + // What an older version could have left behind. + for file in ["leaf.pem", "leaf-key.pem"] { + std::fs::set_permissions( + dir.path().join(file), + std::fs::Permissions::from_mode(0o644), + ) + .expect("chmod"); + } + ensure("demo", dir.path(), &addresses()).expect("reissue"); + + let mode = |path: std::path::PathBuf| { + std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777 + }; + assert_eq!(mode(dir.path().to_path_buf()), 0o700); + for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] { + assert_eq!( + mode(dir.path().join(file)), + 0o600, + "{file} is not owner-only" + ); + } + } + + /// The product name is the whole of what is per-project, so it has to + /// reach the certificate rather than being decoration. + #[test] + fn the_product_names_the_certificate() { + let dir = tempfile::tempdir().expect("tempdir"); + ensure("dev-updater", dir.path(), &addresses()).expect("generate"); + let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca"); + + // Decoded rather than searched as base64: a distinguished name is + // stored in the DER as literal bytes, so this checks the value + // that actually reaches a phone rather than the value we passed in. + let der = decode_pem(&ca); + assert!( + contains(&der, b"dev-updater dev CA"), + "the CA's common name" + ); + assert!(contains(&der, b"dev-updater dev"), "the organisation"); + } +} diff --git a/server/src/enroll.rs b/server/src/enroll.rs new file mode 100644 index 0000000..d6f94cd --- /dev/null +++ b/server/src/enroll.rs @@ -0,0 +1,146 @@ +//! The bearer token a phone carries, and the QR code that gets it there. +//! +//! Pinning authenticates the server to the phone but never the phone to +//! the server, so the token supplies the other direction. Binding the +//! WireGuard interface (see [`crate::netif`]) narrows who can try at all; +//! this narrows it to who was enrolled. +//! +//! The token is 256 bits from the OS CSPRNG and is never typed by a +//! human -- it travels once, in a QR code printed to the terminal -- so +//! being unguessable costs nothing and there is no manual-entry path to +//! design around. +//! +//! Only the hash is ever stored. That is what makes the plaintext a +//! once-only artifact: it exists in the QR at generation time and nowhere +//! afterwards, and a lost phone is answered by rotating rather than by +//! looking the old one up. +//! +//! # Never log the token +//! +//! Nothing here, and nothing that calls it, may log the Authorization +//! header or the token itself. Both existing projects hold a test that +//! drives the rejection path under a capturing subscriber and asserts the +//! token does not appear in the output; that tripwire belongs with the +//! middleware, which stays in each project because it is generic over +//! that project's state. + +use std::net::IpAddr; + +use anyhow::{Context, Result}; +use base64::Engine; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +/// 256 bits from the OS CSPRNG, base64url. +pub fn generate_token() -> String { + use rand::Rng; + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// What the config stores instead of the token: hex SHA-256. +/// +/// A plain hash, not a password KDF, and deliberately: the input is 256 +/// random bits, so there is nothing to dictionary-attack and stretching +/// would buy only latency on every request. +pub fn token_hash_hex(token: &str) -> String { + Sha256::digest(token.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Whether `presented` matches any enrolled hash. +/// +/// The fold visits every entry regardless of an earlier match, so the +/// time taken does not say which entry matched, or whether the first one +/// did. +pub fn token_matches(presented: &str, stored_hashes: &[String]) -> bool { + let presented = token_hash_hex(presented); + stored_hashes.iter().fold(false, |matched, stored| { + matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes())) + }) +} + +/// The `://enroll?...` URI a QR code carries. +/// +/// The scheme is the caller's because it is what routes the scan back to +/// the right app -- `devupdater`, `aiapp` -- and it is the only part of +/// enrollment that is per-project. +pub fn enrollment_uri(scheme: &str, host: IpAddr, port: u16, token: &str) -> String { + format!("{scheme}://enroll?host={host}&port={port}&token={token}") +} + +/// Prints the one-time enrollment QR, and the URI under it for a person +/// who would rather paste than scan. +/// +/// Printed to stdout rather than through `tracing`: it is for the human +/// at the terminal, once, and a log line is the wrong shape for something +/// that has to be photographed. +/// +/// The QR carries no trust material. The CA is embedded in the app at +/// build time, so photographing the terminal leaks only the token, which +/// is rotatable. +pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> Result<()> { + let uri = enrollment_uri(scheme, host, port, token); + let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?; + let rendered = code + .render::() + .quiet_zone(true) + .build(); + println!("\n{rendered}\n"); + println!("Scan with the phone's camera to enroll (or paste into the app's settings):"); + println!(" {uri}"); + println!("The token is not stored in the clear and won't be shown again;"); + println!("a lost phone means re-running with --rotate-token.\n"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hashing_is_stable_and_tokens_verify() { + let token = generate_token(); + assert_eq!(token_hash_hex(&token), token_hash_hex(&token)); + assert_ne!(token, generate_token(), "tokens must not repeat"); + + let hashes = vec![token_hash_hex(&token), token_hash_hex("other")]; + assert!(token_matches(&token, &hashes)); + assert!(token_matches("other", &hashes)); + assert!(!token_matches("wrong", &hashes)); + assert!( + !token_matches(&token, &[]), + "no enrolled token matches nothing" + ); + } + + /// The hash is what gets stored, so it must not be the token, and it + /// must be the shape the config files already hold. + #[test] + fn the_stored_form_reveals_nothing_and_is_hex() { + let token = generate_token(); + let hash = token_hash_hex(&token); + assert_ne!(hash, token); + assert_eq!(hash.len(), 64); + assert!( + hash.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); + } + + /// The scheme is the only per-project part, and the app parses this + /// back -- so the shape is a contract, not a formatting choice. + #[test] + fn the_enrollment_uri_carries_scheme_host_port_and_token() { + let uri = enrollment_uri("devupdater", "10.66.0.1".parse().unwrap(), 8090, "tok"); + assert_eq!( + uri, + "devupdater://enroll?host=10.66.0.1&port=8090&token=tok" + ); + let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok"); + assert!(other.starts_with("aiapp://enroll?")); + } +} diff --git a/server/src/format.rs b/server/src/format.rs new file mode 100644 index 0000000..fd13aa9 --- /dev/null +++ b/server/src/format.rs @@ -0,0 +1,246 @@ +//! Reading and writing the RON both projects' config files are in. +//! +//! Two things are house rules rather than plain RON, and they are here +//! together because they are inverses of each other -- change one and the +//! other stops round-tripping. Both projects had this, identically, to the +//! byte; that is what made it the first thing worth sharing. +//! +//! **No outer parentheses.** A file *is* the body of the struct, so +//! nothing in it is indented for the sake of a wrapper. RON has no +//! implicit top-level struct (`de/mod.rs` requires the `(`), so [`parse`] +//! adds it and [`render`] takes it back off. The opening paren is not +//! followed by a newline, so a parse error's line number still points at +//! the real line. +//! +//! **`Some` is implicit.** Enabled on the deserializer rather than by a +//! `#![enable(implicit_some)]` header every file would have to remember, +//! and matched on the writing side by `skip_serializing_if` so nothing +//! writes back a `Some(...)` a person didn't type. The two halves only +//! round-trip together, which is why a caller's own tests should assert +//! the written shape rather than only that it loads. + +/// Reading and writing the RON these files are in. +/// +/// Two things are house rules rather than plain RON, and they are here +/// together because they are inverses of each other -- change one and the +/// other stops round-tripping. +/// +/// **No outer parentheses.** A file *is* the body of the struct, so nothing +/// in it is indented for the sake of a wrapper. RON has no implicit +/// top-level struct (`de/mod.rs` requires the `(`), so [`format::parse`] +/// adds it and [`format::render`] takes it back off. The opening paren is not followed by a +/// newline, so a parse error's line number still points at the real line. +/// +/// **`Some` is implicit.** Enabled on the deserializer rather than by a +/// `#![enable(implicit_some)]` header every project file would have to +/// remember, and matched on the writing side by `skip_serializing_if` so +/// nothing writes back a `Some(...)` a person didn't type. +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::private; + +fn options() -> ron::Options { + ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME) +} + +pub fn parse(text: &str) -> Result { + options().from_str(&format!("({text})")) +} + +pub fn render(value: &T) -> Result { + let pretty = ron::ser::PrettyConfig::new(); + let text = options().to_string_pretty(value, pretty)?; + Ok(unwrap_outer(&text)) +} + +/// Renders `value` and replaces `path` with it, atomically and owner-only. +/// +/// Whole-file-and-rename rather than an in-place edit, because both +/// projects' config files are small, are read at startup, and hold the +/// enrolled token hashes -- a half-written one would take the server down +/// on its next start with no way to fix it from a phone. The rename is +/// what makes a reader see either the old file or the new one and never +/// part of both. +/// +/// The temp file goes through [`private::write_file`] rather than +/// `std::fs::write`, and that is the subtle half: **the temp file is not +/// always new.** A save killed partway leaves one behind, and opening that +/// again keeps whatever mode it already had -- which is then renamed over +/// the file holding the token hashes. Setting the mode as it is opened +/// covers both the fresh and the leftover case. +pub fn write(path: &Path, value: &T) -> Result<()> { + if let Some(parent) = path.parent() { + private::create_dir(parent)?; + } + let text = render(value).context("serialize config")?; + // Appended rather than substituted, so `config.ron` yields + // `config.ron.tmp` and not `config.tmp` -- a name that cannot collide + // with a real file and that says what it is a temporary copy of. + let tmp = path.with_file_name(format!( + "{}.tmp", + path.file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("config")) + .to_string_lossy() + )); + private::write_file(&tmp, text.as_bytes())?; + std::fs::rename(&tmp, path) + .with_context(|| format!("replace {} with {}", path.display(), tmp.display())) +} + +/// Strips the outer `(`/`)` the writer always emits and removes the +/// indent level they cost. Deliberately narrow: it accepts only the +/// exact shape `PrettyConfig` produces, and leaves anything else alone +/// rather than guessing -- a file with stray parentheses is better than +/// one silently mangled. `parse` round-trips either way, since a +/// wrapped body parses the same as an unwrapped one re-wrapped. +fn unwrap_outer(text: &str) -> String { + let Some(body) = text + .strip_prefix("(\n") + .and_then(|rest| rest.strip_suffix("\n)")) + else { + return text.to_string(); + }; + let mut out: String = body + .lines() + .map(|line| line.strip_prefix(" ").unwrap_or(line)) + .collect::>() + .join("\n"); + out.push('\n'); + out +} + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + use super::*; + + #[derive(Debug, Default, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase", default)] + struct Demo { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + note: Option, + } + + /// The house rule both halves depend on: what is written is the *body* + /// of the struct, with no outer parentheses and nothing indented for + /// them. Asserted rather than trusted, because `render` strips what + /// `parse` adds -- if only one ever changed, every file on disk would + /// still load and only look wrong. + #[test] + fn a_file_is_the_body_of_the_struct() { + let written = render(&Demo { + name: "thing".to_string(), + note: Some("why".to_string()), + }) + .expect("render"); + + assert!( + !written.trim_start().starts_with('('), + "outer parens: {written}" + ); + assert!( + written.starts_with("name: "), + "top level sits at column 0: {written}" + ); + assert_eq!( + parse::(&written).expect("re-read").name, + "thing", + "what is written must read back", + ); + } + + /// An optional value is written as itself, never wrapped -- and a + /// value nobody set is not written at all, so a file stays readable as + /// what was actually chosen. + #[test] + fn an_optional_value_is_written_as_itself_or_not_at_all() { + let with = render(&Demo { + name: "a".to_string(), + note: Some("b".to_string()), + }) + .expect("render"); + assert!( + with.contains(r#"note: "b""#), + "no Some(...) wrapper: {with}" + ); + + let without = render(&Demo::default()).expect("render"); + assert!( + !without.contains("note"), + "an unset value writes nothing: {without}" + ); + + // And the bare form reads back, which is the other half. + assert_eq!( + parse::("name: \"a\",\nnote: \"b\",\n") + .expect("parse") + .note + .as_deref(), + Some("b") + ); + } + + #[test] + fn what_is_written_reads_back_and_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("nested").join("config.ron"); + let value = Demo { + name: "thing".to_string(), + note: None, + }; + + write(&path, &value).expect("write"); + + assert_eq!( + parse::(&std::fs::read_to_string(&path).expect("read")).expect("re-read"), + value, + ); + let mode = std::fs::metadata(&path).expect("stat").permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "config holds token hashes: {mode:o}"); + assert!( + !path.with_extension("ron.tmp").exists(), + "the temp file is renamed away, not left behind", + ); + } + + /// The case the whole thing turns on, and the one that cannot happen on + /// a machine where nothing has ever crashed mid-save: a leftover temp + /// file from an interrupted write is reopened, and if its mode came + /// along it would be renamed straight over the file holding the enrolled + /// token hashes. + #[test] + fn a_leftover_temp_file_cannot_widen_the_config() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.ron"); + let tmp = dir.path().join("config.ron.tmp"); + + std::fs::write(&tmp, b"leftover from a save that died").expect("stale temp"); + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).expect("widen"); + + write(&path, &Demo::default()).expect("write"); + + let mode = std::fs::metadata(&path).expect("stat").permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "a world-readable leftover must not become the config: {mode:o}", + ); + } + + /// A parse error's line number has to point at the real line, which is + /// why the opening paren is not followed by a newline. + #[test] + fn a_parse_error_points_at_the_line_it_is_on() { + let err = parse::("name: \"a\",\nnote: ,\n").expect_err("malformed"); + assert_eq!(err.span.start.line, 2, "{err}"); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..608d72e --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,35 @@ +//! What dev-updater and ai-app both need in order to be reached from a +//! phone, and nothing either of them does afterwards. +//! +//! Both projects are the same shape underneath: a server on a machine +//! somebody owns, bound to a WireGuard interface so it is not on the LAN, +//! presenting a certificate from a CA the app pins, and answering only +//! requests carrying a bearer token that was enrolled by scanning a QR +//! code off the terminal. None of that is about serving APKs or running +//! model sessions -- it is the link, and it was written twice. +//! +//! # What belongs here +//! +//! Anything that would be *identical* in a third such project. The test +//! applied to each module below was to diff the two existing copies: if +//! the only differences were a product name and which state type the code +//! was generic over, it came here. +//! +//! # What deliberately does not +//! +//! The API surfaces. dev-updater's routes are about projects and builds, +//! ai-app's about sessions and providers, and their HTTP clients have +//! diverged to 14% similarity because they are genuinely different +//! programs. Sharing a transport is worth doing; sharing an API would mean +//! inventing a common vocabulary neither project wants. +//! +//! Config *schemas*, for the same reason -- though the RON house rules +//! that both files are written in are shared, since those were identical +//! to the byte. + +pub mod certs; +pub mod enroll; +pub mod format; +pub mod netif; +pub mod private; +pub mod xdg; diff --git a/server/src/netif.rs b/server/src/netif.rs new file mode 100644 index 0000000..ddd4975 --- /dev/null +++ b/server/src/netif.rs @@ -0,0 +1,180 @@ +//! Which address to bind, and which addresses the certificate must cover. +//! +//! Both projects bind the WireGuard interface and nothing else, so that +//! neither is reachable from the LAN. That is the outer of two gates -- +//! the tunnel decides who can try, the token (see [`crate::enroll`]) +//! decides who is answered -- and it is worth having on its own account: +//! an unenrolled scanner never reaches the token check, and a plain-HTTP +//! bootstrap port travels inside the tunnel's encryption. + +use std::net::IpAddr; + +use anyhow::{Context, Result}; + +/// The interface both projects bind. A constant rather than a parameter +/// because a second answer would mean two ideas of what "the tunnel" is. +pub const WG_INTERFACE: &str = "wg0"; + +/// The alias an Android emulator reaches its host by. Not a real +/// interface anywhere, which is why it has to be added by hand. +const EMULATOR_HOST_ALIAS: [u8; 4] = [10, 0, 2, 2]; + +/// Every address this machine answers on, for the leaf certificate's SANs +/// -- so it covers whatever the phone actually dials without anyone +/// maintaining a hardcoded IP. +/// +/// Loopback is included for curl and tests, and the emulator's host alias +/// so a debug build can reach a server running beside it. +/// +/// Failing to enumerate is not fatal: the certificate still covers +/// loopback, which is enough to start and to diagnose from the machine +/// itself. +pub fn local_addresses() -> Vec { + match if_addrs::get_if_addrs() { + Ok(interfaces) => addresses_among(interfaces.iter().map(|iface| iface.ip())), + Err(err) => { + tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"); + addresses_among(std::iter::empty()) + } + } +} + +/// The SAN list built from `found`, which is the part worth testing. +/// +/// Split from the lookup so the outcome does not depend on what this +/// machine happens to have; see [`wg_address_among`] for the same +/// reasoning stated at length. +pub fn addresses_among(found: impl IntoIterator) -> Vec { + let mut addresses = vec![ + IpAddr::from([127, 0, 0, 1]), + IpAddr::from(EMULATOR_HOST_ALIAS), + ]; + for ip in found { + if ip.is_ipv4() && !addresses.contains(&ip) { + addresses.push(ip); + } + } + addresses +} + +/// The IPv4 address on the WireGuard interface, or a refusal to start. +/// +/// Failing closed rather than falling back to 0.0.0.0 is the point. The +/// escape hatch belongs to the caller as an explicit `--bind`, because +/// each of these servers is also how something stranded gets recovered, +/// and that recovery should not depend on the tunnel being healthy. +/// +/// `product` names the binary in the failure, so the message reads as +/// advice rather than as a library complaining. +pub fn wg_address(product: &str) -> Result { + let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?; + wg_address_among( + product, + interfaces + .iter() + .map(|iface| (iface.name.as_str(), iface.ip())), + ) +} + +/// The same answer, from interfaces the caller supplies. +/// +/// This exists because the failure is the interesting half -- it is the +/// message somebody reads when nothing works -- and it cannot be reached +/// on any machine this runs on, all of which have the tunnel up. A test +/// that calls [`wg_address`] and skips when it succeeds passes everywhere +/// and checks nothing, which is the shape of test this project has been +/// bitten by more than once. +pub fn wg_address_among<'a>( + product: &str, + interfaces: impl IntoIterator, +) -> Result { + interfaces + .into_iter() + .find(|(name, ip)| *name == WG_INTERFACE && ip.is_ipv4()) + .map(|(_, ip)| ip) + .ok_or_else(|| { + anyhow::anyhow!( + "no IPv4 address on interface {WG_INTERFACE} -- {product} binds only to the \ + WireGuard tunnel, so that only enrolled peers can reach its API. Bring the \ + tunnel up, or pass --bind 0.0.0.0 to serve the LAN while recovering." + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ip(s: &str) -> IpAddr { + s.parse().expect("an address") + } + + /// Whatever the machine has, the two that are not interfaces must be + /// there -- loopback for tests and curl, the alias for an emulator -- + /// and nothing may appear twice, since these become certificate SANs. + #[test] + fn the_certificate_always_covers_loopback_and_the_emulator_alias() { + let found = [ip("192.168.1.5"), ip("10.66.0.1"), ip("192.168.1.5")]; + let addresses = addresses_among(found); + + assert!(addresses.contains(&ip("127.0.0.1"))); + assert!(addresses.contains(&ip("10.0.2.2"))); + assert!( + addresses.contains(&ip("10.66.0.1")), + "the tunnel's own address" + ); + + let mut seen = addresses.clone(); + seen.sort(); + seen.dedup(); + assert_eq!(seen.len(), addresses.len(), "duplicate SANs: {addresses:?}"); + assert!(addresses.iter().all(|ip| ip.is_ipv4())); + } + + /// A machine with nothing at all still gets a usable certificate, + /// because failing to enumerate must not leave the server unable to + /// answer on loopback and diagnose itself. + #[test] + fn a_machine_with_no_interfaces_still_gets_a_usable_certificate() { + assert_eq!( + addresses_among(std::iter::empty()), + [ip("127.0.0.1"), ip("10.0.2.2")] + ); + } + + #[test] + fn the_tunnel_is_found_by_name_and_only_over_ipv4() { + let found = wg_address_among( + "demo-server", + [ + ("lo", ip("127.0.0.1")), + ("eth0", ip("192.168.1.5")), + (WG_INTERFACE, ip("10.66.0.1")), + ], + ); + assert_eq!(found.expect("the tunnel"), ip("10.66.0.1")); + + // An interface of the right name carrying only IPv6 is not an + // answer: both listeners bind an IPv4 socket. + let v6_only = wg_address_among("demo-server", [(WG_INTERFACE, ip("fd00::1"))]); + assert!(v6_only.is_err(), "IPv6 on wg0 is not the address to bind"); + } + + /// The failure is the thing somebody reads at 2am, so it has to name + /// the binary, the interface, and the way out. + /// + /// Reached by handing in an empty interface list rather than by hoping + /// the machine has no tunnel. Every machine this runs on has one up, + /// so a test that called the real lookup and skipped on success would + /// pass everywhere and assert nothing -- which is exactly what it did + /// before this was rewritten. + #[test] + fn a_missing_tunnel_explains_itself() { + let err = wg_address_among("demo-server", []) + .expect_err("no tunnel") + .to_string(); + assert!(err.contains("demo-server"), "names the binary: {err}"); + assert!(err.contains(WG_INTERFACE), "names the interface: {err}"); + assert!(err.contains("--bind"), "names the way out: {err}"); + } +} diff --git a/server/src/private.rs b/server/src/private.rs new file mode 100644 index 0000000..4ea4c0e --- /dev/null +++ b/server/src/private.rs @@ -0,0 +1,178 @@ +//! Creating files and directories this server alone can read. +//! +//! Everything a server writes outside its repo goes through here: the +//! config holding token hashes, the TLS private keys, and whatever state +//! it keeps. One module owns the modes, so "owner-only" is a property +//! that can be checked in one place rather than re-argued at every +//! `create`. +//! +//! Taken from ai-app, which had factored this out; dev-updater still has +//! the same logic inline in two places, which is the duplication this +//! crate exists to end. + +use std::fs::File; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; +use std::path::Path; + +use anyhow::{Context, Result}; + +/// Creates `dir` and its parents, owner-accessible only. +/// +/// The mode is set again after creation, deliberately: `DirBuilder::mode` +/// applies only when the directory is actually created, so one that +/// already existed -- made by hand, or by an older version -- would +/// otherwise keep whatever permissions it had while holding a private key. +pub fn create_dir(dir: &Path) -> Result<()> { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir) + .with_context(|| format!("create {}", dir.display()))?; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("restrict {}", dir.display())) +} + +/// Writes `contents` to `path`, owner-readable only. +/// +/// The mode is set as the file is opened rather than chmod-ed afterwards, +/// so it is never briefly world-readable at its real path. +pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> { + use std::io::Write; + let mut file = create_file(path)?; + file.write_all(contents) + .with_context(|| format!("write {}", path.display())) +} + +/// Opens `path` for writing, owner-readable only, truncating what is +/// there. For a caller that streams rather than holding the whole body. +pub fn create_file(path: &Path) -> Result { + restrict( + path, + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("write {}", path.display()))?, + ) +} + +/// Opens `path` for appending, owner-readable only, creating it if needed. +/// +/// The append case is separate because a transcript must never be +/// truncated by being opened, and the two differ by one flag that is easy +/// to get wrong in a hurry. +pub fn append_file(path: &Path) -> Result { + restrict( + path, + std::fs::OpenOptions::new() + .append(true) + .create(true) + .mode(0o600) + .open(path) + .with_context(|| format!("append to {}", path.display()))?, + ) +} + +/// Narrows an already-open file to owner-only. +/// +/// `OpenOptions::mode` applies **only when the file is created**, so +/// opening one that already exists silently keeps whatever mode it had -- +/// which for an append helper is the normal case rather than the odd one, +/// and for a truncating one happens on every rewrite after the first. +/// Setting it through the handle rather than the path closes the window +/// where something could swap the path between the two. +/// +/// The same reasoning as [`create_dir`]'s second call, and it was missed +/// here first: a file made wrong by an older version, or by hand, would +/// otherwise stay wrong for as long as it is only ever appended to. +fn restrict(path: &Path, file: File) -> Result { + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("restrict {}", path.display()))?; + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mode_of(path: &Path) -> u32 { + std::fs::metadata(path).expect("stat").permissions().mode() & 0o777 + } + + #[test] + fn a_directory_is_owner_only_even_if_it_already_existed() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("state"); + + // Made by hand, wide open -- what an older version or a person + // might leave behind. + std::fs::create_dir(&target).expect("mkdir"); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + + create_dir(&target).expect("create_dir"); + assert_eq!( + mode_of(&target), + 0o700, + "an existing directory must be restricted too" + ); + } + + /// The case `OpenOptions::mode` cannot cover, because it applies only + /// at creation: a file that already exists, made wrong earlier, and + /// opened again. An append-only transcript hits this on every write + /// after the first. + #[test] + fn an_existing_file_made_wrong_is_narrowed_on_open() { + let dir = tempfile::tempdir().expect("tempdir"); + create_dir(dir.path()).expect("create_dir"); + + for (name, open) in [ + ("appended", append_file as fn(&Path) -> Result), + ("rewritten", create_file as fn(&Path) -> Result), + ] { + let path = dir.path().join(name); + std::fs::write(&path, b"made by an older version").expect("write"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod"); + assert_eq!(mode_of(&path), 0o644, "precondition"); + + open(&path).expect("open"); + assert_eq!( + mode_of(&path), + 0o600, + "{name} kept a mode somebody else could read" + ); + } + } + + #[test] + fn files_are_owner_only_from_the_moment_they_exist() { + let dir = tempfile::tempdir().expect("tempdir"); + create_dir(dir.path()).expect("create_dir"); + + let written = dir.path().join("key.pem"); + write_file(&written, b"secret").expect("write"); + assert_eq!(mode_of(&written), 0o600); + assert_eq!(std::fs::read(&written).expect("read"), b"secret"); + + let appended = dir.path().join("transcript.jsonl"); + { + use std::io::Write; + let mut file = append_file(&appended).expect("append"); + file.write_all(b"one\n").expect("write"); + } + { + use std::io::Write; + let mut file = append_file(&appended).expect("append"); + file.write_all(b"two\n").expect("write"); + } + assert_eq!(mode_of(&appended), 0o600); + // The whole point of the separate opener: opening again must not + // have truncated what was there. + assert_eq!( + std::fs::read_to_string(&appended).expect("read"), + "one\ntwo\n" + ); + } +} diff --git a/server/src/xdg.rs b/server/src/xdg.rs new file mode 100644 index 0000000..5d08e19 --- /dev/null +++ b/server/src/xdg.rs @@ -0,0 +1,94 @@ +//! Where each project keeps the state that must not live in its repo. +//! +//! Both servers hold the same three things outside their checkout -- the +//! config with its token hashes, the CA's private key, and whatever state +//! the product itself keeps -- and both resolve the location the same way. +//! That is not a coincidence of style: it is forced by the arrangement the +//! two projects share. +//! +//! The repo is a virtiofs mount shared between a machine and a VM at +//! *different* absolute paths, so a config inside it would record paths +//! that resolve on only one side -- which is exactly how dev-updater came +//! to show "not built" for every project on one of them. And the mount is +//! writable by the untrusted side, so a CA private key inside it would let +//! that side mint a leaf the pinned app trusts, which voids the pinning +//! the rest of this crate exists to provide. +//! +//! Per machine, therefore, and under the XDG directories rather than a +//! path of our own choosing, so that a person's existing backup and +//! sync rules already cover it. + +use std::ffi::OsString; +use std::path::PathBuf; + +/// `$XDG_CONFIG_HOME/`, or `~/.config/`. +/// +/// Holds `config.ron` and `certs/`. `product` is a parameter rather than +/// a constant because the whole point is that two products keep their +/// state apart while resolving it identically. +pub fn config_home(product: &str) -> PathBuf { + xdg_dir(std::env::var_os("XDG_CONFIG_HOME"), ".config", product) +} + +/// `$XDG_DATA_HOME/`, or `~/.local/share/`. +/// +/// Holds whatever the product accumulates rather than is configured with +/// -- ai-app's session transcripts and attachments, dev-updater's builds. +pub fn data_home(product: &str) -> PathBuf { + xdg_dir(std::env::var_os("XDG_DATA_HOME"), ".local/share", product) +} + +/// The resolution both of the above use, with the environment handed in. +/// +/// Split out so the rules can be tested without setting process-wide +/// environment variables, which two tests running in parallel cannot do +/// without racing each other. +/// +/// A relative value is ignored rather than resolved, per the XDG spec: a +/// server started from a different working directory would otherwise look +/// for its token hashes somewhere new and generate a fresh enrollment, +/// silently locking out the phone that was already enrolled. +fn xdg_dir(base: Option, fallback: &str, product: &str) -> PathBuf { + base.map(PathBuf::from) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| { + std::env::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(fallback) + }) + .join(product) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_absolute_setting_is_used_and_namespaced_by_product() { + assert_eq!( + xdg_dir(Some("/somewhere".into()), ".config", "ai-app"), + PathBuf::from("/somewhere/ai-app"), + ); + assert_eq!( + xdg_dir(Some("/somewhere".into()), ".config", "dev-updater"), + PathBuf::from("/somewhere/dev-updater"), + ); + } + + #[test] + fn an_unset_value_falls_back_under_home() { + let dir = xdg_dir(None, ".local/share", "ai-app"); + assert!(dir.ends_with("ai-app")); + assert!(dir.parent().expect("parent").ends_with("share")); + } + + /// A relative setting lands on the same path as no setting at all, + /// rather than on something that moves with the working directory. + #[test] + fn a_relative_setting_is_ignored_rather_than_resolved() { + assert_eq!( + xdg_dir(Some("relative/path".into()), ".config", "ai-app"), + xdg_dir(None, ".config", "ai-app"), + ); + } +}