diff --git a/README.md b/README.md index c99fad6..a4cf145 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# wg-server-app +# wg-app-link The private link between a phone and a machine you run, extracted from the two projects that had each written it. @@ -94,21 +94,21 @@ time the sharing paid rather than merely being argued for. ## What is here -`server/` — the `wg-app-link` crate. Three modules, each extracted only after +`server/` — the `wg-app-link` crate. Five modules, each extracted only after diffing the two copies and finding nothing but a name between them. -- **`netif`** — `wg_address()`, which fails closed when the tunnel is down, and `local_addresses()` for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining. +- **`netif`** — `wg_address()`, which fails closed when the tunnel is down, and `local_addresses()` for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining. Both split the *lookup* from the *decision*, so the failure path can be tested on a machine that has a tunnel — every machine this runs on does. - **`enroll`** — token generation, hex-SHA-256 storage, constant-time comparison, the `://enroll?…` URI, and the terminal QR. The URI scheme is the parameter, because it is what routes a scan back to the right app. +- **`certs`** — the CA generated once and never replaced, the leaf reissued every start. `product` names the organisation and common name and is the whole of what is per-project. Shared because being written twice is worst here: a trust anchor built two ways can be built differently two ways, and the difference surfaces as an opaque handshake failure on a phone. +- **`format`** — the two RON house rules. This was byte-identical in both projects, which made it the clearest thing in the evidence table and the easiest deletion. - **`private`** — owner-only files and directories, taken from ai-app's version, with an `append_file` alongside `create_file` because a transcript must never be truncated by being opened. ## What should follow, and what should not **Should follow, in this order.** Each is already near-identical: -1. `certs` — CA generated once and never replaced, leaf reissued every start. One `product: &str`. -2. The RON `format` house rules — byte-identical today, so this is pure deletion. -3. The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in *both* directions. -4. Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs. +1. The Kotlin `EnrollmentScanActivity`, `PinnedCert`, and the enrollment/Keystore half of `ServerConfig`, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in *both* directions — and `applyPinnedTls` is security-critical and identical, which is the same argument `certs` won on. +2. Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs. **Should not.** Naming these is the point of the exercise: @@ -117,6 +117,37 @@ diffing the two copies and finding nothing but a name between them. - **Config schemas.** Shared house rules, separate contents. - **Anything above the link.** Projects, builds, sessions, providers. If a third project would not want it, it does not belong here. +## Two decisions worth stating, rather than inheriting + +**`WG_INTERFACE` is a constant, not a parameter.** For these two projects a +second answer would mean two ideas of what "the tunnel" is, and there is +one tunnel. That reasoning is about *these* projects rather than about the +world, so it is the first thing a third user should expect to change. + +**`local_addresses` puts `10.0.2.2` in every certificate.** It is the alias +an Android emulator reaches its host by, and not an address the machine +owns — so this is a SAN for something that is not this host, present so a +debug build can reach a server running beside it. Inherited from ai-app +and correct there; written down here because promoting it makes it every +future project's default rather than one project's choice. + +## A review heuristic this repo keeps proving + +**When you find a rule stated somewhere, grep for its siblings.** Three +separate bugs today were the correct rule already written down, applied to +one member of a set and not the others: + +- `create_dir` set its mode a second time and explained why; `create_file` + and `append_file`, three functions below, did not. +- ai-app explained one ssh failure and not its sibling. +- dev-updater's acceptance gate compared components by *declaration*, and + `BuildState::matches` — which meant the same thing — compared them by + full equality, so writing a measured field replaced a running build's + state. + +None was found by a test. All three were found by a second reader looking +at code as a set, which is the thing this crate is actually buying. + ## The name `wg-app-link`: the link between an app and the machine it talks to, over diff --git a/server/Cargo.lock b/server/Cargo.lock index df2522b..3a3c7ef 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -8,17 +8,80 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -29,6 +92,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -70,6 +143,32 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.11.3" @@ -81,6 +180,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "errno" version = "0.3.14" @@ -88,7 +198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -97,6 +207,23 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -125,9 +252,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -140,18 +273,99 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -189,7 +403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom", + "getrandom 0.4.3", "rand_core", ] @@ -199,6 +413,57 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -209,7 +474,46 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", ] [[package]] @@ -223,6 +527,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "subtle" version = "2.6.1" @@ -240,6 +550,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -247,10 +579,60 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", ] [[package]] @@ -272,7 +654,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -284,6 +666,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "typenum" version = "1.20.1" @@ -296,15 +684,30 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wg-app-link" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.23.1", "if-addrs", "qrcode", "rand", + "rcgen", + "ron", + "serde", "sha2", "subtle", "tempfile", @@ -317,6 +720,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -325,3 +737,101 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/server/Cargo.toml b/server/Cargo.toml index 5e0e5d2..205a111 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -6,6 +6,9 @@ description = "The private link between a phone and a machine you run: WireGuard [dependencies] anyhow = "1" +# The config format both projects use, and the two house rules they share. +ron = "0.12.2" +serde = { version = "1", features = ["derive"] } # Enumerating this machine's addresses, and finding the tunnel's. if-addrs = "0.15" # Token auth: hash for storage, constant-time compare for verification, @@ -17,6 +20,11 @@ base64 = "0.23" # Renders the enrollment QR straight to the terminal; no image output. qrcode = { version = "0.14", default-features = false } tracing = "0.1" +# Generates the CA and leaf in process, so one place decides the +# extensions, the file modes, and which addresses the leaf covers. +# x509-parser so the issuer is read back from the CA actually on disk +# rather than reconstructed from parameters that may since have changed. +rcgen = { version = "0.14", features = ["pem", "x509-parser"] } [dev-dependencies] tempfile = "3" diff --git a/server/src/certs.rs b/server/src/certs.rs new file mode 100644 index 0000000..25ed037 --- /dev/null +++ b/server/src/certs.rs @@ -0,0 +1,239 @@ +//! The TLS certificates a server presents, generated in process on first +//! start, from a CA the app pins. +//! +//! Shared because it existed twice and differed by an organisation name. +//! It is also the piece where being written twice is worst: a trust +//! anchor built two ways can be built differently two ways, and the +//! difference would surface as an opaque handshake failure on a phone. +//! +//! `product` names the certificate's organisation and common name, and is +//! the whole of what is per-project. +//! +//! There used to be a `gen-dev-cert.sh` calling openssl, which meant a +//! setup step to remember, a second place for the "which SANs?" answer to +//! live, and a dependency on whatever openssl was installed. Doing it here +//! means the server can simply ensure its own certificates exist, with the +//! file modes and extensions it wants, and with the address it is actually +//! about to bind already in the leaf. +//! +//! The split that matters is between the two: +//! +//! - The **CA** is generated once and then left alone. The updater app +//! pins it, so replacing it strands every installed copy -- recovery is +//! a reinstall over the plain-HTTP bootstrap port. It is the one thing +//! here that is a one-way door. +//! - The **leaf** is cheap and reissued on every start, signed by that +//! same unchanged CA. Nothing pins it, so covering a new address is just +//! a restart rather than anything the phone has to be told about. +//! +//! Everything is written owner-only into a directory outside the repo (see +//! `config_home`): a CA private key readable by another machine is one it +//! can sign with, and a certificate signed by a pinned CA is accepted +//! without question. + +use std::net::IpAddr; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use rcgen::{ + BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType, +}; + +/// Where the leaf lives, for handing to the TLS listener. +pub struct Certificates { + pub leaf_cert: PathBuf, + pub leaf_key: PathBuf, + /// True when the CA was created just now, i.e. anything already + /// installed pins the wrong one and has to be reinstalled. + pub ca_is_new: bool, +} + +/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what +/// is missing. Safe to call on every start. +pub fn ensure(product: &str, dir: &Path, addresses: &[IpAddr]) -> Result { + crate::private::create_dir(dir)?; + + let ca_cert_path = dir.join("ca.pem"); + let ca_key_path = dir.join("ca-key.pem"); + let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file(); + + let (ca_pem, ca_key_pem) = if ca_is_new { + let (pem, key) = generate_ca(product)?; + crate::private::write_file(&ca_key_path, key.as_bytes())?; + crate::private::write_file(&ca_cert_path, pem.as_bytes())?; + tracing::info!("generated a new CA in {}", dir.display()); + (pem, key) + } else { + ( + std::fs::read_to_string(&ca_cert_path) + .with_context(|| format!("read {}", ca_cert_path.display()))?, + std::fs::read_to_string(&ca_key_path) + .with_context(|| format!("read {}", ca_key_path.display()))?, + ) + }; + + let (leaf_pem, leaf_key_pem) = generate_leaf(product, &ca_pem, &ca_key_pem, addresses)?; + let leaf_cert = dir.join("leaf.pem"); + let leaf_key = dir.join("leaf-key.pem"); + crate::private::write_file(&leaf_key, leaf_key_pem.as_bytes())?; + crate::private::write_file(&leaf_cert, leaf_pem.as_bytes())?; + + Ok(Certificates { + leaf_cert, + leaf_key, + ca_is_new, + }) +} + +fn generate_ca(product: &str) -> Result<(String, String)> { + let key = KeyPair::generate().context("generate CA key")?; + let mut params = CertificateParams::default(); + params + .distinguished_name + .push(DnType::OrganizationName, format!("{product} dev")); + params + .distinguished_name + .push(DnType::CommonName, format!("{product} dev CA")); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + // Explicit, because strict verifiers reject a CA without them -- and + // that rejection surfaces as an opaque handshake failure on a phone. + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let certificate = params.self_signed(&key).context("self-sign CA")?; + Ok((certificate.pem(), key.serialize_pem())) +} + +fn generate_leaf( + product: &str, + ca_pem: &str, + ca_key_pem: &str, + addresses: &[IpAddr], +) -> Result<(String, String)> { + let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?; + let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?; + + let key = KeyPair::generate().context("generate leaf key")?; + let mut params = CertificateParams::default(); + params + .distinguished_name + .push(DnType::OrganizationName, format!("{product} dev")); + params.distinguished_name.push( + DnType::CommonName, + addresses + .first() + .map(|a| a.to_string()) + .unwrap_or_else(|| product.to_string()), + ); + params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect(); + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.use_authority_key_identifier_extension = true; + let certificate = params.signed_by(&key, &issuer).context("sign leaf")?; + Ok((certificate.pem(), key.serialize_pem())) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use super::*; + + fn decode_pem(pem: &str) -> Vec { + use base64::Engine; + let body: String = pem + .lines() + .filter(|line| !line.starts_with("-----")) + .collect(); + base64::engine::general_purpose::STANDARD + .decode(body) + .expect("base64") + } + + fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) + } + + fn addresses() -> Vec { + vec![ + "192.168.1.168".parse().unwrap(), + "127.0.0.1".parse().unwrap(), + ] + } + + #[test] + fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() { + let dir = tempfile::tempdir().expect("tempdir"); + + let first = ensure("demo", dir.path(), &addresses()).expect("generate"); + assert!(first.ca_is_new); + let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca"); + let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf"); + assert!(ca.starts_with("-----BEGIN CERTIFICATE-----")); + + let second = ensure("demo", dir.path(), &addresses()).expect("regenerate"); + // The CA is the pinned one: replacing it strands every installed + // app, so it must survive a restart untouched. + assert!(!second.ca_is_new); + assert_eq!( + ca, + std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca") + ); + // The leaf is not pinned, and is reissued so a new address is a + // restart away rather than a reinstall. + assert_ne!( + leaf, + std::fs::read_to_string(&second.leaf_cert).expect("leaf") + ); + } + + /// Private keys are rewritten on every start, which is the case that + /// makes the mode matter: `private` narrows a file that already + /// exists, and this is the caller that depends on it. + #[test] + fn everything_is_owner_only_even_on_the_second_start() { + let dir = tempfile::tempdir().expect("tempdir"); + ensure("demo", dir.path(), &addresses()).expect("generate"); + + // What an older version could have left behind. + for file in ["leaf.pem", "leaf-key.pem"] { + std::fs::set_permissions( + dir.path().join(file), + std::fs::Permissions::from_mode(0o644), + ) + .expect("chmod"); + } + ensure("demo", dir.path(), &addresses()).expect("reissue"); + + let mode = |path: std::path::PathBuf| { + std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777 + }; + assert_eq!(mode(dir.path().to_path_buf()), 0o700); + for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] { + assert_eq!( + mode(dir.path().join(file)), + 0o600, + "{file} is not owner-only" + ); + } + } + + /// The product name is the whole of what is per-project, so it has to + /// reach the certificate rather than being decoration. + #[test] + fn the_product_names_the_certificate() { + let dir = tempfile::tempdir().expect("tempdir"); + ensure("dev-updater", dir.path(), &addresses()).expect("generate"); + let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca"); + + // Decoded rather than searched as base64: a distinguished name is + // stored in the DER as literal bytes, so this checks the value + // that actually reaches a phone rather than the value we passed in. + let der = decode_pem(&ca); + assert!( + contains(&der, b"dev-updater dev CA"), + "the CA's common name" + ); + assert!(contains(&der, b"dev-updater dev"), "the organisation"); + } +} diff --git a/server/src/format.rs b/server/src/format.rs new file mode 100644 index 0000000..6d8d1ec --- /dev/null +++ b/server/src/format.rs @@ -0,0 +1,156 @@ +//! Reading and writing the RON both projects' config files are in. +//! +//! Two things are house rules rather than plain RON, and they are here +//! together because they are inverses of each other -- change one and the +//! other stops round-tripping. Both projects had this, identically, to the +//! byte; that is what made it the first thing worth sharing. +//! +//! **No outer parentheses.** A file *is* the body of the struct, so +//! nothing in it is indented for the sake of a wrapper. RON has no +//! implicit top-level struct (`de/mod.rs` requires the `(`), so [`parse`] +//! adds it and [`render`] takes it back off. The opening paren is not +//! followed by a newline, so a parse error's line number still points at +//! the real line. +//! +//! **`Some` is implicit.** Enabled on the deserializer rather than by a +//! `#![enable(implicit_some)]` header every file would have to remember, +//! and matched on the writing side by `skip_serializing_if` so nothing +//! writes back a `Some(...)` a person didn't type. The two halves only +//! round-trip together, which is why a caller's own tests should assert +//! the written shape rather than only that it loads. + +/// Reading and writing the RON these files are in. +/// +/// Two things are house rules rather than plain RON, and they are here +/// together because they are inverses of each other -- change one and the +/// other stops round-tripping. +/// +/// **No outer parentheses.** A file *is* the body of the struct, so nothing +/// in it is indented for the sake of a wrapper. RON has no implicit +/// top-level struct (`de/mod.rs` requires the `(`), so [`format::parse`] +/// adds it and [`format::render`] takes it back off. The opening paren is not followed by a +/// newline, so a parse error's line number still points at the real line. +/// +/// **`Some` is implicit.** Enabled on the deserializer rather than by a +/// `#![enable(implicit_some)]` header every project file would have to +/// remember, and matched on the writing side by `skip_serializing_if` so +/// nothing writes back a `Some(...)` a person didn't type. +use serde::{Serialize, de::DeserializeOwned}; + +fn options() -> ron::Options { + ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME) +} + +pub fn parse(text: &str) -> Result { + options().from_str(&format!("({text})")) +} + +pub fn render(value: &T) -> Result { + let pretty = ron::ser::PrettyConfig::new(); + let text = options().to_string_pretty(value, pretty)?; + Ok(unwrap_outer(&text)) +} + +/// Strips the outer `(`/`)` the writer always emits and removes the +/// indent level they cost. Deliberately narrow: it accepts only the +/// exact shape `PrettyConfig` produces, and leaves anything else alone +/// rather than guessing -- a file with stray parentheses is better than +/// one silently mangled. `parse` round-trips either way, since a +/// wrapped body parses the same as an unwrapped one re-wrapped. +fn unwrap_outer(text: &str) -> String { + let Some(body) = text + .strip_prefix("(\n") + .and_then(|rest| rest.strip_suffix("\n)")) + else { + return text.to_string(); + }; + let mut out: String = body + .lines() + .map(|line| line.strip_prefix(" ").unwrap_or(line)) + .collect::>() + .join("\n"); + out.push('\n'); + out +} + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + use super::*; + + #[derive(Debug, Default, PartialEq, Serialize, Deserialize)] + #[serde(rename_all = "camelCase", default)] + struct Demo { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + note: Option, + } + + /// The house rule both halves depend on: what is written is the *body* + /// of the struct, with no outer parentheses and nothing indented for + /// them. Asserted rather than trusted, because `render` strips what + /// `parse` adds -- if only one ever changed, every file on disk would + /// still load and only look wrong. + #[test] + fn a_file_is_the_body_of_the_struct() { + let written = render(&Demo { + name: "thing".to_string(), + note: Some("why".to_string()), + }) + .expect("render"); + + assert!( + !written.trim_start().starts_with('('), + "outer parens: {written}" + ); + assert!( + written.starts_with("name: "), + "top level sits at column 0: {written}" + ); + assert_eq!( + parse::(&written).expect("re-read").name, + "thing", + "what is written must read back", + ); + } + + /// An optional value is written as itself, never wrapped -- and a + /// value nobody set is not written at all, so a file stays readable as + /// what was actually chosen. + #[test] + fn an_optional_value_is_written_as_itself_or_not_at_all() { + let with = render(&Demo { + name: "a".to_string(), + note: Some("b".to_string()), + }) + .expect("render"); + assert!( + with.contains(r#"note: "b""#), + "no Some(...) wrapper: {with}" + ); + + let without = render(&Demo::default()).expect("render"); + assert!( + !without.contains("note"), + "an unset value writes nothing: {without}" + ); + + // And the bare form reads back, which is the other half. + assert_eq!( + parse::("name: \"a\",\nnote: \"b\",\n") + .expect("parse") + .note + .as_deref(), + Some("b") + ); + } + + /// A parse error's line number has to point at the real line, which is + /// why the opening paren is not followed by a newline. + #[test] + fn a_parse_error_points_at_the_line_it_is_on() { + let err = parse::("name: \"a\",\nnote: ,\n").expect_err("malformed"); + assert_eq!(err.span.start.line, 2, "{err}"); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 8578cc9..1b57ddb 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -27,6 +27,8 @@ //! that both files are written in are shared, since those were identical //! to the byte. +pub mod certs; pub mod enroll; +pub mod format; pub mod netif; pub mod private; diff --git a/server/src/netif.rs b/server/src/netif.rs index 41816c2..ed2e942 100644 --- a/server/src/netif.rs +++ b/server/src/netif.rs @@ -30,20 +30,29 @@ const EMULATOR_HOST_ALIAS: [u8; 4] = [10, 0, 2, 2]; /// loopback, which is enough to start and to diagnose from the machine /// itself. pub fn local_addresses() -> Vec { + match if_addrs::get_if_addrs() { + Ok(interfaces) => addresses_among(interfaces.iter().map(|iface| iface.ip())), + Err(err) => { + tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"); + addresses_among(std::iter::empty()) + } + } +} + +/// The SAN list built from `found`, which is the part worth testing. +/// +/// Split from the lookup so the outcome does not depend on what this +/// machine happens to have; see [`wg_address_among`] for the same +/// reasoning stated at length. +pub fn addresses_among(found: impl IntoIterator) -> Vec { let mut addresses = vec![ IpAddr::from([127, 0, 0, 1]), IpAddr::from(EMULATOR_HOST_ALIAS), ]; - 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); - } - } + for ip in found { + if ip.is_ipv4() && !addresses.contains(&ip) { + addresses.push(ip); } - Err(err) => tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"), } addresses } @@ -59,10 +68,30 @@ pub fn local_addresses() -> Vec { /// advice rather than as a library complaining. pub fn wg_address(product: &str) -> Result { let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?; + wg_address_among( + product, + interfaces + .iter() + .map(|iface| (iface.name.as_str(), iface.ip())), + ) +} + +/// The same answer, from interfaces the caller supplies. +/// +/// This exists because the failure is the interesting half -- it is the +/// message somebody reads when nothing works -- and it cannot be reached +/// on any machine this runs on, all of which have the tunnel up. A test +/// that calls [`wg_address`] and skips when it succeeds passes everywhere +/// and checks nothing, which is the shape of test this project has been +/// bitten by more than once. +pub fn wg_address_among<'a>( + product: &str, + interfaces: impl IntoIterator, +) -> Result { interfaces .into_iter() - .find(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4()) - .map(|iface| iface.ip()) + .find(|(name, ip)| *name == WG_INTERFACE && ip.is_ipv4()) + .map(|(_, ip)| ip) .ok_or_else(|| { anyhow::anyhow!( "no IPv4 address on interface {WG_INTERFACE} -- {product} binds only to the \ @@ -76,14 +105,24 @@ pub fn wg_address(product: &str) -> Result { mod tests { use super::*; - /// Whatever this machine has, the two that are not interfaces must be + fn ip(s: &str) -> IpAddr { + s.parse().expect("an address") + } + + /// Whatever the machine has, the two that are not interfaces must be /// there -- loopback for tests and curl, the alias for an emulator -- /// and nothing may appear twice, since these become certificate SANs. #[test] fn the_certificate_always_covers_loopback_and_the_emulator_alias() { - let addresses = local_addresses(); - assert!(addresses.contains(&IpAddr::from([127, 0, 0, 1]))); - assert!(addresses.contains(&IpAddr::from(EMULATOR_HOST_ALIAS))); + let found = [ip("192.168.1.168"), ip("10.66.0.1"), ip("192.168.1.168")]; + let addresses = addresses_among(found); + + assert!(addresses.contains(&ip("127.0.0.1"))); + assert!(addresses.contains(&ip("10.0.2.2"))); + assert!( + addresses.contains(&ip("10.66.0.1")), + "the tunnel's own address" + ); let mut seen = addresses.clone(); seen.sort(); @@ -92,20 +131,50 @@ mod tests { assert!(addresses.iter().all(|ip| ip.is_ipv4())); } - /// The failure is the thing a person reads at 2am, so it has to name + /// A machine with nothing at all still gets a usable certificate, + /// because failing to enumerate must not leave the server unable to + /// answer on loopback and diagnose itself. + #[test] + fn a_machine_with_no_interfaces_still_gets_a_usable_certificate() { + assert_eq!( + addresses_among(std::iter::empty()), + [ip("127.0.0.1"), ip("10.0.2.2")] + ); + } + + #[test] + fn the_tunnel_is_found_by_name_and_only_over_ipv4() { + let found = wg_address_among( + "demo-server", + [ + ("lo", ip("127.0.0.1")), + ("eth0", ip("192.168.1.5")), + (WG_INTERFACE, ip("10.66.0.1")), + ], + ); + assert_eq!(found.expect("the tunnel"), ip("10.66.0.1")); + + // An interface of the right name carrying only IPv6 is not an + // answer: both listeners bind an IPv4 socket. + let v6_only = wg_address_among("demo-server", [(WG_INTERFACE, ip("fd00::1"))]); + assert!(v6_only.is_err(), "IPv6 on wg0 is not the address to bind"); + } + + /// The failure is the thing somebody reads at 2am, so it has to name /// the binary, the interface, and the way out. + /// + /// Reached by handing in an empty interface list rather than by hoping + /// the machine has no tunnel. Every machine this runs on has one up, + /// so a test that called the real lookup and skipped on success would + /// pass everywhere and assert nothing -- which is exactly what it did + /// before this was rewritten. #[test] fn a_missing_tunnel_explains_itself() { - // Only meaningful where there is no wg0; where there is one, the - // call succeeds and there is no message to check. - if wg_address("demo-server").is_ok() { - return; - } - let err = wg_address("demo-server") + let err = wg_address_among("demo-server", []) .expect_err("no tunnel") .to_string(); - assert!(err.contains("demo-server"), "{err}"); - assert!(err.contains(WG_INTERFACE), "{err}"); - assert!(err.contains("--bind"), "{err}"); + assert!(err.contains("demo-server"), "names the binary: {err}"); + assert!(err.contains(WG_INTERFACE), "names the interface: {err}"); + assert!(err.contains("--bind"), "names the way out: {err}"); } }