Generate the TLS certificates in process
gen-dev-cert.sh is gone. The server ensures its own certificates on start, which removes a setup step to remember, a dependency on whatever openssl was installed, and a second place for the "which addresses?" answer to live -- the leaf now covers every local IPv4 plus loopback and the emulator's host alias, so nobody maintains a hardcoded IP. The split that mattered in the script is kept and now enforced by tests: the CA is generated once and left alone, because the app pins it and replacing it strands every installed copy; the leaf is cheap and reissued every start, so covering a new address is a restart. Both are written owner-only into a directory outside the repo. Two things the tests caught. DirBuilder's mode applies only when the directory is created, so a directory that already existed kept whatever permissions it had while holding a private key -- the mode is now set explicitly, in the session directories too. And loading the leaf into the real RustlsConfig needs the crypto provider installed, which main does but tests don't. Verified end to end: deleted the certs, started the server, watched it generate a CA and warn that installed apps now pin the wrong one, rebuilt the APK against the new CA, and reinstalled -- the emulator connects over a certificate that never existed as a pasted constant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
eeb2dde4ab
commit
65743b899d
8 files changed
+548
-173
No files matched your search
@@ -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
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ real-phone/WireGuard bring-up, which is operational rather than code.
|
|||||||
on the emulator.
|
on the emulator.
|
||||||
- **The APK pins the CA of the machine that builds it**, read at build time
|
- **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
|
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides) and
|
||||||
generated into a constant. So `./gen-dev-cert.sh` must have run on that
|
generated into a constant. So the server must have started once on that
|
||||||
machine first — the build stops with that instruction otherwise — and an
|
machine first — the build stops with that instruction otherwise — and an
|
||||||
APK built in this VM only works against a server in this VM.
|
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
|
||||||
@@ -122,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
|
||||||
|
|||||||
@@ -259,9 +259,14 @@ 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 local-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 local-updater, the pinned CA is **not a constant in the source**:
|
- Unlike local-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
|
the build reads `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` from the machine
|
||||||
doing the build and generates the constant (`generatePinnedCert` in
|
doing the build and generates the constant (`generatePinnedCert` in
|
||||||
|
|||||||
-141
@@ -1,141 +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): any app already installed pins the *previous*"
|
|
||||||
echo " CA and silently stops being able to reach this server. Rebuild and"
|
|
||||||
echo " reinstall it -- the APK embeds whatever ca.pem is here at build"
|
|
||||||
echo " time, so there is nothing to paste:"
|
|
||||||
echo
|
|
||||||
echo " ./app/build-apk.sh # then install via Local Updater"
|
|
||||||
echo
|
|
||||||
echo " Nothing but ca.pem is meant to leave this machine, and it leaves"
|
|
||||||
echo " only by being compiled into an APK built here. Don't put this"
|
|
||||||
echo " directory in the repo, which is shared with the VM (see the 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(|| "local-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.
|
||||||
|
|||||||
+48
-16
@@ -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 Local 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,19 +224,10 @@ 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!(
|
|
||||||
"missing {} / {} -- run ./gen-dev-cert.sh on this machine first. The app pins the CA \
|
|
||||||
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
|
.await
|
||||||
.context("failed to load TLS cert/key")?;
|
.context("failed to load TLS cert/key")?;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user