wg-app-link: the WireGuard-and-pinned-TLS half both apps needed
The Rust crate and the Android half of one arrangement: a server that binds the tunnel interface and nothing else, certificates it generates and keeps outside any shared checkout, enrolment that carries a token and the CA to a phone, and a client that trusts exactly that certificate and no other. Extracted because ai-app and dev-updater had written all of it twice and the two copies had already drifted -- one of them carried a bug the other did not. History before this point was squashed away; it was a running record of that extraction and of a personal machine's addresses, and neither is worth keeping in a public repository.
This commit is contained in:
commit
f95bc77f7b
17 files changed
+2552
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
server/target/
|
||||||
|
|
||||||
|
# Gradle build outputs for the app half.
|
||||||
|
app/build/
|
||||||
|
.gradle/
|
||||||
@@ -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 `<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.
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// The app half of the link, as a library both phones' apps depend on.
|
||||||
|
//
|
||||||
|
// Consumed as a *subproject* of each app's build rather than as a published
|
||||||
|
// artifact or a composite build, so the two stay version-locked to whatever
|
||||||
|
// commit of this repo the consuming project's submodule points at -- the
|
||||||
|
// same arrangement the Rust half uses with a path dependency. Each app adds
|
||||||
|
// two lines to its `settings.gradle.kts`:
|
||||||
|
//
|
||||||
|
// include(":link")
|
||||||
|
// project(":link").projectDir = file("../wg-app-link/app")
|
||||||
|
//
|
||||||
|
// and one entry to its version catalog, since a subproject resolves plugin
|
||||||
|
// versions from the build including it:
|
||||||
|
//
|
||||||
|
// androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
//
|
||||||
|
// Deliberately no Compose. Nothing here draws anything -- the screens that
|
||||||
|
// use it are each app's own, because they are where the two products
|
||||||
|
// actually differ.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.androidLibrary)
|
||||||
|
alias(libs.plugins.ktfmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
ktfmt { kotlinLangStyle() }
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.wgapplink"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
// minSdk matches the lower of the two consumers rather than this
|
||||||
|
// library's own preference: a library that raised it would silently
|
||||||
|
// raise theirs. No desugaring here for the same reason -- nothing in
|
||||||
|
// this module uses java.time, and requiring it would impose a build
|
||||||
|
// setting on anyone who depends on it.
|
||||||
|
defaultConfig { minSdk = 24 }
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// SharedPreferences.edit, and the KTX extensions this code calls
|
||||||
|
// directly rather than inheriting transitively.
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
// The capture activity EnrollmentScanActivity subclasses. `api` rather
|
||||||
|
// than `implementation`: the subclass is part of this module's public
|
||||||
|
// surface, so a consumer declaring it in its manifest needs the type.
|
||||||
|
api(libs.zxing.embedded)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Nothing is declared here. EnrollmentScanActivity is registered by each
|
||||||
|
consuming app's own manifest, because the activity's label, theme and
|
||||||
|
parent are the app's business and merging a fixed one would take that
|
||||||
|
choice away. -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.example.wgapplink
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.journeyapps.barcodescanner.CaptureActivity
|
||||||
|
import com.journeyapps.barcodescanner.DecoratedBarcodeView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen behind an app's "Scan QR code" button: zxing-android-embedded's capture activity with
|
||||||
|
* two of its defaults taken off, both because they put work on the person holding the phone.
|
||||||
|
*
|
||||||
|
* - **It decodes only the framing rectangle**, which `CameraPreview.marginFraction` insets by 10%
|
||||||
|
* from every edge and `DecoderThread` crops each frame to before the decoder sees it. A code that
|
||||||
|
* fills the viewfinder keeps decoding -- measured, not assumed -- but it does so having spent its
|
||||||
|
* quiet zone and margin for error on the crop, and anything further out is simply not looked at.
|
||||||
|
* Decoding the whole preview costs nothing and means the framing is never the user's problem.
|
||||||
|
* - **It decorates the preview** with a red laser line and the dots the detector scatters wherever
|
||||||
|
* it finds a candidate pattern. That is the library's house style; a plain preview is ours.
|
||||||
|
*
|
||||||
|
* Orientation is left to the sensor rather than pinned to landscape as the library's own manifest
|
||||||
|
* entry pins it, so the phone can be held whichever way the code is in front of it.
|
||||||
|
*/
|
||||||
|
class EnrollmentScanActivity : CaptureActivity() {
|
||||||
|
override fun initializeContent(): DecoratedBarcodeView {
|
||||||
|
val view = super.initializeContent()
|
||||||
|
view.barcodeView.marginFraction = 0.0
|
||||||
|
view.viewFinder.visibility = View.GONE
|
||||||
|
view.statusView.visibility = View.GONE
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.example.wgapplink
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TLS that trusts one CA and nothing else -- not the device's system trust store, so a genuine
|
||||||
|
* CA-issued certificate for some other host is refused just as firmly as a self-signed one.
|
||||||
|
*
|
||||||
|
* The certificate is passed in rather than read from anywhere, because each app generates its own
|
||||||
|
* at build time from the CA on the machine doing the build. That is what makes the trust anchor
|
||||||
|
* follow the build: an APK built on the backend host trusts that host, and one built in a dev VM
|
||||||
|
* trusts that VM's throwaway CA and is good only for its emulator. Neither app should ever have a
|
||||||
|
* second anchor to get wrong, which is also why enrollment carries no CA -- photographing the
|
||||||
|
* terminal leaks only the rotatable token.
|
||||||
|
*
|
||||||
|
* Hold one of these per app and reuse it: the [socketFactory] is built lazily and then kept, since
|
||||||
|
* every reconnect would otherwise redo the KeyStore and TrustManager setup from scratch.
|
||||||
|
*/
|
||||||
|
class PinnedTls(private val caPem: String) {
|
||||||
|
|
||||||
|
val socketFactory: SSLSocketFactory by lazy {
|
||||||
|
// Trimmed because CertificateFactory only recognises PEM when the
|
||||||
|
// "-----BEGIN" preamble is the very first thing it sees; surrounding
|
||||||
|
// whitespace sends it down the DER path instead, which fails at
|
||||||
|
// runtime with an ASN.1 decode error nowhere near the cause.
|
||||||
|
val caCert =
|
||||||
|
CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(ByteArrayInputStream(caPem.trim().encodeToByteArray()))
|
||||||
|
val keyStore =
|
||||||
|
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||||
|
load(null, null)
|
||||||
|
// The alias names an entry in a KeyStore that exists only in
|
||||||
|
// memory for the length of this block, so it is arbitrary and
|
||||||
|
// deliberately not per-app -- nothing reads it back.
|
||||||
|
setCertificateEntry(PINNED_CA_ALIAS, caCert)
|
||||||
|
}
|
||||||
|
val trustManager =
|
||||||
|
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
.apply { init(keyStore) }
|
||||||
|
.trustManagers
|
||||||
|
.filterIsInstance<X509TrustManager>()
|
||||||
|
.first()
|
||||||
|
SSLContext.getInstance("TLS")
|
||||||
|
.apply { init(null, arrayOf(trustManager), null) }
|
||||||
|
.socketFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins [connection], which must be every connection the app makes -- there is no unpinned path
|
||||||
|
* in either project, and adding one would quietly undo the whole arrangement.
|
||||||
|
*
|
||||||
|
* A plain HTTP connection is left alone rather than refused, since dev-updater serves its
|
||||||
|
* bootstrap download over one deliberately.
|
||||||
|
*/
|
||||||
|
fun applyTo(connection: HttpURLConnection) {
|
||||||
|
if (connection is HttpsURLConnection) {
|
||||||
|
connection.sslSocketFactory = socketFactory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PINNED_CA_ALIAS = "pinned-ca"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.example.wgapplink
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a backend is and how to authenticate to it. Absent until the phone is enrolled, which
|
||||||
|
* happens by scanning the QR the server prints to its terminal.
|
||||||
|
*/
|
||||||
|
data class ServerSettings(val host: String, val port: Int, val token: String) {
|
||||||
|
val baseUrl: String
|
||||||
|
get() = "https://$host:$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads and writes one app's enrollment.
|
||||||
|
*
|
||||||
|
* Two things are per-app and both are passed in rather than derived, because getting either wrong
|
||||||
|
* is silent rather than loud:
|
||||||
|
* - [scheme] is what routes a scanned QR back to the right app (`aiapp`, `devupdater`). Two apps
|
||||||
|
* accepting the same scheme would each offer to handle the other's enrollment.
|
||||||
|
* - [keyAlias] names the Android Keystore key the token is sealed under. It is **persisted**, so an
|
||||||
|
* app that changes it stops being able to unseal what it already stored, and the phone silently
|
||||||
|
* reads as not enrolled. Existing values must be carried over exactly when adopting this class.
|
||||||
|
*/
|
||||||
|
class ServerStore(private val scheme: String, private val keyAlias: String) {
|
||||||
|
|
||||||
|
fun load(context: Context): ServerSettings? {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val host = prefs.getString(KEY_HOST, null) ?: return null
|
||||||
|
val port = prefs.getInt(KEY_PORT, 0)
|
||||||
|
val sealed = prefs.getString(KEY_TOKEN, null) ?: return null
|
||||||
|
val token = unseal(sealed) ?: return null
|
||||||
|
if (port == 0 || token.isEmpty()) return null
|
||||||
|
return ServerSettings(host, port, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(context: Context, settings: ServerSettings) {
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit {
|
||||||
|
putString(KEY_HOST, settings.host)
|
||||||
|
putInt(KEY_PORT, settings.port)
|
||||||
|
putString(KEY_TOKEN, seal(settings.token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the enrollment URI the QR carries, e.g.
|
||||||
|
* `aiapp://enroll?host=10.66.0.1&port=8443&token=...`.
|
||||||
|
*
|
||||||
|
* Null if any part is missing, so a malformed or foreign scan cannot clobber a working
|
||||||
|
* enrollment with a half-filled one.
|
||||||
|
*/
|
||||||
|
fun parseEnrollmentUri(uri: Uri): ServerSettings? {
|
||||||
|
if (uri.scheme != scheme || uri.host != "enroll") return null
|
||||||
|
val host = uri.getQueryParameter("host") ?: return null
|
||||||
|
val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null
|
||||||
|
val token = uri.getQueryParameter("token") ?: return null
|
||||||
|
if (host.isEmpty() || token.isEmpty()) return null
|
||||||
|
return ServerSettings(host, port, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Token sealing. The token authenticates requests to a server whose API is
|
||||||
|
// remote code execution, so it is stored AES-GCM-encrypted under an Android
|
||||||
|
// Keystore key (hardware-backed where the device has one) rather than in
|
||||||
|
// plain preferences. Hand-rolled rather than Jetpack's
|
||||||
|
// EncryptedSharedPreferences, which is deprecated with no drop-in
|
||||||
|
// successor -- Google's own guidance is now to use Keystore directly.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key if there is one, without making one.
|
||||||
|
*
|
||||||
|
* The read side must never create. A sealed token with no key behind it means the key was lost
|
||||||
|
* -- a device reset, or the app's data restored onto another device, where Keystore keys do not
|
||||||
|
* travel -- and generating a fresh one there would leave a key nothing had ever sealed with and
|
||||||
|
* still fail to decrypt. Absent is the honest answer, and the caller reads it as "not
|
||||||
|
* enrolled".
|
||||||
|
*/
|
||||||
|
private fun existingTokenKey(): SecretKey? {
|
||||||
|
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
|
||||||
|
return keyStore.getKey(keyAlias, null) as? SecretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokenKey(): SecretKey {
|
||||||
|
existingTokenKey()?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
|
||||||
|
generator.init(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
keyAlias,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
return generator.generateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `iv:ciphertext`, both base64 -- the stored form of the token. */
|
||||||
|
private fun seal(token: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, tokenKey())
|
||||||
|
val ciphertext = cipher.doFinal(token.encodeToByteArray())
|
||||||
|
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) +
|
||||||
|
":" +
|
||||||
|
Base64.encodeToString(ciphertext, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null on any failure, including the lost-key case above. The caller treats that as "not
|
||||||
|
* enrolled"; re-scanning the QR, or rotating the token server-side, is the recovery -- so
|
||||||
|
* failing soft here is right rather than lenient.
|
||||||
|
*/
|
||||||
|
private fun unseal(sealed: String): String? =
|
||||||
|
try {
|
||||||
|
val (ivB64, dataB64) =
|
||||||
|
sealed.split(":", limit = 2).let {
|
||||||
|
if (it.size != 2) return null else it[0] to it[1]
|
||||||
|
}
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE,
|
||||||
|
existingTokenKey() ?: return null,
|
||||||
|
GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)),
|
||||||
|
)
|
||||||
|
cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this app may reach a local network address at all.
|
||||||
|
*
|
||||||
|
* Android 17 made `ACCESS_LOCAL_NETWORK` mandatory for it, and a denial is invisible at the socket:
|
||||||
|
* the OS drops the traffic, so a blocked app and an unreachable server produce the same connect
|
||||||
|
* timeout. Without asking explicitly there is no way to tell those apart, and the failure shown
|
||||||
|
* would blame the server or the tunnel for something neither is doing.
|
||||||
|
*
|
||||||
|
* **The permission is still required when the server is reached through WireGuard, and the
|
||||||
|
* platform's own documentation says otherwise.** Android's Local Network Definition describes a
|
||||||
|
* local network as one that "utilizes a broadcast-capable network interface, such as Wi-Fi or
|
||||||
|
* Ethernet, but excludes cellular (WWAN) or VPN connections" — read straight, a tunnelled 10.66.0.1
|
||||||
|
* is excluded and needs nothing. Measured on a real device on 2026-08-28: it is not excluded, and
|
||||||
|
* without the permission the traffic is dropped. Do not remove the permission on the strength of
|
||||||
|
* that paragraph.
|
||||||
|
*
|
||||||
|
* **Neither project can catch this in an emulator.** The API 36 images both are tested against do
|
||||||
|
* not enforce the permission at all, so removing it passes every local test and fails only on a
|
||||||
|
* phone. That asymmetry is the reason this note is here rather than in a commit message.
|
||||||
|
*/
|
||||||
|
fun localNetworkAllowed(context: Context): Boolean =
|
||||||
|
android.os.Build.VERSION.SDK_INT < 37 ||
|
||||||
|
context.checkSelfPermission("android.permission.ACCESS_LOCAL_NETWORK") ==
|
||||||
|
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
// Both apps store under the same preferences name, which does not collide:
|
||||||
|
// SharedPreferences are per-application, so "server" means this app's server.
|
||||||
|
private const val PREFS_NAME = "server"
|
||||||
|
private const val KEY_HOST = "host"
|
||||||
|
private const val KEY_PORT = "port"
|
||||||
|
private const val KEY_TOKEN = "token"
|
||||||
|
|
||||||
|
private const val KEYSTORE = "AndroidKeyStore"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val GCM_TAG_BITS = 128
|
||||||
Executable
+5
@@ -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 "$@"
|
||||||
Generated
+837
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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<Certificates> {
|
||||||
|
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<u8> {
|
||||||
|
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<IpAddr> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `<scheme>://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::<qrcode::render::unicode::Dense1x2>()
|
||||||
|
.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?"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T: DeserializeOwned>(text: &str) -> Result<T, ron::error::SpannedError> {
|
||||||
|
options().from_str(&format!("({text})"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
|
||||||
|
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<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
|
||||||
|
/// 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::<Vec<_>>()
|
||||||
|
.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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::<Demo>(&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::<Demo>("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::<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]
|
||||||
|
fn a_parse_error_points_at_the_line_it_is_on() {
|
||||||
|
let err = parse::<Demo>("name: \"a\",\nnote: ,\n").expect_err("malformed");
|
||||||
|
assert_eq!(err.span.start.line, 2, "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<IpAddr> {
|
||||||
|
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<Item = IpAddr>) -> Vec<IpAddr> {
|
||||||
|
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<IpAddr> {
|
||||||
|
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<Item = (&'a str, IpAddr)>,
|
||||||
|
) -> Result<IpAddr> {
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<File> {
|
||||||
|
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<File> {
|
||||||
|
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> {
|
||||||
|
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<File>),
|
||||||
|
("rewritten", create_file as fn(&Path) -> Result<File>),
|
||||||
|
] {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<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"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user