Merge branch 'app-embeds-ca-from-config'
The app pins the CA of the machine that builds it, read at build time rather than pasted into the source; state and certificates move out of the shared repo; certificates are generated in process; and the sibling project's rename to dev-updater is followed here.
This commit is contained in:
commit
d4a4ee7808
17 files changed
+776
-228
No files matched your search
@@ -22,11 +22,11 @@ session-type branch in shared code (routes, transcript, app screens).
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
Mirrors `../local-updater` deliberately — same stack (axum 0.8 +
|
Mirrors `../dev-updater` deliberately — same stack (axum 0.8 +
|
||||||
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform,
|
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform,
|
||||||
single `:androidApp` module), same cert scheme, same registry pattern (every
|
single `:androidApp` module), same cert scheme, same registry pattern (every
|
||||||
session mutation funnels through the manager so in-memory and on-disk state
|
session mutation funnels through the manager so in-memory and on-disk state
|
||||||
can't come apart). Read local-updater's `README.md` and `AGENTS.md` for the
|
can't come apart). Read dev-updater's `README.md` and `AGENTS.md` for the
|
||||||
conventions before diverging from them; module-by-module intent for this
|
conventions before diverging from them; module-by-module intent for this
|
||||||
repo is in PLAN.md's "Backend layout" section.
|
repo is in PLAN.md's "Backend layout" section.
|
||||||
|
|
||||||
@@ -40,10 +40,11 @@ repo is in PLAN.md's "Backend layout" section.
|
|||||||
`when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the
|
`when`; `Api.kt`/`EventStream.kt` the REST + SSE clients; `Events.kt` the
|
||||||
event model mirror; `ServerConfig.kt` settings + Keystore-sealed token;
|
event model mirror; `ServerConfig.kt` settings + Keystore-sealed token;
|
||||||
screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`.
|
screens in `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`.
|
||||||
- `gen-dev-cert.sh` / `certs/` — copied from local-updater's scheme
|
- `server/src/certs.rs` — the TLS certificates, generated in process on
|
||||||
(idempotent CA, reissued leaf; regenerating the CA strands the installed
|
first start into `$XDG_CONFIG_HOME/ai-app/certs`: idempotent CA, leaf
|
||||||
app — same one-way door). Dev SANs cover 127.0.0.1, 10.0.2.2 (emulator →
|
reissued every start covering every local IPv4 plus 127.0.0.1 and
|
||||||
host), and the LAN IP alongside the WireGuard address.
|
10.0.2.2 (emulator → host). Regenerating the CA strands the installed
|
||||||
|
app — the one-way door.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -59,8 +60,15 @@ real-phone/WireGuard bring-up, which is operational rather than code.
|
|||||||
|
|
||||||
- Server: `./run-tests.sh` (or `cargo test`) + `cargo clippy --all-targets`
|
- Server: `./run-tests.sh` (or `cargo test`) + `cargo clippy --all-targets`
|
||||||
from `server/` — the build stays warning-clean, keep it that way.
|
from `server/` — the build stays warning-clean, keep it that way.
|
||||||
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`;
|
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`
|
||||||
`./run-android.sh` builds, installs, and launches on the emulator.
|
to typecheck; `./build-apk.sh` to produce the APK to install on a phone
|
||||||
|
(through Dev Updater); `./run-android.sh` to build, install, and launch
|
||||||
|
on the emulator.
|
||||||
|
- **The APK pins the CA of the machine that builds it**, read at build time
|
||||||
|
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and
|
||||||
|
generated into a constant. So the server must have started once on that
|
||||||
|
machine first — the build stops with that instruction otherwise — and an
|
||||||
|
APK built in this VM only works against a server in this VM.
|
||||||
- Run the server for development with `--bind 127.0.0.1` (wg0 doesn't exist
|
- Run the server for development with `--bind 127.0.0.1` (wg0 doesn't exist
|
||||||
on this machine yet; the default fails closed). First run prints the
|
on this machine yet; the default fails closed). First run prints the
|
||||||
enrollment QR/URI with the token — capture it from the log.
|
enrollment QR/URI with the token — capture it from the log.
|
||||||
@@ -75,7 +83,7 @@ real-phone/WireGuard bring-up, which is operational rather than code.
|
|||||||
Established 2026-08-25, and it decides more than it looks like:
|
Established 2026-08-25, and it decides more than it looks like:
|
||||||
|
|
||||||
- **The host (192.168.1.168) is the backend machine.** It runs
|
- **The host (192.168.1.168) is the backend machine.** It runs
|
||||||
local-updater's server today and is where `ai-server` belongs in
|
dev-updater's server today and is where `ai-server` belongs in
|
||||||
production: it has the LAN address the phone can reach, and it's where
|
production: it has the LAN address the phone can reach, and it's where
|
||||||
WireGuard terminates. `wg-setup-host.sh` sets that up (keys, `wg0.conf`,
|
WireGuard terminates. `wg-setup-host.sh` sets that up (keys, `wg0.conf`,
|
||||||
the phone's QR); run it there with `sudo WG_ENDPOINT=<ddns name>`.
|
the phone's QR); run it there with `sudo WG_ENDPOINT=<ddns name>`.
|
||||||
@@ -115,9 +123,11 @@ Established 2026-08-25, and it decides more than it looks like:
|
|||||||
PLAN.md's security section), and the repo is shared read-write with the
|
PLAN.md's security section), and the repo is shared read-write with the
|
||||||
host, so state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.json`
|
host, so state lives outside it: `$XDG_CONFIG_HOME/ai-app/config.json`
|
||||||
and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only.
|
and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only.
|
||||||
- Certificates are generated **on the machine that serves them**
|
- Certificates are generated **by the server, on first start**, into
|
||||||
(`./gen-dev-cert.sh`, honours `AI_APP_CERTS`). Running it in the VM makes
|
`$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created
|
||||||
a separate throwaway dev CA for emulator work — never install a build
|
once and then left alone; the leaf is reissued every start, so covering a
|
||||||
|
new address is a restart. Starting the server in the VM therefore makes a
|
||||||
|
separate throwaway dev CA for emulator work — never install a build
|
||||||
pinning that on the real phone.
|
pinning that on the real phone.
|
||||||
- Point development at a scratch state directory rather than the real one:
|
- Point development at a scratch state directory rather than the real one:
|
||||||
`--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`, or
|
`--config /tmp/…/config.json --data-dir /tmp/…/sessions --port 8444`, or
|
||||||
@@ -138,11 +148,19 @@ Established 2026-08-25, and it decides more than it looks like:
|
|||||||
- **CMP 1.11 deprecates the `compose.*` dependency accessors** — declare
|
- **CMP 1.11 deprecates the `compose.*` dependency accessors** — declare
|
||||||
`org.jetbrains.compose.<x>:<x>` directly (material3 has its own release
|
`org.jetbrains.compose.<x>:<x>` directly (material3 has its own release
|
||||||
train, separate from the CMP version).
|
train, separate from the CMP version).
|
||||||
|
- **A PEM constant must start at the opening quotes.** A generated
|
||||||
|
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory`
|
||||||
|
its preamble sniff, so it tries DER instead and fails at runtime with
|
||||||
|
`ASN.1 ... DECODE_ERROR` — nowhere near the code that produced it.
|
||||||
|
- **AGP 9 refuses `Provider`s in the source-set API**: generated sources go
|
||||||
|
through `androidComponents.onVariants { it.sources.java?.addGenerated
|
||||||
|
SourceDirectory(task, Task::outputDir) }`, which also carries the task
|
||||||
|
dependency.
|
||||||
|
|
||||||
## Environment notes (this machine, learned in local-updater)
|
## Environment notes (this machine, learned in dev-updater)
|
||||||
|
|
||||||
- Android SDK is at `~/Android/Sdk`, not the root-owned `/opt/android-sdk`
|
- Android SDK is at `~/Android/Sdk`, not the root-owned `/opt/android-sdk`
|
||||||
the ambient `$ANDROID_HOME` may point at; copy local-updater's
|
the ambient `$ANDROID_HOME` may point at; copy dev-updater's
|
||||||
`android-env.sh` override pattern.
|
`android-env.sh` override pattern.
|
||||||
- Each agent command runs in a fresh shell — exported environment does not
|
- Each agent command runs in a fresh shell — exported environment does not
|
||||||
carry over. Chain: `cd app && . ./android-env.sh && ./gradlew …`. Never
|
carry over. Chain: `cd app && . ./android-env.sh && ./gradlew …`. Never
|
||||||
@@ -153,5 +171,5 @@ Established 2026-08-25, and it decides more than it looks like:
|
|||||||
(`setsid nohup … & disown -h`, verify `PPID 1`), and every
|
(`setsid nohup … & disown -h`, verify `PPID 1`), and every
|
||||||
`pgrep -f`/`pkill -f` pattern needs its first character bracketed
|
`pgrep -f`/`pkill -f` pattern needs its first character bracketed
|
||||||
(`[a]i-server`) in **every** occurrence in the command, or the pattern
|
(`[a]i-server`) in **every** occurrence in the command, or the pattern
|
||||||
matches the shell running it. Full explanation in local-updater's
|
matches the shell running it. Full explanation in dev-updater's
|
||||||
`AGENTS.md` — it bites exactly the same way here.
|
`AGENTS.md` — it bites exactly the same way here.
|
||||||
@@ -7,7 +7,7 @@ the official app gets wrong (e.g. it won't deliver a typed message until the
|
|||||||
session fully finishes its turn, where the TUI injects it at the next tool
|
session fully finishes its turn, where the TUI injects it at the next tool
|
||||||
boundary).
|
boundary).
|
||||||
|
|
||||||
Same shape as `../local-updater`: a Rust (Axum) backend on the desktop, a
|
Same shape as `../dev-updater`: a Rust (Axum) backend on the desktop, a
|
||||||
Kotlin/Compose Android app, pinned self-signed TLS between them.
|
Kotlin/Compose Android app, pinned self-signed TLS between them.
|
||||||
|
|
||||||
## The one idea everything hangs off
|
## The one idea everything hangs off
|
||||||
@@ -81,12 +81,12 @@ backend (Rust/Axum, desktop)
|
|||||||
|
|
||||||
### Backend layout (`server/`)
|
### Backend layout (`server/`)
|
||||||
|
|
||||||
Mirroring local-updater's stack: axum 0.8, axum-server + rustls, tokio, serde,
|
Mirroring dev-updater's stack: axum 0.8, axum-server + rustls, tokio, serde,
|
||||||
clap, tracing. Rust edition 2024, warning-clean, clippy in CI habit.
|
clap, tracing. Rust edition 2024, warning-clean, clippy in CI habit.
|
||||||
|
|
||||||
- `main.rs` — bootstrap, TLS listener.
|
- `main.rs` — bootstrap, TLS listener.
|
||||||
- `routes.rs` — the whole HTTP table in one module doc comment (as in
|
- `routes.rs` — the whole HTTP table in one module doc comment (as in
|
||||||
local-updater).
|
dev-updater).
|
||||||
- `session/mod.rs` — `SessionManager`: the live session registry, every
|
- `session/mod.rs` — `SessionManager`: the live session registry, every
|
||||||
mutation funnels through it (the `registry.rs` pattern: in-memory and
|
mutation funnels through it (the `registry.rs` pattern: in-memory and
|
||||||
on-disk state can't come apart).
|
on-disk state can't come apart).
|
||||||
@@ -259,9 +259,24 @@ complete path out of everything spawning one created.
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- TLS with a self-signed CA, pinned in the app — `gen-dev-cert.sh` and
|
- TLS with a self-signed CA, pinned in the app — same
|
||||||
`PinnedCert.kt` copied from local-updater, same idempotent-CA/reissued-leaf
|
idempotent-CA/reissued-leaf scheme as dev-updater, same one-way-door
|
||||||
scheme, same one-way-door caveat about regenerating the CA.
|
caveat about regenerating the CA, but generated **in process on first
|
||||||
|
start** (`certs.rs`) rather than by a shell script calling openssl
|
||||||
|
(2026-08-25). One place then decides the extensions, the file modes, and
|
||||||
|
which addresses the leaf covers — every local IPv4 plus loopback and the
|
||||||
|
emulator's host alias, so nobody maintains a hardcoded IP — and there is
|
||||||
|
no setup step to forget.
|
||||||
|
- Unlike dev-updater, the pinned CA is **not a constant in the source**:
|
||||||
|
the build reads `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` from the machine
|
||||||
|
doing the build and generates the constant (`generatePinnedCert` in
|
||||||
|
`app/androidApp/build.gradle.kts`; `AI_APP_CA` overrides). Decided
|
||||||
|
2026-08-25, and it does three things at once — the trust anchor follows
|
||||||
|
the build machine, so an APK built on the backend host pins that host
|
||||||
|
and one built in the dev VM pins the VM's throwaway CA and is only good
|
||||||
|
for its emulator; there is no second anchor to add for development and
|
||||||
|
forget to remove; and regenerating a CA needs a rebuild rather than a
|
||||||
|
paste, so a stale constant can't quietly disagree with the server.
|
||||||
- **The dev VM is untrusted** (decided 2026-08-25): a machine that isn't
|
- **The dev VM is untrusted** (decided 2026-08-25): a machine that isn't
|
||||||
malicious but could become so. It matters because the repo is a
|
malicious but could become so. It matters because the repo is a
|
||||||
read-write virtiofs mount shared between the VM and the backend host, so
|
read-write virtiofs mount shared between the VM and the backend host, so
|
||||||
@@ -377,12 +392,12 @@ complete path out of everything spawning one created.
|
|||||||
a deliberate flag, never a fallback, so the fail-closed default is
|
a deliberate flag, never a fallback, so the fail-closed default is
|
||||||
untouched (2026-08-24).
|
untouched (2026-08-24).
|
||||||
- The bootstrap-over-HTTP trick from the updater is unnecessary here — the
|
- The bootstrap-over-HTTP trick from the updater is unnecessary here — the
|
||||||
app installs via Local Updater.
|
app installs via Dev Updater.
|
||||||
|
|
||||||
## App (`app/`)
|
## App (`app/`)
|
||||||
|
|
||||||
Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as
|
Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as
|
||||||
local-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
|
dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
|
||||||
|
|
||||||
1. **Session list** — cards: kind icon, title, host, model, status
|
1. **Session list** — cards: kind icon, title, host, model, status
|
||||||
(running / awaiting answer / idle / exited), last activity. Spawn FAB;
|
(running / awaiting answer / idle / exited), last activity. Spawn FAB;
|
||||||
@@ -405,7 +420,7 @@ local-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens:
|
|||||||
5. **Settings** — server address + token, hosts editor, llama model list
|
5. **Settings** — server address + token, hosts editor, llama model list
|
||||||
editor.
|
editor.
|
||||||
|
|
||||||
Networking mirrors local-updater's app layer (`AppsApi.kt` style thin client +
|
Networking mirrors dev-updater's app layer (`AppsApi.kt` style thin client +
|
||||||
pinned transport), plus an SSE client with `after=` resume driven by
|
pinned transport), plus an SSE client with `after=` resume driven by
|
||||||
connectivity/lifecycle. The app keeps no persistent transcript store — the
|
connectivity/lifecycle. The app keeps no persistent transcript store — the
|
||||||
backend's transcript is the source of truth; the app caches only for the
|
backend's transcript is the source of truth; the app caches only for the
|
||||||
@@ -486,7 +501,7 @@ window just fills.
|
|||||||
Each phase ends runnable and verified against the real thing (rule 22); the
|
Each phase ends runnable and verified against the real thing (rule 22); the
|
||||||
backend gets tests where logic is pure (event normalization, transcript
|
backend gets tests where logic is pure (event normalization, transcript
|
||||||
cursors, config persistence, refcounting) — the app is UI over the API and is
|
cursors, config persistence, refcounting) — the app is UI over the API and is
|
||||||
verified by running it, matching local-updater's posture.
|
verified by running it, matching dev-updater's posture.
|
||||||
|
|
||||||
## Open questions / risks
|
## Open questions / risks
|
||||||
|
|
||||||
@@ -524,6 +539,6 @@ installed versions when each phase starts):
|
|||||||
≥180 s polling; wrong User-Agent → aggressive 429 bucket):
|
≥180 s polling; wrong User-Agent → aggressive 429 bucket):
|
||||||
https://github.com/anthropics/claude-code/issues/31637 and
|
https://github.com/anthropics/claude-code/issues/31637 and
|
||||||
https://github.com/Maciek-roboblog/Claude-Code-Usage-Monitor/issues/202
|
https://github.com/Maciek-roboblog/Claude-Code-Usage-Monitor/issues/202
|
||||||
- Sibling project this repo's conventions mirror: `../local-updater`
|
- Sibling project this repo's conventions mirror: `../dev-updater`
|
||||||
(README.md + AGENTS.md — server/registry/routes layout, cert scheme,
|
(README.md + AGENTS.md — server/registry/routes layout, cert scheme,
|
||||||
testing posture, Android env notes).
|
testing posture, Android env notes).
|
||||||
@@ -4,6 +4,84 @@ plugins {
|
|||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The CA this app pins is baked in at build time from the certificates on
|
||||||
|
// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`,
|
||||||
|
// which the server generates on first start. AI_APP_CA overrides the path.
|
||||||
|
//
|
||||||
|
// Reading it rather than keeping a pasted copy in the source is what makes
|
||||||
|
// the trust boundary follow the build: an APK built on the backend host
|
||||||
|
// pins the host's CA and never sees any other, while one built in the dev
|
||||||
|
// VM pins that VM's throwaway CA and is only good for its emulator. There
|
||||||
|
// is no second trust anchor to get wrong, and no stale paste to notice
|
||||||
|
// three days later. It also means the private key never has to exist
|
||||||
|
// anywhere near this repo.
|
||||||
|
val pinnedCaPath: String = System.getenv("AI_APP_CA")
|
||||||
|
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
||||||
|
"/ai-app/certs/ca.pem"
|
||||||
|
|
||||||
|
abstract class GeneratePinnedCert : DefaultTask() {
|
||||||
|
/** Where the certificate is looked for, reported in failures. */
|
||||||
|
@get:Input
|
||||||
|
abstract val caPath: Property<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The certificate itself, set only when it exists -- so a missing one
|
||||||
|
* produces this task's own instructions rather than Gradle's "no such
|
||||||
|
* input file", which doesn't say what to run.
|
||||||
|
*/
|
||||||
|
@get:InputFile
|
||||||
|
@get:Optional
|
||||||
|
@get:PathSensitive(PathSensitivity.NONE)
|
||||||
|
abstract val caCertificate: RegularFileProperty
|
||||||
|
|
||||||
|
/** Wired by AGP through `addGeneratedSourceDirectory`. */
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun generate() {
|
||||||
|
val path = caPath.get()
|
||||||
|
val ca = File(path)
|
||||||
|
if (!ca.isFile) {
|
||||||
|
throw GradleException(
|
||||||
|
"No CA certificate at $path.\n" +
|
||||||
|
"Start ai-server once on this machine first -- it generates the CA the " +
|
||||||
|
"app pins, and the certificate has to exist before an APK can embed it.\n" +
|
||||||
|
"Set AI_APP_CA=/path/to/ca.pem to build against a different one.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val pem = ca.readText().trim()
|
||||||
|
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
||||||
|
throw GradleException("$path is not a PEM certificate.")
|
||||||
|
}
|
||||||
|
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
|
||||||
|
file.parentFile.mkdirs()
|
||||||
|
// The PEM must start immediately after the opening quotes: a
|
||||||
|
// leading newline makes Android's CertificateFactory stop
|
||||||
|
// recognising the "-----BEGIN" preamble and try to parse the whole
|
||||||
|
// thing as DER, which fails with an ASN.1 decode error at runtime
|
||||||
|
// rather than anywhere near this file.
|
||||||
|
file.writeText(
|
||||||
|
"""
|
||||||
|
|// Generated from $path by the generatePinnedCert task. Do not edit.
|
||||||
|
|package com.example.aiapp
|
||||||
|
|
|
||||||
|
|const val PINNED_CA_PEM = ""${'"'}$pem
|
||||||
|
|""${'"'}
|
||||||
|
|
|
||||||
|
""".trimMargin(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val generatePinnedCert = tasks.register<GeneratePinnedCert>("generatePinnedCert") {
|
||||||
|
val ca = file(pinnedCaPath)
|
||||||
|
caPath.set(pinnedCaPath)
|
||||||
|
if (ca.isFile) {
|
||||||
|
caCertificate.set(ca)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.aiapp"
|
namespace = "com.example.aiapp"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
@@ -31,6 +109,17 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AGP 9 wants generated sources registered through the variant API rather
|
||||||
|
// than added to a source set, so the task dependency is carried properly.
|
||||||
|
androidComponents {
|
||||||
|
onVariants { variant ->
|
||||||
|
variant.sources.java?.addGeneratedSourceDirectory(
|
||||||
|
generatePinnedCert,
|
||||||
|
GeneratePinnedCert::outputDir,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
network address, including a plain socket to a LAN IP literal.
|
network address, including a plain socket to a LAN IP literal.
|
||||||
Without it the traffic is silently dropped, surfacing only as a
|
Without it the traffic is silently dropped, surfacing only as a
|
||||||
connect timeout. See MainActivity.kt's runtime request, and
|
connect timeout. See MainActivity.kt's runtime request, and
|
||||||
local-updater's manifest for the full story. -->
|
dev-updater's manifest for the full story. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
// Transparent status bar on every version; the Surface below paints
|
// Transparent status bar on every version; the Surface below paints
|
||||||
// through underneath it and content insets itself. Same reasoning
|
// through underneath it and content insets itself. Same reasoning
|
||||||
// as local-updater's MainActivity.
|
// as dev-updater's MainActivity.
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = true
|
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = true
|
||||||
|
|
||||||
|
|||||||
@@ -10,31 +10,19 @@ import javax.net.ssl.SSLSocketFactory
|
|||||||
import javax.net.ssl.TrustManagerFactory
|
import javax.net.ssl.TrustManagerFactory
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
/**
|
// PINNED_CA_PEM is generated at build time from the CA on the machine doing
|
||||||
* PEM-encoded dev CA certificate this repo's `gen-dev-cert.sh` generates --
|
// the build -- see the generatePinnedCert task in build.gradle.kts. It is
|
||||||
* the sole trust anchor for every request this app makes. The server's TLS
|
// deliberately not a checked-in constant: the private key that signs against
|
||||||
* listener presents a leaf certificate signed by this CA. Everything behind
|
// it must never be anywhere this repo is, and an APK should pin whatever CA
|
||||||
* that listener *is* remote code execution on the backend, so this app
|
// the backend it was built for actually serves.
|
||||||
* trusts exactly this CA and nothing else -- not even the system store.
|
//
|
||||||
*
|
// So the trust anchor follows the build machine. Built on the backend host,
|
||||||
* Regenerating that CA means updating this to match; its SHA-256
|
// the app trusts that host and nothing else -- not even the system store.
|
||||||
* fingerprint is printed by the script and saved to `certs/ca-sha256.txt`.
|
// Built in the dev VM, it trusts that VM's throwaway CA and is good only for
|
||||||
* The QR enrollment deliberately does not carry the CA: trust lives here in
|
// its emulator; never install one of those on a real phone.
|
||||||
* the APK, so photographing the terminal leaks only the (rotatable) token.
|
//
|
||||||
*/
|
// The QR enrollment deliberately carries no CA: trust lives here in the APK,
|
||||||
const val PINNED_CA_PEM = """-----BEGIN CERTIFICATE-----
|
// so photographing the terminal leaks only the (rotatable) token.
|
||||||
MIIBvzCCAWWgAwIBAgIUGCxNZPJIfzGQipZGxWVZ8vMYZXgwCgYIKoZIzj0EAwIw
|
|
||||||
LTETMBEGA1UECgwKYWktYXBwIGRldjEWMBQGA1UEAwwNYWktYXBwIGRldiBDQTAe
|
|
||||||
Fw0yNjA4MjUwMTI2MDRaFw0zNjA4MjIwMTI2MDRaMC0xEzARBgNVBAoMCmFpLWFw
|
|
||||||
cCBkZXYxFjAUBgNVBAMMDWFpLWFwcCBkZXYgQ0EwWTATBgcqhkjOPQIBBggqhkjO
|
|
||||||
PQMBBwNCAARE6qRKz1HeCzcvmdT6ztwTR2w4DGP97aaYJhp3z+es6dceNXdpP1qx
|
|
||||||
3DlazArgYLjOcNOHTqonj4H5NwHfeP4So2MwYTAdBgNVHQ4EFgQUL9VAISmEPDhJ
|
|
||||||
dHnl5iS10qLbcekwHwYDVR0jBBgwFoAUL9VAISmEPDhJdHnl5iS10qLbcekwDwYD
|
|
||||||
VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwIDSAAwRQIh
|
|
||||||
AL7Z0wfT0pXD08J6GNbPVfy/PB3EoUtIwA9z9rYGPEhZAiBeLPiXCvIbWWTpsjRr
|
|
||||||
Uk5VTHJchx0xXCdTRSJo2BEh7Q==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
"""
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trusts only [PINNED_CA_PEM], not the device's system trust store, so a
|
* Trusts only [PINNED_CA_PEM], not the device's system trust store, so a
|
||||||
@@ -43,8 +31,11 @@ Uk5VTHJchx0xXCdTRSJo2BEh7Q==
|
|||||||
* KeyStore/TrustManager setup from scratch.
|
* KeyStore/TrustManager setup from scratch.
|
||||||
*/
|
*/
|
||||||
val pinnedSslSocketFactory: SSLSocketFactory by lazy {
|
val pinnedSslSocketFactory: 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.
|
||||||
val caCert = CertificateFactory.getInstance("X.509")
|
val caCert = CertificateFactory.getInstance("X.509")
|
||||||
.generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.encodeToByteArray()))
|
.generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.trim().encodeToByteArray()))
|
||||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||||
load(null, null)
|
load(null, null)
|
||||||
setCertificateEntry("ai-app-dev-ca", caCert)
|
setCertificateEntry("ai-app-dev-ca", caCert)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
// Status colors, keyed by the wire strings in Events.kt. Light theme only,
|
// Status colors, keyed by the wire strings in Events.kt. Light theme only,
|
||||||
// as in local-updater.
|
// as in dev-updater.
|
||||||
private val AWAITING_COLOR = Color(0xFFB26A00)
|
private val AWAITING_COLOR = Color(0xFFB26A00)
|
||||||
private val RUNNING_COLOR = Color(0xFF2E7D32)
|
private val RUNNING_COLOR = Color(0xFF2E7D32)
|
||||||
|
|
||||||
|
|||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Builds the app's APK, ready to install on a phone through Dev Updater.
|
||||||
|
#
|
||||||
|
# ./build-apk.sh
|
||||||
|
#
|
||||||
|
# The APK pins the CA on *this* machine ($XDG_CONFIG_HOME/ai-app/certs/ca.pem,
|
||||||
|
# or AI_APP_CA), so build it on the machine that runs the backend: an app
|
||||||
|
# built somewhere else trusts a CA that backend can't present, and simply
|
||||||
|
# won't connect. Start ai-server once first if there are no certificates
|
||||||
|
# yet -- it generates them; the build stops with that instruction if it
|
||||||
|
# can't find one.
|
||||||
|
#
|
||||||
|
# Unlike ./run-android.sh, this touches no emulator: it only produces the
|
||||||
|
# file. Installing on a real phone goes through Dev Updater, which serves
|
||||||
|
# whatever is under this project's build directory.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Prefer an SDK this machine has already configured -- the host and the dev
|
||||||
|
# VM don't keep it in the same place, and android-env.sh is written for the
|
||||||
|
# VM's layout (it also installs missing packages, which isn't wanted here).
|
||||||
|
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then
|
||||||
|
echo "==> Using ANDROID_HOME=$ANDROID_HOME"
|
||||||
|
elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then
|
||||||
|
ANDROID_HOME="$ANDROID_SDK_ROOT"
|
||||||
|
export ANDROID_HOME
|
||||||
|
echo "==> Using ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||||
|
elif [ -d "$HOME/Android/Sdk" ]; then
|
||||||
|
ANDROID_HOME="$HOME/Android/Sdk"
|
||||||
|
ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||||
|
export ANDROID_HOME ANDROID_SDK_ROOT
|
||||||
|
echo "==> Using $ANDROID_HOME"
|
||||||
|
else
|
||||||
|
echo "No Android SDK found. Set ANDROID_HOME to it, or install one" >&2
|
||||||
|
echo "(Android Studio's default location is ~/Android/Sdk)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}"
|
||||||
|
if [ -f "$CA" ]; then
|
||||||
|
# Printed so a wrong or stale certificate is visible here rather than as
|
||||||
|
# a handshake failure on the phone. Compare with the server's own
|
||||||
|
# the CA the server is actually presenting.
|
||||||
|
FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \
|
||||||
|
| openssl pkey -pubin -outform der 2>/dev/null \
|
||||||
|
| openssl dgst -sha256 -binary 2>/dev/null \
|
||||||
|
| openssl base64 2>/dev/null || echo "(openssl unavailable)")
|
||||||
|
echo "==> Pinning the CA at $CA"
|
||||||
|
echo " fingerprint: $FINGERPRINT"
|
||||||
|
else
|
||||||
|
echo "No CA certificate at $CA -- start ai-server once on this machine" >&2
|
||||||
|
echo "(it generates them), or set AI_APP_CA. The APK embeds it at build time." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Building"
|
||||||
|
./gradlew :androidApp:assembleDebug
|
||||||
|
|
||||||
|
APK="$SCRIPT_DIR/androidApp/build/outputs/apk/debug/androidApp-debug.apk"
|
||||||
|
echo
|
||||||
|
echo "==> Built $APK"
|
||||||
|
[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}'
|
||||||
|
echo
|
||||||
|
echo "To get it onto the phone: add this project to Dev Updater (or hit"
|
||||||
|
echo "Update on it if it's already there) and install from there."
|
||||||
|
echo "Then start the backend and scan the enrollment QR it prints:"
|
||||||
|
echo " ./server/target/release/ai-server --rotate-token"
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Builds and runs this app on an emulator, creating/booting the AVD first if
|
# Builds and runs this app on an emulator, creating/booting the AVD first if
|
||||||
# it isn't already up. Same flow as local-updater's run-android.sh; see that
|
# it isn't already up. Same flow as dev-updater's run-android.sh; see that
|
||||||
# script for the reasoning behind the avd handling.
|
# script for the reasoning behind the avd handling.
|
||||||
#
|
#
|
||||||
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
|
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
|
||||||
|
|||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Generates the self-signed dev CA and leaf certificate `server/` serves its
|
|
||||||
# TLS listener with. Run it once, ON THE MACHINE THAT RUNS THE BACKEND, before
|
|
||||||
# the first start; the server exits with a clear message if the certificates
|
|
||||||
# are missing.
|
|
||||||
#
|
|
||||||
# Same scheme as ../local-updater's: nothing on a device trusts this
|
|
||||||
# automatically -- the app embeds the CA certificate verbatim and pins to it
|
|
||||||
# (`PinnedCert.kt`), rather than relying on the device's system trust store.
|
|
||||||
# This server's API *is* remote code execution (it spawns AI sessions on
|
|
||||||
# request), so a MITM on it would be as bad as it gets -- hence pinning.
|
|
||||||
#
|
|
||||||
# WHERE THE KEYS LIVE, AND WHY NOT IN THE REPO
|
|
||||||
#
|
|
||||||
# Output goes to $XDG_CONFIG_HOME/ai-app/certs (0700), deliberately *not*
|
|
||||||
# beside this script. The repo is a virtiofs mount shared with the dev VM,
|
|
||||||
# and that VM is treated as untrusted -- a machine that isn't malicious but
|
|
||||||
# could become so. A CA private key it can read is a CA private key it can
|
|
||||||
# sign with, and a leaf signed by this CA is one the phone's pinned app
|
|
||||||
# accepts without question. Keeping the key off the shared mount is what
|
|
||||||
# makes pinning mean anything.
|
|
||||||
#
|
|
||||||
# The same reasoning says the CA key doesn't belong on the backend either,
|
|
||||||
# strictly: the server only ever reads leaf.pem and leaf-key.pem, and the CA
|
|
||||||
# key is needed solely to reissue a leaf. Moving ca-key.pem somewhere offline
|
|
||||||
# once the setup is stable costs nothing but having it to hand at reissue.
|
|
||||||
#
|
|
||||||
# Running this inside the VM is fine and expected for emulator work -- it
|
|
||||||
# just produces a *different*, throwaway CA there. Never install a build
|
|
||||||
# pinning that dev CA on the real phone.
|
|
||||||
#
|
|
||||||
# The CA is idempotent -- skipped if `certs/ca.pem` already exists, so
|
|
||||||
# re-running this doesn't invalidate the certificate the installed app has
|
|
||||||
# pinned against without a reason to. The leaf is cheap and reissued on
|
|
||||||
# every run (still signed by that same, unchanged CA), so adding another SAN
|
|
||||||
# entry only means rerunning this script, not touching anything pinned.
|
|
||||||
#
|
|
||||||
# The leaf's SANs must cover every address a device reaches this server at.
|
|
||||||
# In production that is exactly one: the backend's WireGuard address, which
|
|
||||||
# the phone uses from everywhere (see PLAN.md's off-network section).
|
|
||||||
# Override with SERVER_IP=... if your wg0 address differs.
|
|
||||||
#
|
|
||||||
# Outputs into `certs/` (gitignored -- private key material, and the whole
|
|
||||||
# thing is trivially regeneratable anyway):
|
|
||||||
# ca.pem the CA certificate (not its private key) -- what the
|
|
||||||
# app embeds and pins against.
|
|
||||||
# ca-key.pem the CA's private key -- only this script needs it, to
|
|
||||||
# sign the leaf below. Never shipped anywhere.
|
|
||||||
# leaf.pem the server's own certificate (CA-signed), presented on
|
|
||||||
# every TLS handshake.
|
|
||||||
# leaf-key.pem the leaf's private key -- what the server loads to
|
|
||||||
# terminate TLS.
|
|
||||||
# ca-sha256.txt the CA certificate's SPKI SHA-256 fingerprint, printed
|
|
||||||
# below too. Informational -- the app embeds the whole
|
|
||||||
# `ca.pem`, not this digest.
|
|
||||||
# leaf-sha256.txt the leaf's SPKI SHA-256 fingerprint. Informational --
|
|
||||||
# nothing pins the leaf; the app pins the CA and
|
|
||||||
# validates the chain.
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
|
||||||
CERTS_DIR="${AI_APP_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs}"
|
|
||||||
# The backend's WireGuard address -- the one address the phone ever dials in
|
|
||||||
# production (PLAN.md: single-path addressing, no home/away distinction).
|
|
||||||
SERVER_IP="${SERVER_IP:-10.66.0.1}"
|
|
||||||
# Also covered so development before the tunnel exists can complete a real
|
|
||||||
# handshake against the same pinned CA:
|
|
||||||
# 127.0.0.1 curl from the machine itself, and tests binding loopback
|
|
||||||
# 10.0.2.2 the Android emulator's alias for the host's loopback
|
|
||||||
# LAN_IP a real phone on the same LAN, pre-WireGuard
|
|
||||||
LOOPBACK_IP="127.0.0.1"
|
|
||||||
EMULATOR_HOST_IP="10.0.2.2"
|
|
||||||
LAN_IP="${LAN_IP:-192.168.1.168}"
|
|
||||||
|
|
||||||
# Private key material: owner-only from the moment it exists, rather than
|
|
||||||
# created world-readable and chmod'ed a beat later.
|
|
||||||
umask 077
|
|
||||||
mkdir -p "$CERTS_DIR"
|
|
||||||
chmod 700 "$CERTS_DIR"
|
|
||||||
cd "$CERTS_DIR"
|
|
||||||
echo "==> Writing certificates to $CERTS_DIR"
|
|
||||||
|
|
||||||
if [ -f ca.pem ]; then
|
|
||||||
echo "==> ca.pem already exists, reusing existing CA."
|
|
||||||
else
|
|
||||||
echo "==> Generating CA key + self-signed CA certificate"
|
|
||||||
openssl ecparam -name prime256v1 -genkey -noout -out ca-key.pem
|
|
||||||
# Explicit keyUsage: strict verifiers (e.g. Python 3.14's ssl) reject a
|
|
||||||
# CA without it, and Android could follow -- cheap to be proper now,
|
|
||||||
# expensive to regenerate after phones have pinned it.
|
|
||||||
openssl req -new -x509 -key ca-key.pem -out ca.pem -days 3650 \
|
|
||||||
-subj "/O=ai-app dev/CN=ai-app dev CA" \
|
|
||||||
-addext "basicConstraints=critical,CA:TRUE" \
|
|
||||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "==> Generating leaf key + CSR for $SERVER_IP (+ dev addresses)"
|
|
||||||
openssl ecparam -name prime256v1 -genkey -noout -out leaf-key.pem
|
|
||||||
openssl req -new -key leaf-key.pem -out leaf.csr \
|
|
||||||
-subj "/O=ai-app dev/CN=$SERVER_IP"
|
|
||||||
|
|
||||||
echo "==> Signing leaf certificate with the dev CA"
|
|
||||||
cat > leaf.ext <<EOF
|
|
||||||
subjectAltName = IP:$SERVER_IP,IP:$LOOPBACK_IP,IP:$EMULATOR_HOST_IP,IP:$LAN_IP
|
|
||||||
basicConstraints = CA:FALSE
|
|
||||||
keyUsage = digitalSignature
|
|
||||||
extendedKeyUsage = serverAuth
|
|
||||||
EOF
|
|
||||||
openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
|
|
||||||
-out leaf.pem -days 3650 -extfile leaf.ext
|
|
||||||
rm -f leaf.csr leaf.ext ca.srl
|
|
||||||
|
|
||||||
echo "==> Computing certificate fingerprints (SPKI SHA-256)"
|
|
||||||
CA_SHA256=$(openssl x509 -in ca.pem -pubkey -noout \
|
|
||||||
| openssl pkey -pubin -outform der \
|
|
||||||
| openssl dgst -sha256 -binary \
|
|
||||||
| openssl base64)
|
|
||||||
echo "$CA_SHA256" > ca-sha256.txt
|
|
||||||
LEAF_SHA256=$(openssl x509 -in leaf.pem -pubkey -noout \
|
|
||||||
| openssl pkey -pubin -outform der \
|
|
||||||
| openssl dgst -sha256 -binary \
|
|
||||||
| openssl base64)
|
|
||||||
echo "$LEAF_SHA256" > leaf-sha256.txt
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "==> Done."
|
|
||||||
echo " CA fingerprint (base64): $CA_SHA256"
|
|
||||||
echo " Leaf fingerprint (base64): $LEAF_SHA256"
|
|
||||||
echo
|
|
||||||
echo " Only relevant if the CA was regenerated just now (i.e. ca.pem did"
|
|
||||||
echo " not already exist): the app embeds PINNED_CA_PEM and needs the new"
|
|
||||||
echo " $CERTS_DIR/ca.pem contents pasted in, or it silently"
|
|
||||||
echo " stops being able to reach this server. The app installs via Local"
|
|
||||||
echo " Updater, so recovery is a reinstall through that -- but it's still"
|
|
||||||
echo " a one-way door for the installed copy."
|
|
||||||
echo
|
|
||||||
echo " app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt"
|
|
||||||
echo
|
|
||||||
echo " Only ca.pem is meant to leave this machine. Copy it by hand; do not"
|
|
||||||
echo " put the certs directory back in the repo, which is shared with the"
|
|
||||||
echo " VM (see this script's header)."
|
|
||||||
Generated
+262
-3
@@ -24,11 +24,12 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-server",
|
"axum-server",
|
||||||
"base64",
|
"base64 0.23.1",
|
||||||
"clap",
|
"clap",
|
||||||
"if-addrs",
|
"if-addrs",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"rand",
|
"rand",
|
||||||
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -109,6 +110,45 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -219,12 +259,27 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.23.1"
|
version = "0.23.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.1"
|
version = "2.13.1"
|
||||||
@@ -363,6 +418,32 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.11.3"
|
version = "0.11.3"
|
||||||
@@ -374,6 +455,17 @@ dependencies = [
|
|||||||
"crypto-common",
|
"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]]
|
[[package]]
|
||||||
name = "dunce"
|
name = "dunce"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -740,6 +832,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -778,6 +876,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -787,6 +895,49 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -799,6 +950,16 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -817,6 +978,12 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.107"
|
version = "1.0.107"
|
||||||
@@ -864,6 +1031,20 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
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]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.18"
|
version = "0.4.18"
|
||||||
@@ -895,6 +1076,15 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rusticata-macros"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -1133,6 +1323,17 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -1175,6 +1376,36 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.1"
|
||||||
@@ -1351,7 +1582,7 @@ version = "3.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.23.1",
|
||||||
"flate2",
|
"flate2",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -1368,7 +1599,7 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.23.1",
|
||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
@@ -1501,6 +1732,34 @@ version = "0.52.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
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]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ qrcode = { version = "0.14", default-features = false }
|
|||||||
# The wg0-bound listener needs the interface's address; the stdlib has no
|
# The wg0-bound listener needs the interface's address; the stdlib has no
|
||||||
# getifaddrs. This is the smallest crate that wraps just that.
|
# getifaddrs. This is the smallest crate that wraps just that.
|
||||||
if-addrs = "0.15"
|
if-addrs = "0.15"
|
||||||
|
# Generates this server's TLS certificates on first start, replacing a
|
||||||
|
# setup script that shelled out to whatever openssl happened to be
|
||||||
|
# installed. In process means 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: reconstructing it from the same
|
||||||
|
# parameters would work only as long as nothing ever changed them, and a
|
||||||
|
# mismatched issuer name yields a chain that fails to validate rather than
|
||||||
|
# anything that looks wrong at generation time.
|
||||||
|
rcgen = { version = "0.14", features = ["pem", "x509-parser"] }
|
||||||
# Outbound HTTPS for the usage endpoint. A small blocking client fits an
|
# Outbound HTTPS for the usage endpoint. A small blocking client fits an
|
||||||
# every-few-minutes poll better than pulling in reqwest's tower stack;
|
# every-few-minutes poll better than pulling in reqwest's tower stack;
|
||||||
# rustls-backed like the rest of the TLS here.
|
# rustls-backed like the rest of the TLS here.
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! The TLS certificates this server presents, generated in process on
|
||||||
|
//! first start.
|
||||||
|
//!
|
||||||
|
//! 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 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`): the repo is a mount shared with a VM that is not
|
||||||
|
//! trusted, and a CA private key that VM can read is one it can sign with
|
||||||
|
//! -- a certificate signed by a pinned CA is accepted without question,
|
||||||
|
//! which is exactly the attack pinning exists to stop.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
||||||
|
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(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(dir)
|
||||||
|
.with_context(|| format!("create {}", dir.display()))?;
|
||||||
|
// Set explicitly as well: `mode` applies only when the directory is
|
||||||
|
// created, so a directory that already existed -- made by hand, or by
|
||||||
|
// an older version -- would otherwise keep whatever permissions it had
|
||||||
|
// while holding a private key.
|
||||||
|
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||||
|
.with_context(|| format!("restrict {}", dir.display()))?;
|
||||||
|
|
||||||
|
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()?;
|
||||||
|
write_private(&ca_key_path, &key)?;
|
||||||
|
write_private(&ca_cert_path, &pem)?;
|
||||||
|
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(&ca_pem, &ca_key_pem, addresses)?;
|
||||||
|
let leaf_cert = dir.join("leaf.pem");
|
||||||
|
let leaf_key = dir.join("leaf-key.pem");
|
||||||
|
write_private(&leaf_key, &leaf_key_pem)?;
|
||||||
|
write_private(&leaf_cert, &leaf_pem)?;
|
||||||
|
|
||||||
|
Ok(Certificates { leaf_cert, leaf_key, ca_is_new })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_ca() -> Result<(String, String)> {
|
||||||
|
let key = KeyPair::generate().context("generate CA key")?;
|
||||||
|
let mut params = CertificateParams::default();
|
||||||
|
params.distinguished_name.push(DnType::OrganizationName, "ai-app dev");
|
||||||
|
params.distinguished_name.push(DnType::CommonName, "ai-app 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(
|
||||||
|
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, "ai-app dev");
|
||||||
|
params.distinguished_name.push(
|
||||||
|
DnType::CommonName,
|
||||||
|
addresses.first().map(|a| a.to_string()).unwrap_or_else(|| "dev-updater".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()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes owner-readable only, from the moment the file exists rather than
|
||||||
|
/// a `chmod` afterwards.
|
||||||
|
fn write_private(path: &Path, contents: &str) -> Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("write {}", path.display()))?;
|
||||||
|
file.write_all(contents.as_bytes())
|
||||||
|
.with_context(|| format!("write {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn addresses() -> Vec<IpAddr> {
|
||||||
|
vec!["10.66.0.1".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(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(dir.path(), &addresses()).expect("regenerate");
|
||||||
|
// The CA is the pinned one: replacing it would strand 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 just
|
||||||
|
// a restart away.
|
||||||
|
assert_ne!(leaf, std::fs::read_to_string(&second.leaf_cert).expect("leaf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_is_owner_only() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let certs = ensure(dir.path(), &addresses()).expect("generate");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(dir.path()).expect("dir").permissions().mode() & 0o777,
|
||||||
|
0o700,
|
||||||
|
);
|
||||||
|
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
|
||||||
|
let mode = std::fs::metadata(dir.path().join(file))
|
||||||
|
.expect(file)
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600, "{file} is not owner-only");
|
||||||
|
}
|
||||||
|
assert!(certs.leaf_key.is_file());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pair has to be loadable by the TLS stack that will actually
|
||||||
|
/// serve it -- a "file exists" check wouldn't catch a key that doesn't
|
||||||
|
/// match its certificate, which fails at the first handshake instead.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_leaf_loads_into_the_real_tls_config() {
|
||||||
|
// main() installs this; tests don't run main. Both rustls crypto
|
||||||
|
// providers are in the graph (ureq brings ring, axum-server
|
||||||
|
// aws-lc-rs), so rustls refuses to pick one on its own. Ignoring
|
||||||
|
// the result because another test may have installed it first.
|
||||||
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let certs = ensure(dir.path(), &addresses()).expect("generate");
|
||||||
|
axum_server::tls_rustls::RustlsConfig::from_pem_file(&certs.leaf_cert, &certs.leaf_key)
|
||||||
|
.await
|
||||||
|
.expect("the generated leaf and key should load as a TLS identity");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
//! holds only the metadata needed to list and respawn sessions.
|
//! holds only the metadata needed to list and respawn sessions.
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -26,7 +26,13 @@ pub fn create_private_dir(dir: &Path) -> Result<()> {
|
|||||||
.recursive(true)
|
.recursive(true)
|
||||||
.mode(0o700)
|
.mode(0o700)
|
||||||
.create(dir)
|
.create(dir)
|
||||||
.with_context(|| format!("create {}", dir.display()))
|
.with_context(|| format!("create {}", dir.display()))?;
|
||||||
|
// Set explicitly as well: `mode` applies only when the directory is
|
||||||
|
// created, so one that already existed -- made by hand, or by a
|
||||||
|
// version that didn't do this -- would otherwise keep whatever
|
||||||
|
// permissions it had while holding transcripts.
|
||||||
|
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||||
|
.with_context(|| format!("restrict {}", dir.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens `path` for writing, creating it owner-readable only.
|
/// Opens `path` for writing, creating it owner-readable only.
|
||||||
|
|||||||
+50
-18
@@ -14,6 +14,7 @@
|
|||||||
//! unencrypted by misconfiguration -- even inside the tunnel.
|
//! unencrypted by misconfiguration -- even inside the tunnel.
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod certs;
|
||||||
mod config;
|
mod config;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod session;
|
mod session;
|
||||||
@@ -24,7 +25,7 @@ use std::net::{IpAddr, SocketAddr};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use config::TokenEntry;
|
use config::TokenEntry;
|
||||||
@@ -79,8 +80,8 @@ struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
data_dir: Option<PathBuf>,
|
data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
/// Directory holding `leaf.pem`/`leaf-key.pem`, as produced by
|
/// Directory holding the TLS certificates, generated here on first
|
||||||
/// `gen-dev-cert.sh`. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
|
/// start. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
certs: Option<PathBuf>,
|
certs: Option<PathBuf>,
|
||||||
|
|
||||||
@@ -90,6 +91,30 @@ struct Args {
|
|||||||
rotate_token: bool,
|
rotate_token: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every address this machine answers on, for the leaf's SANs -- so the
|
||||||
|
/// certificate covers whatever the phone actually dials without anyone
|
||||||
|
/// maintaining a hardcoded IP. In production that is the WireGuard
|
||||||
|
/// address; loopback is included for curl and tests, and 10.0.2.2 is the
|
||||||
|
/// alias an Android emulator reaches its host by, which is not a real
|
||||||
|
/// interface anywhere.
|
||||||
|
fn local_addresses() -> Vec<IpAddr> {
|
||||||
|
let mut addresses = vec![IpAddr::from([127, 0, 0, 1]), IpAddr::from([10, 0, 2, 2])];
|
||||||
|
match if_addrs::get_if_addrs() {
|
||||||
|
Ok(interfaces) => {
|
||||||
|
for interface in interfaces {
|
||||||
|
let ip = interface.ip();
|
||||||
|
if ip.is_ipv4() && !addresses.contains(&ip) {
|
||||||
|
addresses.push(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not fatal: the certificate still covers loopback, which is
|
||||||
|
// enough to start and to diagnose from the machine itself.
|
||||||
|
Err(err) => tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"),
|
||||||
|
}
|
||||||
|
addresses
|
||||||
|
}
|
||||||
|
|
||||||
/// The IPv4 address on the WireGuard interface, or a refusal to start.
|
/// The IPv4 address on the WireGuard interface, or a refusal to start.
|
||||||
/// Failing closed here (rather than falling back to a wider bind) is part
|
/// Failing closed here (rather than falling back to a wider bind) is part
|
||||||
/// of the security posture -- see the module doc comment.
|
/// of the security posture -- see the module doc comment.
|
||||||
@@ -157,6 +182,22 @@ async fn main() -> Result<()> {
|
|||||||
tracing::info!(" session {} ({}, {:?})", info.id, info.provider, info.status);
|
tracing::info!(" session {} ({}, {:?})", info.id, info.provider, info.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before the interface check below, deliberately: the certificates are
|
||||||
|
// also what the phone app embeds at build time, so they need to be
|
||||||
|
// obtainable on a machine whose tunnel isn't up yet. The leaf is
|
||||||
|
// reissued on every start, so once wg0 exists the next start covers it.
|
||||||
|
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
|
||||||
|
let certificates = certs::ensure(&certs_dir, &local_addresses())
|
||||||
|
.with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?;
|
||||||
|
if certificates.ca_is_new {
|
||||||
|
tracing::warn!(
|
||||||
|
"a new CA was generated in {} -- any installed app pins the previous one and can no \
|
||||||
|
longer reach this server. Rebuild it with app/build-apk.sh, which embeds this CA, \
|
||||||
|
and reinstall through Dev Updater.",
|
||||||
|
certs_dir.display(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let bind_ip = match args.bind {
|
let bind_ip = match args.bind {
|
||||||
Some(ip) => {
|
Some(ip) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -183,21 +224,12 @@ async fn main() -> Result<()> {
|
|||||||
print_enrollment(bind_ip, args.port, &token)?;
|
print_enrollment(bind_ip, args.port, &token)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let certs_dir = args.certs.unwrap_or_else(|| config_home().join("certs"));
|
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
||||||
let leaf_cert = certs_dir.join("leaf.pem");
|
&certificates.leaf_cert,
|
||||||
let leaf_key = certs_dir.join("leaf-key.pem");
|
&certificates.leaf_key,
|
||||||
if !leaf_cert.is_file() || !leaf_key.is_file() {
|
)
|
||||||
bail!(
|
.await
|
||||||
"missing {} / {} -- run ./gen-dev-cert.sh on this machine first. The app pins the CA \
|
.context("failed to load TLS cert/key")?;
|
||||||
it generates and this server refuses to serve without TLS, so the private keys must \
|
|
||||||
be generated here and stay here.",
|
|
||||||
leaf_cert.display(),
|
|
||||||
leaf_key.display(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(&leaf_cert, &leaf_key)
|
|
||||||
.await
|
|
||||||
.context("failed to load TLS cert/key")?;
|
|
||||||
|
|
||||||
let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(usage::ClaudeUsage {
|
let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(usage::ClaudeUsage {
|
||||||
credentials_path: std::env::home_dir()
|
credentials_path: std::env::home_dir()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! The live session registry. Every session mutation -- spawn, delete,
|
//! The live session registry. Every session mutation -- spawn, delete,
|
||||||
//! token changes -- funnels through [`SessionManager`] under one lock, so
|
//! token changes -- funnels through [`SessionManager`] under one lock, so
|
||||||
//! in-memory state and `config.json` can't come apart (the same pattern as
|
//! in-memory state and `config.json` can't come apart (the same pattern as
|
||||||
//! local-updater's `registry.rs`).
|
//! dev-updater's `registry.rs`).
|
||||||
//!
|
//!
|
||||||
//! A live session is a driver plus one event pump: the driver reports
|
//! A live session is a driver plus one event pump: the driver reports
|
||||||
//! [`Event`]s into an mpsc channel; the pump assigns each a sequence
|
//! [`Event`]s into an mpsc channel; the pump assigns each a sequence
|
||||||
|
|||||||
+6
-6
@@ -14,8 +14,8 @@
|
|||||||
# The veth pair stands in for "the internet" carrying WireGuard's UDP; the
|
# The veth pair stands in for "the internet" carrying WireGuard's UDP; the
|
||||||
# wg interfaces are real, with a real handshake and real keys. 10.66.0.1 is
|
# wg interfaces are real, with a real handshake and real keys. 10.66.0.1 is
|
||||||
# deliberately the same address the leaf certificate carries a SAN for
|
# deliberately the same address the leaf certificate carries a SAN for
|
||||||
# (gen-dev-cert.sh), so a client inside the tunnel completes the same
|
# (certs.rs covers every local address), so a client inside the tunnel
|
||||||
# pinned-TLS handshake a phone will.
|
# completes the same pinned-TLS handshake a phone will.
|
||||||
#
|
#
|
||||||
# ./test-wg-tunnel.sh up create the tunnel (needs sudo)
|
# ./test-wg-tunnel.sh up create the tunnel (needs sudo)
|
||||||
# ./test-wg-tunnel.sh test run the server on wg0 and reach it from "phone"
|
# ./test-wg-tunnel.sh test run the server on wg0 and reach it from "phone"
|
||||||
@@ -80,8 +80,9 @@ up() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test_tunnel() {
|
test_tunnel() {
|
||||||
if [ ! -f "$REPO/certs/leaf.pem" ]; then
|
CERTS="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs"
|
||||||
echo "No certs/ -- run ./gen-dev-cert.sh first." >&2
|
if [ ! -f "$CERTS/leaf.pem" ]; then
|
||||||
|
echo "No certificates in $CERTS -- start ai-server once; it makes them." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ ! -x "$REPO/server/target/debug/ai-server" ]; then
|
if [ ! -x "$REPO/server/target/debug/ai-server" ]; then
|
||||||
@@ -98,13 +99,12 @@ test_tunnel() {
|
|||||||
ss -tlnp 2>/dev/null | grep 8443 | sed 's/^/ /' || echo " (nothing on 8443)"
|
ss -tlnp 2>/dev/null | grep 8443 | sed 's/^/ /' || echo " (nothing on 8443)"
|
||||||
|
|
||||||
echo "==> From inside the tunnel: GET /sessions through wg1 -> wg0"
|
echo "==> From inside the tunnel: GET /sessions through wg1 -> wg0"
|
||||||
TOKEN=$(sudo cat "$REPO/config.json" 2>/dev/null | sed -n 's/.*"sha256": "\(.*\)".*/\1/p' | head -1)
|
|
||||||
if [ -z "${AI_TOKEN:-}" ]; then
|
if [ -z "${AI_TOKEN:-}" ]; then
|
||||||
echo " (set AI_TOKEN=<the enrollment token> to test an authorized call;"
|
echo " (set AI_TOKEN=<the enrollment token> to test an authorized call;"
|
||||||
echo " without it this only proves reachability + TLS, via a 401)"
|
echo " without it this only proves reachability + TLS, via a 401)"
|
||||||
fi
|
fi
|
||||||
sudo ip netns exec "$NS" curl -s -o /dev/null -w " HTTP %{http_code} (TLS ok, pinned CA)\n" \
|
sudo ip netns exec "$NS" curl -s -o /dev/null -w " HTTP %{http_code} (TLS ok, pinned CA)\n" \
|
||||||
--cacert "$REPO/certs/ca.pem" \
|
--cacert "$CERTS/ca.pem" \
|
||||||
${AI_TOKEN:+-H "Authorization: Bearer $AI_TOKEN"} \
|
${AI_TOKEN:+-H "Authorization: Bearer $AI_TOKEN"} \
|
||||||
"https://$SERVER_WG_IP:8443/sessions" || echo " UNREACHABLE"
|
"https://$SERVER_WG_IP:8443/sessions" || echo " UNREACHABLE"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user