Compare commits

..
6 Commits
Author SHA1 Message Date
iris 841a3a8372 Make the crate do what its documentation already claimed
Review from ai-app's session, acted on.

**A test that asserted nothing.** `a_missing_tunnel_explains_itself`
returned early whenever `wg0` was up -- which it is on this VM and on the
host, so it passed everywhere and never once checked the message. The same
shape of test that let the file-permissions bug survive three codebases.
`netif` now splits the lookup from the decision: `wg_address_among` and
`addresses_among` take the interfaces, so the failure is reachable by
handing in an empty list rather than by hoping the machine has no tunnel.
Four tests where there were two, including that an IPv6-only `wg0` is not
an answer.

**Two modules the docs promised and the crate did not have.** `certs`,
which is the piece where being written twice is worst -- a trust anchor
built two ways can be built differently two ways, and the difference
reaches a phone as an opaque handshake failure. And `format`, the RON
house rules, which were byte-identical in both projects and so the
clearest thing in the evidence table. `product` names the certificate and
is the whole of what is per-project; the test decodes the DER and looks
for it there rather than trusting what was passed in.

**The README title still said wg-server-app**, three commits after
everything else was renamed.

Two judgement calls promoted from silent to written down, both of which
would otherwise be inherited rather than chosen: `WG_INTERFACE` is a
constant because *these* projects have one tunnel, which is the first
thing a third user should expect to change; and `local_addresses` puts
the emulator's host alias in every certificate, a SAN for an address the
machine does not own.

And a review heuristic the day kept proving: when you find a rule stated,
grep for its siblings. Three bugs today were the correct rule already
written down and applied to one member of a set.
2026-08-28 14:34:48 -04:00
iris 592114bfc9 Name it wg-app-link
`wg-link` was not merely vague. `wg` and `ip link` are both real network
tools, so it reads as something that manages a WireGuard interface --
misleading rather than ambiguous. `app` rules that reading out, and says
which two ends the link is between.

The repository is still called wg-server-app until it is renamed on
gitea; the crate leads.
2026-08-28 13:34:30 -04:00
iris d6d802c3c7 Record the bug the extraction found, and why nobody had caught it
Three copies, one bug, and the correct reasoning already written down in
a comment three functions above the code that needed it. That is an
argument about review rather than about duplication, and it is more
persuasive than any of the similarity percentages above it.
2026-08-28 13:30:41 -04:00
iris 861e6a329b Narrow a file that already existed, not just one being created
`OpenOptions::mode` applies only when the file is created, so opening one
that already exists keeps whatever mode it had. For `append_file` that is
the normal case rather than the odd one -- every write to a transcript
after the first -- and for `create_file` it happens on every rewrite. A
file made wrong by an older version or by hand would have stayed
world-readable for as long as it was only ever appended to.

This is the same reasoning as `create_dir`'s second call, which was
already here and already commented. Missing it on the file paths was the
kind of gap that only shows when the two are read side by side, which is
an argument for this crate existing rather than against it.

Set through the open handle rather than the path, so nothing can swap
what is at that path between the open and the chmod.

Caught by ai-app's session reviewing the module I had taken from their
code, which is the review this repo is supposed to make possible.
2026-08-28 13:30:41 -04:00
iris 7651d491ac The link both projects wrote twice
dev-updater serves APKs to a phone and ai-app runs model sessions for
one. Above the waterline they share nothing. Underneath they are the
same program: bound to wg0 so they are not on the LAN, presenting a
certificate from a CA the app pins, answering only requests carrying a
token enrolled by scanning a QR off the terminal, keeping state in
owner-only files outside the repo.

Three modules, each extracted only after diffing the two copies and
finding nothing between them but a product name and a type parameter.
netif fails closed when the tunnel is down. enroll generates, stores and
compares the token, and prints the QR, with the URI scheme as the one
per-project part. private owns the file modes, taken from ai-app's
version because it had already factored out what dev-updater still has
inline in two places.

Nothing is removed from either project. This is a proposal with a
working core, and the README carries the measured evidence -- the
enrollment scanner activity differs by its package line and nothing
else, the RON format module is byte-identical, and the two copies have
each drifted into holding an improvement the other lacks, which is the
cost being paid today.
2026-08-28 13:30:41 -04:00
iris 995b29f10d Initial commit 2026-08-28 13:25:51 -04:00
13 changed files with 18 additions and 819 deletions

No files matched your search

-4
View File
@@ -1,5 +1 @@
server/target/
# Gradle build outputs for the app half.
app/build/
.gradle/
+4 -53
View File
@@ -94,70 +94,21 @@ 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
`server/` — the `wg-app-link` crate. Five 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 `<scheme>://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.
- **`format`** — the two RON house rules. This was byte-identical in both projects, which made it the clearest thing in the evidence table and the easiest deletion.
- **`private`** — owner-only files and directories, taken from ai-app's version, with an `append_file` alongside `create_file` because a transcript must never be truncated by being opened.
- **`xdg`** — where each project's state lives: `config_home(product)` and `data_home(product)`, resolving the XDG variable, ignoring it unless absolute, and falling back under `$HOME`. Shared because the *reason* is shared and is not obvious from the code — the repo is a mount that resolves at different absolute paths on each side, so config inside it records paths that work on only one, and a CA private key inside it would let the untrusted side mint a leaf the pinned app trusts.
`app/` — the Android library, `com.example.wgapplink`. The same test applied
to Kotlin: the four files appeared in both apps and differed only in
comments and a product name.
- **`PinnedTls`** — trusts one CA and not the system store, so a genuine certificate for another host is refused as firmly as a self-signed one. The PEM is constructed with, not read, because each app generates its own at build time from the machine doing the build.
- **`ServerStore`** — the enrollment: preferences, the `<scheme>://enroll?…` URI, and the token sealed under an Android Keystore key. Two parameters, both per-app and both load-bearing — see the warning below.
- **`EnrollmentScanActivity`** — zxing's capture activity with the 10% framing inset and the laser decorations removed, so framing is never the user's problem.
- **`localNetworkAllowed`** — whether `ACCESS_LOCAL_NETWORK` was granted. Android 17 made it mandatory and a denial is invisible at the socket, so without asking, a blocked app and an unreachable server produce the same timeout.
**Adopting the app half takes two lines and one catalog entry.** In
`settings.gradle.kts`:
```kotlin
include(":link")
project(":link").projectDir = file("../wg-app-link/app")
```
and in the version catalog, because a subproject resolves plugin versions
from the build including it rather than from its own:
```toml
androidLibrary = { id = "com.android.library", version.ref = "agp" }
```
It also needs `androidx-core-ktx` and `zxing-embedded` in that catalog, and
`alias(libs.plugins.androidLibrary) apply false` in the root build file.
Consumed as a subproject rather than a published artifact so the two stay
locked to whatever commit the submodule points at — the same arrangement
the Rust half uses with a path dependency.
> **The Keystore alias is persisted. Carry the existing value over exactly.**
> `ServerStore(scheme, keyAlias)` takes both because both are per-app, but
> they fail differently. A wrong scheme means a scanned QR is ignored, which
> is visible immediately. A wrong `keyAlias` means the app can no longer
> unseal the token it already stored, so an enrolled phone silently reads as
> not enrolled with nothing on screen to say why. ai-app's is
> `aiapp-token-key`; dev-updater's is `dev-updater-token-key`.
**Not moved: the `PinnedCaCertificate.kt` generator.** It is ~40 lines of
Gradle in each app's build file, and all three things it varies — the
environment variable, the certificate path, and the package the constant is
emitted into — are per-app, so sharing it means parameterising the whole of
it and introducing a composite build that neither project has today. The
one bug it has had is fixed identically in both copies: a PEM constant must
start at the opening quotes, or `CertificateFactory` loses its `-----BEGIN`
preamble sniff, tries DER, and fails at runtime with an ASN.1 decode error
nowhere near the generator. Worth revisiting if it ever needs a second fix.
## What should follow, and what should not
**Should follow, in this order.** Each is already near-identical:
1. ~~The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`.~~ 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.
1. The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in *both* directions — and `applyPinnedTls` is security-critical and identical, which is the same argument `certs` won on.
2. Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs.
**Should not.** Naming these is the point of the exercise:
-52
View File
@@ -1,52 +0,0 @@
// The app half of the link, as a library both phones' apps depend on.
//
// Consumed as a *subproject* of each app's build rather than as a published
// artifact or a composite build, so the two stay version-locked to whatever
// commit of this repo the consuming project's submodule points at -- the
// same arrangement the Rust half uses with a path dependency. Each app adds
// two lines to its `settings.gradle.kts`:
//
// include(":link")
// project(":link").projectDir = file("../wg-app-link/app")
//
// and one entry to its version catalog, since a subproject resolves plugin
// versions from the build including it:
//
// androidLibrary = { id = "com.android.library", version.ref = "agp" }
//
// Deliberately no Compose. Nothing here draws anything -- the screens that
// use it are each app's own, because they are where the two products
// actually differ.
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.ktfmt)
}
ktfmt { kotlinLangStyle() }
android {
namespace = "com.example.wgapplink"
compileSdk = 37
// minSdk matches the lower of the two consumers rather than this
// library's own preference: a library that raised it would silently
// raise theirs. No desugaring here for the same reason -- nothing in
// this module uses java.time, and requiring it would impose a build
// setting on anyone who depends on it.
defaultConfig { minSdk = 24 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
dependencies {
// SharedPreferences.edit, and the KTX extensions this code calls
// directly rather than inheriting transitively.
implementation(libs.androidx.core.ktx)
// The capture activity EnrollmentScanActivity subclasses. `api` rather
// than `implementation`: the subclass is part of this module's public
// surface, so a consumer declaring it in its manifest needs the type.
api(libs.zxing.embedded)
}
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Nothing is declared here. EnrollmentScanActivity is registered by each
consuming app's own manifest, because the activity's label, theme and
parent are the app's business and merging a fixed one would take that
choice away. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -1,30 +0,0 @@
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
}
}
@@ -1,70 +0,0 @@
package com.example.wgapplink
import java.io.ByteArrayInputStream
import java.net.HttpURLConnection
import java.security.KeyStore
import java.security.cert.CertificateFactory
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* TLS that trusts one CA and nothing else -- not the device's system trust store, so a genuine
* CA-issued certificate for some other host is refused just as firmly as a self-signed one.
*
* The certificate is passed in rather than read from anywhere, because each app generates its own
* at build time from the CA on the machine doing the build. That is what makes the trust anchor
* follow the build: an APK built on the backend host trusts that host, and one built in a dev VM
* trusts that VM's throwaway CA and is good only for its emulator. Neither app should ever have a
* second anchor to get wrong, which is also why enrollment carries no CA -- photographing the
* terminal leaks only the rotatable token.
*
* Hold one of these per app and reuse it: the [socketFactory] is built lazily and then kept, since
* every reconnect would otherwise redo the KeyStore and TrustManager setup from scratch.
*/
class PinnedTls(private val caPem: String) {
val socketFactory: SSLSocketFactory by lazy {
// Trimmed because CertificateFactory only recognises PEM when the
// "-----BEGIN" preamble is the very first thing it sees; surrounding
// whitespace sends it down the DER path instead, which fails at
// runtime with an ASN.1 decode error nowhere near the cause.
val caCert =
CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(caPem.trim().encodeToByteArray()))
val keyStore =
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
// The alias names an entry in a KeyStore that exists only in
// memory for the length of this block, so it is arbitrary and
// deliberately not per-app -- nothing reads it back.
setCertificateEntry(PINNED_CA_ALIAS, caCert)
}
val trustManager =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
.apply { init(keyStore) }
.trustManagers
.filterIsInstance<X509TrustManager>()
.first()
SSLContext.getInstance("TLS")
.apply { init(null, arrayOf(trustManager), null) }
.socketFactory
}
/**
* Pins [connection], which must be every connection the app makes -- there is no unpinned path
* in either project, and adding one would quietly undo the whole arrangement.
*
* A plain HTTP connection is left alone rather than refused, since dev-updater serves its
* bootstrap download over one deliberately.
*/
fun applyTo(connection: HttpURLConnection) {
if (connection is HttpsURLConnection) {
connection.sslSocketFactory = socketFactory
}
}
}
private const val PINNED_CA_ALIAS = "pinned-ca"
@@ -1,177 +0,0 @@
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
+4 -1
View File
@@ -155,7 +155,10 @@ mod tests {
}
fn addresses() -> Vec<IpAddr> {
vec!["192.168.1.5".parse().unwrap(), "127.0.0.1".parse().unwrap()]
vec![
"192.168.1.168".parse().unwrap(),
"127.0.0.1".parse().unwrap(),
]
}
#[test]
+9 -240
View File
@@ -24,10 +24,7 @@
//! middleware, which stays in each project because it is generic over
//! that project's state.
use std::fs;
use std::net::IpAddr;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use base64::Engine;
@@ -71,62 +68,8 @@ pub fn token_matches(presented: &str, stored_hashes: &[String]) -> bool {
/// 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.
///
/// `ca_pem` is the trust anchor, and is optional because the two projects
/// answer "where does the app get the CA?" differently. An app built on
/// the machine its server runs on pins the CA at build time and needs
/// nothing here (pass `None`); one built somewhere else -- ai-app's iris
/// client is cross-compiled in a VM and run against the host's server --
/// cannot, so the CA travels with the link instead. See [`ca_param`] for
/// the encoding and what it costs a QR code.
pub fn enrollment_uri(
scheme: &str,
host: IpAddr,
port: u16,
token: &str,
ca_pem: Option<&str>,
) -> Result<String> {
let mut uri = format!("{scheme}://enroll?host={host}&port={port}&token={token}");
if let Some(ca_pem) = ca_pem {
uri.push_str("&ca=");
uri.push_str(&ca_param(ca_pem)?);
}
Ok(uri)
}
/// The `ca` parameter's value for one PEM certificate: its DER, base64url
/// without padding, so it needs no percent-encoding and survives every
/// splitter a link passes through.
///
/// **The parameter is optional and unrecognised keys are ignored**, so a
/// link carrying it still enrolls an app that predates it -- which is what
/// makes adding it a compatible change to a format three languages parse
/// (this crate mints it; `client_core::config` and `ServerStore.kt` read
/// it).
///
/// Costs a QR code real estate: measured on ai-app's own P-256 CA, a link
/// goes from 89 bytes to 652 and its terminal QR from 45 to 93 columns.
/// That is why the parameter is the minter's choice per call rather than
/// always present.
pub fn ca_param(ca_pem: &str) -> Result<String> {
let der = pem_der(ca_pem)?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(der))
}
/// The DER inside a PEM certificate: everything between the BEGIN and END
/// lines, whitespace removed, base64-decoded.
fn pem_der(pem: &str) -> Result<Vec<u8>> {
const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
const END: &str = "-----END CERTIFICATE-----";
let body = pem
.split_once(BEGIN)
.and_then(|(_, rest)| rest.split_once(END))
.map(|(body, _)| body)
.context("not a PEM certificate (no BEGIN/END CERTIFICATE lines)")?;
let body: String = body.chars().filter(|c| !c.is_whitespace()).collect();
base64::engine::general_purpose::STANDARD
.decode(body.as_bytes())
.context("a PEM certificate's body is not base64")
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
@@ -136,17 +79,11 @@ fn pem_der(pem: &str) -> Result<Vec<u8>> {
/// at the terminal, once, and a log line is the wrong shape for something
/// that has to be photographed.
///
/// The QR carries no *secret* beyond the token, which is rotatable:
/// `ca_pem`, where the caller passes one, is a public certificate, so
/// photographing the terminal still leaks only the token.
pub fn print_enrollment(
scheme: &str,
host: IpAddr,
port: u16,
token: &str,
ca_pem: Option<&str>,
) -> Result<()> {
let uri = enrollment_uri(scheme, host, port, token, ca_pem)?;
/// 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::<qrcode::render::unicode::Dense1x2>()
@@ -160,74 +97,6 @@ pub fn print_enrollment(
Ok(())
}
/// How long a spooled enrolment stays valid unused. The link is meant to be
/// opened straight away, by the tool that asked for it; one that was never
/// opened should not stay a valid credential on disk.
pub const PENDING_TTL: Duration = Duration::from_secs(60 * 60);
/// Records a token minted by another process for the running server to
/// adopt on first use -- see [`take_pending`].
///
/// Why a spool rather than writing the config: the server holds its config
/// in memory and writes it back whole, so a second process appending a
/// token to the file loses the race with the next save, silently. Here the
/// other process writes only into `dir` (created private to the user), one
/// file per token, named by the hash and holding the device name; the
/// server owns the config as before and moves the entry across itself.
/// Only the hash touches disk, as with every stored token.
pub fn spool_pending(dir: &Path, name: &str, token: &str) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
.with_context(|| format!("restrict {}", dir.display()))?;
}
let path = dir.join(token_hash_hex(token));
fs::write(&path, name).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
/// Adopts a spooled token if `presented` is one: returns the device name
/// it was spooled under and removes the entry, so a spooled token is
/// consumed exactly once and belongs to the config from then on. Anything
/// older than [`PENDING_TTL`] is removed rather than honoured.
///
/// A missing directory is the common case -- nothing has ever been
/// spooled -- and answers `None` like an empty one.
pub fn take_pending(dir: &Path, presented: &str) -> Result<Option<String>> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).with_context(|| format!("read {}", dir.display())),
};
let wanted = token_hash_hex(presented);
let mut found = None;
for entry in entries {
let entry = entry.with_context(|| format!("read {}", dir.display()))?;
let path = entry.path();
let fresh = entry
.metadata()
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age < PENDING_TTL);
if !fresh {
let _ = fs::remove_file(&path);
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if bool::from(name.as_bytes().ct_eq(wanted.as_bytes())) {
found = Some(path);
}
}
let Some(path) = found else { return Ok(None) };
let device = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
Ok(Some(device))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -266,112 +135,12 @@ mod tests {
/// 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",
None,
)
.unwrap();
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", None).unwrap();
let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok");
assert!(other.starts_with("aiapp://enroll?"));
}
/// The CA rides as base64url of the DER, appended to the same link --
/// so a reader that ignores unknown keys sees exactly the link above.
#[test]
fn a_ca_rides_in_the_link_as_url_safe_base64_der() {
let der = [0x30u8, 0x82, 0x01, 0xfb, 0x3e, 0x7f];
let pem = format!(
"-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n",
base64::engine::general_purpose::STANDARD.encode(der)
);
let uri = enrollment_uri(
"aiapp",
"10.66.0.1".parse().unwrap(),
8443,
"tok",
Some(&pem),
)
.unwrap();
let (base, ca) = uri.split_once("&ca=").expect("the ca parameter");
assert_eq!(base, "aiapp://enroll?host=10.66.0.1&port=8443&token=tok");
assert_eq!(
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(ca)
.unwrap(),
der,
"the parameter is the certificate's DER, url-safe and unpadded"
);
assert!(
!ca.contains(['+', '/', '=']),
"nothing in it needs percent-encoding: {ca}"
);
}
/// Something that is not a certificate is refused where it is read,
/// rather than minting a link an app can only fail on.
#[test]
fn a_ca_that_is_not_a_certificate_is_named_in_the_error() {
let err = ca_param("hello").unwrap_err().to_string();
assert!(err.contains("PEM certificate"), "{err}");
}
fn scratch_dir() -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("wg-app-link-enroll-{}", generate_token()));
fs::create_dir_all(&dir).unwrap();
dir
}
/// A spooled token is adopted once, under the name it was spooled with,
/// and by nothing but that token.
#[test]
fn a_spooled_token_is_taken_exactly_once() {
let dir = scratch_dir();
let token = generate_token();
spool_pending(&dir, "tablet", &token).unwrap();
assert_eq!(take_pending(&dir, "wrong").unwrap(), None);
assert_eq!(
take_pending(&dir, &token).unwrap().as_deref(),
Some("tablet")
);
assert_eq!(take_pending(&dir, &token).unwrap(), None, "consumed");
assert!(
fs::read_dir(&dir).unwrap().next().is_none(),
"nothing left behind"
);
fs::remove_dir_all(dir).unwrap();
}
/// Nothing spooled -- not even the directory -- is an ordinary miss.
#[test]
fn no_spool_is_a_miss() {
let dir = scratch_dir().join("never-made");
assert_eq!(take_pending(&dir, "anything").unwrap(), None);
}
/// An entry past its age is swept rather than honoured.
#[test]
fn a_stale_entry_is_swept_not_honoured() {
let dir = scratch_dir();
let token = generate_token();
spool_pending(&dir, "old", &token).unwrap();
let path = dir.join(token_hash_hex(&token));
let past = std::time::SystemTime::now() - PENDING_TTL - Duration::from_secs(1);
fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(past)
.unwrap();
assert_eq!(take_pending(&dir, &token).unwrap(), None);
assert!(!path.exists(), "swept");
fs::remove_dir_all(dir).unwrap();
}
}
-90
View File
@@ -35,13 +35,8 @@
/// `#![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)
}
@@ -56,40 +51,6 @@ pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
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<T: Serialize>(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
@@ -185,57 +146,6 @@ mod tests {
);
}
#[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::<Demo>(&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]
-1
View File
@@ -32,4 +32,3 @@ pub mod enroll;
pub mod format;
pub mod netif;
pub mod private;
pub mod xdg;
+1 -1
View File
@@ -114,7 +114,7 @@ mod tests {
/// 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 found = [ip("192.168.1.168"), ip("10.66.0.1"), ip("192.168.1.168")];
let addresses = addresses_among(found);
assert!(addresses.contains(&ip("127.0.0.1")));
-94
View File
@@ -1,94 +0,0 @@
//! 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/<product>`, or `~/.config/<product>`.
///
/// 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/<product>`, or `~/.local/share/<product>`.
///
/// 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<OsString>, 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"),
);
}
}