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:
iris committed 2026-08-25 12:03:21 -04:00
commit d4a4ee7808
17 files changed
+776 -228

No files matched your search

+33 -15
View File
@@ -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.
+26 -11
View File
@@ -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).
+89
View File
@@ -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)
+1 -1
View File
@@ -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)
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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)."
+262 -3
View File
@@ -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"
+9
View File
@@ -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.
+202
View File
@@ -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");
}
}
+8 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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"