Take the app half too, which was duplicated the same way

Four Kotlin files appeared in both apps. Diffed with the product names
normalised, EnrollmentScanActivity was 31 lines each differing in six --
all of them comments -- and PinnedCert was 58 against 59 with identical
TrustManager logic. That is the same evidence that moved the Rust half.

Two parameters, both per-app, and they fail differently. A wrong URI
scheme means a scanned QR is ignored, which is visible at once. A wrong
Keystore alias means the app cannot unseal the token it already stored,
so an enrolled phone reads as not enrolled and nothing says why -- so
both existing values are recorded in the README rather than left to be
rediscovered.

The certificate alias went the other way and became a constant: it names
an entry in a KeyStore that exists only in memory for the length of one
lazy block, so nothing ever reads it back and having two of them said
nothing.

The drift was in both directions, as the evidence predicted: ai-app had
gained localNetworkAllowed -- which matters because Android 17's
ACCESS_LOCAL_NETWORK denial is invisible at the socket, and without it a
blocked app and an unreachable server produce the same timeout -- and had
moved to the KTX `edit` block. dev-updater had neither, and gets both.

No Compose, deliberately. Nothing here draws anything; the screens are
each app's own because that is where the two products actually differ.

The PinnedCaCertificate generator did not move, and the README says why:
all three things it varies are per-app, so sharing it means a composite
build neither project has. Its one historical bug is already fixed
identically in both copies.
This commit is contained in:
iris committed 2026-08-28 17:57:07 -04:00
1 parent 73a32cb897
commit 4e423bbfb0
201 files changed
+3148 -2

No files matched your search

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