wg-app-link: the WireGuard-and-pinned-TLS half both apps needed

The Rust crate and the Android half of one arrangement: a server that binds
the tunnel interface and nothing else, certificates it generates and keeps
outside any shared checkout, enrolment that carries a token and the CA to a
phone, and a client that trusts exactly that certificate and no other.

Extracted because ai-app and dev-updater had written all of it twice and the
two copies had already drifted -- one of them carried a bug the other did
not. History before this point was squashed away; it was a running record of
that extraction and of a personal machine's addresses, and neither is worth
keeping in a public repository.
This commit is contained in:
iris committed 2026-08-31 20:27:27 -04:00
commit f95bc77f7b
17 files changed
+2552

No files matched your search

+837
View File
@@ -0,0 +1,837 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom",
"num-traits",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cc"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
"nom",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
]
[[package]]
name = "hybrid-array"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
[[package]]
name = "if-addrs"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rcgen"
version = "0.14.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"x509-parser",
"yasna",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "ron"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
dependencies = [
"bitflags",
"once_cell",
"serde",
"serde_derive",
"typeid",
"unicode-ident",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wg-app-link"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.23.1",
"if-addrs",
"qrcode",
"rand",
"rcgen",
"ron",
"serde",
"sha2",
"subtle",
"tempfile",
"tracing",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom",
"oid-registry",
"ring",
"rusticata-macros",
"thiserror",
"time",
]
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec",
"time",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "wg-app-link"
version = "0.1.0"
edition = "2024"
description = "The private link between a phone and a machine you run: WireGuard binding, a self-signed CA the app pins, and QR enrollment of a bearer token."
[dependencies]
anyhow = "1"
# The config format both projects use, and the two house rules they share.
ron = "0.12.2"
serde = { version = "1", features = ["derive"] }
# Enumerating this machine's addresses, and finding the tunnel's.
if-addrs = "0.15"
# Token auth: hash for storage, constant-time compare for verification,
# CSPRNG-backed generation, base64url for the enrollment string.
sha2 = "0.11"
subtle = "2"
rand = "0.10"
base64 = "0.23"
# Renders the enrollment QR straight to the terminal; no image output.
qrcode = { version = "0.14", default-features = false }
tracing = "0.1"
# Generates the CA and leaf in process, so one place decides the
# extensions, the file modes, and which addresses the leaf covers.
# x509-parser so the issuer is read back from the CA actually on disk
# rather than reconstructed from parameters that may since have changed.
rcgen = { version = "0.14", features = ["pem", "x509-parser"] }
[dev-dependencies]
tempfile = "3"
+239
View File
@@ -0,0 +1,239 @@
//! The TLS certificates a server presents, generated in process on first
//! start, from a CA the app pins.
//!
//! Shared because it existed twice and differed by an organisation name.
//! It is also the piece where being written twice is worst: a trust
//! anchor built two ways can be built differently two ways, and the
//! difference would surface as an opaque handshake failure on a phone.
//!
//! `product` names the certificate's organisation and common name, and is
//! the whole of what is per-project.
//!
//! There used to be a `gen-dev-cert.sh` calling openssl, which meant a
//! setup step to remember, a second place for the "which SANs?" answer to
//! live, and a dependency on whatever openssl was installed. Doing it here
//! means the server can simply ensure its own certificates exist, with the
//! file modes and extensions it wants, and with the address it is actually
//! about to bind already in the leaf.
//!
//! The split that matters is between the two:
//!
//! - The **CA** is generated once and then left alone. The updater app
//! pins it, so replacing it strands every installed copy -- recovery is
//! a reinstall over the plain-HTTP bootstrap port. It is the one thing
//! here that is a one-way door.
//! - The **leaf** is cheap and reissued on every start, signed by that
//! same unchanged CA. Nothing pins it, so covering a new address is just
//! a restart rather than anything the phone has to be told about.
//!
//! Everything is written owner-only into a directory outside the repo (see
//! `config_home`): a CA private key readable by another machine is one it
//! can sign with, and a certificate signed by a pinned CA is accepted
//! without question.
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
/// Where the leaf lives, for handing to the TLS listener.
pub struct Certificates {
pub leaf_cert: PathBuf,
pub leaf_key: PathBuf,
/// True when the CA was created just now, i.e. anything already
/// installed pins the wrong one and has to be reinstalled.
pub ca_is_new: bool,
}
/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what
/// is missing. Safe to call on every start.
pub fn ensure(product: &str, dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
crate::private::create_dir(dir)?;
let ca_cert_path = dir.join("ca.pem");
let ca_key_path = dir.join("ca-key.pem");
let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file();
let (ca_pem, ca_key_pem) = if ca_is_new {
let (pem, key) = generate_ca(product)?;
crate::private::write_file(&ca_key_path, key.as_bytes())?;
crate::private::write_file(&ca_cert_path, pem.as_bytes())?;
tracing::info!("generated a new CA in {}", dir.display());
(pem, key)
} else {
(
std::fs::read_to_string(&ca_cert_path)
.with_context(|| format!("read {}", ca_cert_path.display()))?,
std::fs::read_to_string(&ca_key_path)
.with_context(|| format!("read {}", ca_key_path.display()))?,
)
};
let (leaf_pem, leaf_key_pem) = generate_leaf(product, &ca_pem, &ca_key_pem, addresses)?;
let leaf_cert = dir.join("leaf.pem");
let leaf_key = dir.join("leaf-key.pem");
crate::private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
crate::private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
Ok(Certificates {
leaf_cert,
leaf_key,
ca_is_new,
})
}
fn generate_ca(product: &str) -> Result<(String, String)> {
let key = KeyPair::generate().context("generate CA key")?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::OrganizationName, format!("{product} dev"));
params
.distinguished_name
.push(DnType::CommonName, format!("{product} dev CA"));
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
// Explicit, because strict verifiers reject a CA without them -- and
// that rejection surfaces as an opaque handshake failure on a phone.
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let certificate = params.self_signed(&key).context("self-sign CA")?;
Ok((certificate.pem(), key.serialize_pem()))
}
fn generate_leaf(
product: &str,
ca_pem: &str,
ca_key_pem: &str,
addresses: &[IpAddr],
) -> Result<(String, String)> {
let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?;
let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?;
let key = KeyPair::generate().context("generate leaf key")?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::OrganizationName, format!("{product} dev"));
params.distinguished_name.push(
DnType::CommonName,
addresses
.first()
.map(|a| a.to_string())
.unwrap_or_else(|| product.to_string()),
);
params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect();
params.is_ca = IsCa::ExplicitNoCa;
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.use_authority_key_identifier_extension = true;
let certificate = params.signed_by(&key, &issuer).context("sign leaf")?;
Ok((certificate.pem(), key.serialize_pem()))
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt;
use super::*;
fn decode_pem(pem: &str) -> Vec<u8> {
use base64::Engine;
let body: String = pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect();
base64::engine::general_purpose::STANDARD
.decode(body)
.expect("base64")
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn addresses() -> Vec<IpAddr> {
vec![
"192.168.1.5".parse().unwrap(),
"127.0.0.1".parse().unwrap(),
]
}
#[test]
fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() {
let dir = tempfile::tempdir().expect("tempdir");
let first = ensure("demo", dir.path(), &addresses()).expect("generate");
assert!(first.ca_is_new);
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf");
assert!(ca.starts_with("-----BEGIN CERTIFICATE-----"));
let second = ensure("demo", dir.path(), &addresses()).expect("regenerate");
// The CA is the pinned one: replacing it strands every installed
// app, so it must survive a restart untouched.
assert!(!second.ca_is_new);
assert_eq!(
ca,
std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca")
);
// The leaf is not pinned, and is reissued so a new address is a
// restart away rather than a reinstall.
assert_ne!(
leaf,
std::fs::read_to_string(&second.leaf_cert).expect("leaf")
);
}
/// Private keys are rewritten on every start, which is the case that
/// makes the mode matter: `private` narrows a file that already
/// exists, and this is the caller that depends on it.
#[test]
fn everything_is_owner_only_even_on_the_second_start() {
let dir = tempfile::tempdir().expect("tempdir");
ensure("demo", dir.path(), &addresses()).expect("generate");
// What an older version could have left behind.
for file in ["leaf.pem", "leaf-key.pem"] {
std::fs::set_permissions(
dir.path().join(file),
std::fs::Permissions::from_mode(0o644),
)
.expect("chmod");
}
ensure("demo", dir.path(), &addresses()).expect("reissue");
let mode = |path: std::path::PathBuf| {
std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777
};
assert_eq!(mode(dir.path().to_path_buf()), 0o700);
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
assert_eq!(
mode(dir.path().join(file)),
0o600,
"{file} is not owner-only"
);
}
}
/// The product name is the whole of what is per-project, so it has to
/// reach the certificate rather than being decoration.
#[test]
fn the_product_names_the_certificate() {
let dir = tempfile::tempdir().expect("tempdir");
ensure("dev-updater", dir.path(), &addresses()).expect("generate");
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
// Decoded rather than searched as base64: a distinguished name is
// stored in the DER as literal bytes, so this checks the value
// that actually reaches a phone rather than the value we passed in.
let der = decode_pem(&ca);
assert!(
contains(&der, b"dev-updater dev CA"),
"the CA's common name"
);
assert!(contains(&der, b"dev-updater dev"), "the organisation");
}
}
+146
View File
@@ -0,0 +1,146 @@
//! The bearer token a phone carries, and the QR code that gets it there.
//!
//! Pinning authenticates the server to the phone but never the phone to
//! the server, so the token supplies the other direction. Binding the
//! WireGuard interface (see [`crate::netif`]) narrows who can try at all;
//! this narrows it to who was enrolled.
//!
//! The token is 256 bits from the OS CSPRNG and is never typed by a
//! human -- it travels once, in a QR code printed to the terminal -- so
//! being unguessable costs nothing and there is no manual-entry path to
//! design around.
//!
//! Only the hash is ever stored. That is what makes the plaintext a
//! once-only artifact: it exists in the QR at generation time and nowhere
//! afterwards, and a lost phone is answered by rotating rather than by
//! looking the old one up.
//!
//! # Never log the token
//!
//! Nothing here, and nothing that calls it, may log the Authorization
//! header or the token itself. Both existing projects hold a test that
//! drives the rejection path under a capturing subscriber and asserts the
//! token does not appear in the output; that tripwire belongs with the
//! middleware, which stays in each project because it is generic over
//! that project's state.
use std::net::IpAddr;
use anyhow::{Context, Result};
use base64::Engine;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
/// 256 bits from the OS CSPRNG, base64url.
pub fn generate_token() -> String {
use rand::Rng;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
/// What the config stores instead of the token: hex SHA-256.
///
/// A plain hash, not a password KDF, and deliberately: the input is 256
/// random bits, so there is nothing to dictionary-attack and stretching
/// would buy only latency on every request.
pub fn token_hash_hex(token: &str) -> String {
Sha256::digest(token.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
/// Whether `presented` matches any enrolled hash.
///
/// The fold visits every entry regardless of an earlier match, so the
/// time taken does not say which entry matched, or whether the first one
/// did.
pub fn token_matches(presented: &str, stored_hashes: &[String]) -> bool {
let presented = token_hash_hex(presented);
stored_hashes.iter().fold(false, |matched, stored| {
matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes()))
})
}
/// The `<scheme>://enroll?...` URI a QR code carries.
///
/// The scheme is the caller's because it is what routes the scan back to
/// the right app -- `devupdater`, `aiapp` -- and it is the only part of
/// enrollment that is per-project.
pub fn enrollment_uri(scheme: &str, host: IpAddr, port: u16, token: &str) -> String {
format!("{scheme}://enroll?host={host}&port={port}&token={token}")
}
/// Prints the one-time enrollment QR, and the URI under it for a person
/// who would rather paste than scan.
///
/// Printed to stdout rather than through `tracing`: it is for the human
/// at the terminal, once, and a log line is the wrong shape for something
/// that has to be photographed.
///
/// The QR carries no trust material. The CA is embedded in the app at
/// build time, so photographing the terminal leaks only the token, which
/// is rotatable.
pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> Result<()> {
let uri = enrollment_uri(scheme, host, port, token);
let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?;
let rendered = code
.render::<qrcode::render::unicode::Dense1x2>()
.quiet_zone(true)
.build();
println!("\n{rendered}\n");
println!("Scan with the phone's camera to enroll (or paste into the app's settings):");
println!(" {uri}");
println!("The token is not stored in the clear and won't be shown again;");
println!("a lost phone means re-running with --rotate-token.\n");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hashing_is_stable_and_tokens_verify() {
let token = generate_token();
assert_eq!(token_hash_hex(&token), token_hash_hex(&token));
assert_ne!(token, generate_token(), "tokens must not repeat");
let hashes = vec![token_hash_hex(&token), token_hash_hex("other")];
assert!(token_matches(&token, &hashes));
assert!(token_matches("other", &hashes));
assert!(!token_matches("wrong", &hashes));
assert!(
!token_matches(&token, &[]),
"no enrolled token matches nothing"
);
}
/// The hash is what gets stored, so it must not be the token, and it
/// must be the shape the config files already hold.
#[test]
fn the_stored_form_reveals_nothing_and_is_hex() {
let token = generate_token();
let hash = token_hash_hex(&token);
assert_ne!(hash, token);
assert_eq!(hash.len(), 64);
assert!(
hash.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
/// The scheme is the only per-project part, and the app parses this
/// back -- so the shape is a contract, not a formatting choice.
#[test]
fn the_enrollment_uri_carries_scheme_host_port_and_token() {
let uri = enrollment_uri("devupdater", "10.66.0.1".parse().unwrap(), 8090, "tok");
assert_eq!(
uri,
"devupdater://enroll?host=10.66.0.1&port=8090&token=tok"
);
let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok");
assert!(other.starts_with("aiapp://enroll?"));
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Reading and writing the RON both projects' config files are in.
//!
//! Two things are house rules rather than plain RON, and they are here
//! together because they are inverses of each other -- change one and the
//! other stops round-tripping. Both projects had this, identically, to the
//! byte; that is what made it the first thing worth sharing.
//!
//! **No outer parentheses.** A file *is* the body of the struct, so
//! nothing in it is indented for the sake of a wrapper. RON has no
//! implicit top-level struct (`de/mod.rs` requires the `(`), so [`parse`]
//! adds it and [`render`] takes it back off. The opening paren is not
//! followed by a newline, so a parse error's line number still points at
//! the real line.
//!
//! **`Some` is implicit.** Enabled on the deserializer rather than by a
//! `#![enable(implicit_some)]` header every file would have to remember,
//! and matched on the writing side by `skip_serializing_if` so nothing
//! writes back a `Some(...)` a person didn't type. The two halves only
//! round-trip together, which is why a caller's own tests should assert
//! the written shape rather than only that it loads.
/// Reading and writing the RON these files are in.
///
/// Two things are house rules rather than plain RON, and they are here
/// together because they are inverses of each other -- change one and the
/// other stops round-tripping.
///
/// **No outer parentheses.** A file *is* the body of the struct, so nothing
/// in it is indented for the sake of a wrapper. RON has no implicit
/// top-level struct (`de/mod.rs` requires the `(`), so [`format::parse`]
/// adds it and [`format::render`] takes it back off. The opening paren is not followed by a
/// newline, so a parse error's line number still points at the real line.
///
/// **`Some` is implicit.** Enabled on the deserializer rather than by a
/// `#![enable(implicit_some)]` header every project file would have to
/// remember, and matched on the writing side by `skip_serializing_if` so
/// nothing writes back a `Some(...)` a person didn't type.
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Serialize, de::DeserializeOwned};
use crate::private;
fn options() -> ron::Options {
ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME)
}
pub fn parse<T: DeserializeOwned>(text: &str) -> Result<T, ron::error::SpannedError> {
options().from_str(&format!("({text})"))
}
pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
let pretty = ron::ser::PrettyConfig::new();
let text = options().to_string_pretty(value, pretty)?;
Ok(unwrap_outer(&text))
}
/// Renders `value` and replaces `path` with it, atomically and owner-only.
///
/// Whole-file-and-rename rather than an in-place edit, because both
/// projects' config files are small, are read at startup, and hold the
/// enrolled token hashes -- a half-written one would take the server down
/// on its next start with no way to fix it from a phone. The rename is
/// what makes a reader see either the old file or the new one and never
/// part of both.
///
/// The temp file goes through [`private::write_file`] rather than
/// `std::fs::write`, and that is the subtle half: **the temp file is not
/// always new.** A save killed partway leaves one behind, and opening that
/// again keeps whatever mode it already had -- which is then renamed over
/// the file holding the token hashes. Setting the mode as it is opened
/// covers both the fresh and the leftover case.
pub fn write<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
private::create_dir(parent)?;
}
let text = render(value).context("serialize config")?;
// Appended rather than substituted, so `config.ron` yields
// `config.ron.tmp` and not `config.tmp` -- a name that cannot collide
// with a real file and that says what it is a temporary copy of.
let tmp = path.with_file_name(format!(
"{}.tmp",
path.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("config"))
.to_string_lossy()
));
private::write_file(&tmp, text.as_bytes())?;
std::fs::rename(&tmp, path)
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()))
}
/// Strips the outer `(`/`)` the writer always emits and removes the
/// indent level they cost. Deliberately narrow: it accepts only the
/// exact shape `PrettyConfig` produces, and leaves anything else alone
/// rather than guessing -- a file with stray parentheses is better than
/// one silently mangled. `parse` round-trips either way, since a
/// wrapped body parses the same as an unwrapped one re-wrapped.
fn unwrap_outer(text: &str) -> String {
let Some(body) = text
.strip_prefix("(\n")
.and_then(|rest| rest.strip_suffix("\n)"))
else {
return text.to_string();
};
let mut out: String = body
.lines()
.map(|line| line.strip_prefix(" ").unwrap_or(line))
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use serde::Deserialize;
use super::*;
#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct Demo {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
note: Option<String>,
}
/// The house rule both halves depend on: what is written is the *body*
/// of the struct, with no outer parentheses and nothing indented for
/// them. Asserted rather than trusted, because `render` strips what
/// `parse` adds -- if only one ever changed, every file on disk would
/// still load and only look wrong.
#[test]
fn a_file_is_the_body_of_the_struct() {
let written = render(&Demo {
name: "thing".to_string(),
note: Some("why".to_string()),
})
.expect("render");
assert!(
!written.trim_start().starts_with('('),
"outer parens: {written}"
);
assert!(
written.starts_with("name: "),
"top level sits at column 0: {written}"
);
assert_eq!(
parse::<Demo>(&written).expect("re-read").name,
"thing",
"what is written must read back",
);
}
/// An optional value is written as itself, never wrapped -- and a
/// value nobody set is not written at all, so a file stays readable as
/// what was actually chosen.
#[test]
fn an_optional_value_is_written_as_itself_or_not_at_all() {
let with = render(&Demo {
name: "a".to_string(),
note: Some("b".to_string()),
})
.expect("render");
assert!(
with.contains(r#"note: "b""#),
"no Some(...) wrapper: {with}"
);
let without = render(&Demo::default()).expect("render");
assert!(
!without.contains("note"),
"an unset value writes nothing: {without}"
);
// And the bare form reads back, which is the other half.
assert_eq!(
parse::<Demo>("name: \"a\",\nnote: \"b\",\n")
.expect("parse")
.note
.as_deref(),
Some("b")
);
}
#[test]
fn what_is_written_reads_back_and_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("nested").join("config.ron");
let value = Demo {
name: "thing".to_string(),
note: None,
};
write(&path, &value).expect("write");
assert_eq!(
parse::<Demo>(&std::fs::read_to_string(&path).expect("read")).expect("re-read"),
value,
);
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
assert_eq!(mode & 0o777, 0o600, "config holds token hashes: {mode:o}");
assert!(
!path.with_extension("ron.tmp").exists(),
"the temp file is renamed away, not left behind",
);
}
/// The case the whole thing turns on, and the one that cannot happen on
/// a machine where nothing has ever crashed mid-save: a leftover temp
/// file from an interrupted write is reopened, and if its mode came
/// along it would be renamed straight over the file holding the enrolled
/// token hashes.
#[test]
fn a_leftover_temp_file_cannot_widen_the_config() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.ron");
let tmp = dir.path().join("config.ron.tmp");
std::fs::write(&tmp, b"leftover from a save that died").expect("stale temp");
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).expect("widen");
write(&path, &Demo::default()).expect("write");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"a world-readable leftover must not become the config: {mode:o}",
);
}
/// A parse error's line number has to point at the real line, which is
/// why the opening paren is not followed by a newline.
#[test]
fn a_parse_error_points_at_the_line_it_is_on() {
let err = parse::<Demo>("name: \"a\",\nnote: ,\n").expect_err("malformed");
assert_eq!(err.span.start.line, 2, "{err}");
}
}
+35
View File
@@ -0,0 +1,35 @@
//! What dev-updater and ai-app both need in order to be reached from a
//! phone, and nothing either of them does afterwards.
//!
//! Both projects are the same shape underneath: a server on a machine
//! somebody owns, bound to a WireGuard interface so it is not on the LAN,
//! presenting a certificate from a CA the app pins, and answering only
//! requests carrying a bearer token that was enrolled by scanning a QR
//! code off the terminal. None of that is about serving APKs or running
//! model sessions -- it is the link, and it was written twice.
//!
//! # What belongs here
//!
//! Anything that would be *identical* in a third such project. The test
//! applied to each module below was to diff the two existing copies: if
//! the only differences were a product name and which state type the code
//! was generic over, it came here.
//!
//! # What deliberately does not
//!
//! The API surfaces. dev-updater's routes are about projects and builds,
//! ai-app's about sessions and providers, and their HTTP clients have
//! diverged to 14% similarity because they are genuinely different
//! programs. Sharing a transport is worth doing; sharing an API would mean
//! inventing a common vocabulary neither project wants.
//!
//! Config *schemas*, for the same reason -- though the RON house rules
//! that both files are written in are shared, since those were identical
//! to the byte.
pub mod certs;
pub mod enroll;
pub mod format;
pub mod netif;
pub mod private;
pub mod xdg;
+180
View File
@@ -0,0 +1,180 @@
//! Which address to bind, and which addresses the certificate must cover.
//!
//! Both projects bind the WireGuard interface and nothing else, so that
//! neither is reachable from the LAN. That is the outer of two gates --
//! the tunnel decides who can try, the token (see [`crate::enroll`])
//! decides who is answered -- and it is worth having on its own account:
//! an unenrolled scanner never reaches the token check, and a plain-HTTP
//! bootstrap port travels inside the tunnel's encryption.
use std::net::IpAddr;
use anyhow::{Context, Result};
/// The interface both projects bind. A constant rather than a parameter
/// because a second answer would mean two ideas of what "the tunnel" is.
pub const WG_INTERFACE: &str = "wg0";
/// The alias an Android emulator reaches its host by. Not a real
/// interface anywhere, which is why it has to be added by hand.
const EMULATOR_HOST_ALIAS: [u8; 4] = [10, 0, 2, 2];
/// Every address this machine answers on, for the leaf certificate's SANs
/// -- so it covers whatever the phone actually dials without anyone
/// maintaining a hardcoded IP.
///
/// Loopback is included for curl and tests, and the emulator's host alias
/// so a debug build can reach a server running beside it.
///
/// Failing to enumerate is not fatal: the certificate still covers
/// loopback, which is enough to start and to diagnose from the machine
/// itself.
pub fn local_addresses() -> Vec<IpAddr> {
match if_addrs::get_if_addrs() {
Ok(interfaces) => addresses_among(interfaces.iter().map(|iface| iface.ip())),
Err(err) => {
tracing::warn!("couldn't enumerate interfaces for the certificate: {err}");
addresses_among(std::iter::empty())
}
}
}
/// The SAN list built from `found`, which is the part worth testing.
///
/// Split from the lookup so the outcome does not depend on what this
/// machine happens to have; see [`wg_address_among`] for the same
/// reasoning stated at length.
pub fn addresses_among(found: impl IntoIterator<Item = IpAddr>) -> Vec<IpAddr> {
let mut addresses = vec![
IpAddr::from([127, 0, 0, 1]),
IpAddr::from(EMULATOR_HOST_ALIAS),
];
for ip in found {
if ip.is_ipv4() && !addresses.contains(&ip) {
addresses.push(ip);
}
}
addresses
}
/// The IPv4 address on the WireGuard interface, or a refusal to start.
///
/// Failing closed rather than falling back to 0.0.0.0 is the point. The
/// escape hatch belongs to the caller as an explicit `--bind`, because
/// each of these servers is also how something stranded gets recovered,
/// and that recovery should not depend on the tunnel being healthy.
///
/// `product` names the binary in the failure, so the message reads as
/// advice rather than as a library complaining.
pub fn wg_address(product: &str) -> Result<IpAddr> {
let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?;
wg_address_among(
product,
interfaces
.iter()
.map(|iface| (iface.name.as_str(), iface.ip())),
)
}
/// The same answer, from interfaces the caller supplies.
///
/// This exists because the failure is the interesting half -- it is the
/// message somebody reads when nothing works -- and it cannot be reached
/// on any machine this runs on, all of which have the tunnel up. A test
/// that calls [`wg_address`] and skips when it succeeds passes everywhere
/// and checks nothing, which is the shape of test this project has been
/// bitten by more than once.
pub fn wg_address_among<'a>(
product: &str,
interfaces: impl IntoIterator<Item = (&'a str, IpAddr)>,
) -> Result<IpAddr> {
interfaces
.into_iter()
.find(|(name, ip)| *name == WG_INTERFACE && ip.is_ipv4())
.map(|(_, ip)| ip)
.ok_or_else(|| {
anyhow::anyhow!(
"no IPv4 address on interface {WG_INTERFACE} -- {product} binds only to the \
WireGuard tunnel, so that only enrolled peers can reach its API. Bring the \
tunnel up, or pass --bind 0.0.0.0 to serve the LAN while recovering."
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().expect("an address")
}
/// Whatever the machine has, the two that are not interfaces must be
/// there -- loopback for tests and curl, the alias for an emulator --
/// and nothing may appear twice, since these become certificate SANs.
#[test]
fn the_certificate_always_covers_loopback_and_the_emulator_alias() {
let found = [ip("192.168.1.5"), ip("10.66.0.1"), ip("192.168.1.5")];
let addresses = addresses_among(found);
assert!(addresses.contains(&ip("127.0.0.1")));
assert!(addresses.contains(&ip("10.0.2.2")));
assert!(
addresses.contains(&ip("10.66.0.1")),
"the tunnel's own address"
);
let mut seen = addresses.clone();
seen.sort();
seen.dedup();
assert_eq!(seen.len(), addresses.len(), "duplicate SANs: {addresses:?}");
assert!(addresses.iter().all(|ip| ip.is_ipv4()));
}
/// A machine with nothing at all still gets a usable certificate,
/// because failing to enumerate must not leave the server unable to
/// answer on loopback and diagnose itself.
#[test]
fn a_machine_with_no_interfaces_still_gets_a_usable_certificate() {
assert_eq!(
addresses_among(std::iter::empty()),
[ip("127.0.0.1"), ip("10.0.2.2")]
);
}
#[test]
fn the_tunnel_is_found_by_name_and_only_over_ipv4() {
let found = wg_address_among(
"demo-server",
[
("lo", ip("127.0.0.1")),
("eth0", ip("192.168.1.5")),
(WG_INTERFACE, ip("10.66.0.1")),
],
);
assert_eq!(found.expect("the tunnel"), ip("10.66.0.1"));
// An interface of the right name carrying only IPv6 is not an
// answer: both listeners bind an IPv4 socket.
let v6_only = wg_address_among("demo-server", [(WG_INTERFACE, ip("fd00::1"))]);
assert!(v6_only.is_err(), "IPv6 on wg0 is not the address to bind");
}
/// The failure is the thing somebody reads at 2am, so it has to name
/// the binary, the interface, and the way out.
///
/// Reached by handing in an empty interface list rather than by hoping
/// the machine has no tunnel. Every machine this runs on has one up,
/// so a test that called the real lookup and skipped on success would
/// pass everywhere and assert nothing -- which is exactly what it did
/// before this was rewritten.
#[test]
fn a_missing_tunnel_explains_itself() {
let err = wg_address_among("demo-server", [])
.expect_err("no tunnel")
.to_string();
assert!(err.contains("demo-server"), "names the binary: {err}");
assert!(err.contains(WG_INTERFACE), "names the interface: {err}");
assert!(err.contains("--bind"), "names the way out: {err}");
}
}
+178
View File
@@ -0,0 +1,178 @@
//! Creating files and directories this server alone can read.
//!
//! Everything a server writes outside its repo goes through here: the
//! config holding token hashes, the TLS private keys, and whatever state
//! it keeps. One module owns the modes, so "owner-only" is a property
//! that can be checked in one place rather than re-argued at every
//! `create`.
//!
//! Taken from ai-app, which had factored this out; dev-updater still has
//! the same logic inline in two places, which is the duplication this
//! crate exists to end.
use std::fs::File;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::Path;
use anyhow::{Context, Result};
/// Creates `dir` and its parents, owner-accessible only.
///
/// The mode is set again after creation, deliberately: `DirBuilder::mode`
/// applies only when the directory is actually created, so one that
/// already existed -- made by hand, or by an older version -- would
/// otherwise keep whatever permissions it had while holding a private key.
pub fn create_dir(dir: &Path) -> Result<()> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.with_context(|| format!("create {}", dir.display()))?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("restrict {}", dir.display()))
}
/// Writes `contents` to `path`, owner-readable only.
///
/// The mode is set as the file is opened rather than chmod-ed afterwards,
/// so it is never briefly world-readable at its real path.
pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
use std::io::Write;
let mut file = create_file(path)?;
file.write_all(contents)
.with_context(|| format!("write {}", path.display()))
}
/// Opens `path` for writing, owner-readable only, truncating what is
/// there. For a caller that streams rather than holding the whole body.
pub fn create_file(path: &Path) -> Result<File> {
restrict(
path,
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("write {}", path.display()))?,
)
}
/// Opens `path` for appending, owner-readable only, creating it if needed.
///
/// The append case is separate because a transcript must never be
/// truncated by being opened, and the two differ by one flag that is easy
/// to get wrong in a hurry.
pub fn append_file(path: &Path) -> Result<File> {
restrict(
path,
std::fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(path)
.with_context(|| format!("append to {}", path.display()))?,
)
}
/// Narrows an already-open file to owner-only.
///
/// `OpenOptions::mode` applies **only when the file is created**, so
/// opening one that already exists silently keeps whatever mode it had --
/// which for an append helper is the normal case rather than the odd one,
/// and for a truncating one happens on every rewrite after the first.
/// Setting it through the handle rather than the path closes the window
/// where something could swap the path between the two.
///
/// The same reasoning as [`create_dir`]'s second call, and it was missed
/// here first: a file made wrong by an older version, or by hand, would
/// otherwise stay wrong for as long as it is only ever appended to.
fn restrict(path: &Path, file: File) -> Result<File> {
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("restrict {}", path.display()))?;
Ok(file)
}
#[cfg(test)]
mod tests {
use super::*;
fn mode_of(path: &Path) -> u32 {
std::fs::metadata(path).expect("stat").permissions().mode() & 0o777
}
#[test]
fn a_directory_is_owner_only_even_if_it_already_existed() {
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("state");
// Made by hand, wide open -- what an older version or a person
// might leave behind.
std::fs::create_dir(&target).expect("mkdir");
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).expect("chmod");
create_dir(&target).expect("create_dir");
assert_eq!(
mode_of(&target),
0o700,
"an existing directory must be restricted too"
);
}
/// The case `OpenOptions::mode` cannot cover, because it applies only
/// at creation: a file that already exists, made wrong earlier, and
/// opened again. An append-only transcript hits this on every write
/// after the first.
#[test]
fn an_existing_file_made_wrong_is_narrowed_on_open() {
let dir = tempfile::tempdir().expect("tempdir");
create_dir(dir.path()).expect("create_dir");
for (name, open) in [
("appended", append_file as fn(&Path) -> Result<File>),
("rewritten", create_file as fn(&Path) -> Result<File>),
] {
let path = dir.path().join(name);
std::fs::write(&path, b"made by an older version").expect("write");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
assert_eq!(mode_of(&path), 0o644, "precondition");
open(&path).expect("open");
assert_eq!(
mode_of(&path),
0o600,
"{name} kept a mode somebody else could read"
);
}
}
#[test]
fn files_are_owner_only_from_the_moment_they_exist() {
let dir = tempfile::tempdir().expect("tempdir");
create_dir(dir.path()).expect("create_dir");
let written = dir.path().join("key.pem");
write_file(&written, b"secret").expect("write");
assert_eq!(mode_of(&written), 0o600);
assert_eq!(std::fs::read(&written).expect("read"), b"secret");
let appended = dir.path().join("transcript.jsonl");
{
use std::io::Write;
let mut file = append_file(&appended).expect("append");
file.write_all(b"one\n").expect("write");
}
{
use std::io::Write;
let mut file = append_file(&appended).expect("append");
file.write_all(b"two\n").expect("write");
}
assert_eq!(mode_of(&appended), 0o600);
// The whole point of the separate opener: opening again must not
// have truncated what was there.
assert_eq!(
std::fs::read_to_string(&appended).expect("read"),
"one\ntwo\n"
);
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Where each project keeps the state that must not live in its repo.
//!
//! Both servers hold the same three things outside their checkout -- the
//! config with its token hashes, the CA's private key, and whatever state
//! the product itself keeps -- and both resolve the location the same way.
//! That is not a coincidence of style: it is forced by the arrangement the
//! two projects share.
//!
//! The repo is a virtiofs mount shared between a machine and a VM at
//! *different* absolute paths, so a config inside it would record paths
//! that resolve on only one side -- which is exactly how dev-updater came
//! to show "not built" for every project on one of them. And the mount is
//! writable by the untrusted side, so a CA private key inside it would let
//! that side mint a leaf the pinned app trusts, which voids the pinning
//! the rest of this crate exists to provide.
//!
//! Per machine, therefore, and under the XDG directories rather than a
//! path of our own choosing, so that a person's existing backup and
//! sync rules already cover it.
use std::ffi::OsString;
use std::path::PathBuf;
/// `$XDG_CONFIG_HOME/<product>`, or `~/.config/<product>`.
///
/// Holds `config.ron` and `certs/`. `product` is a parameter rather than
/// a constant because the whole point is that two products keep their
/// state apart while resolving it identically.
pub fn config_home(product: &str) -> PathBuf {
xdg_dir(std::env::var_os("XDG_CONFIG_HOME"), ".config", product)
}
/// `$XDG_DATA_HOME/<product>`, or `~/.local/share/<product>`.
///
/// Holds whatever the product accumulates rather than is configured with
/// -- ai-app's session transcripts and attachments, dev-updater's builds.
pub fn data_home(product: &str) -> PathBuf {
xdg_dir(std::env::var_os("XDG_DATA_HOME"), ".local/share", product)
}
/// The resolution both of the above use, with the environment handed in.
///
/// Split out so the rules can be tested without setting process-wide
/// environment variables, which two tests running in parallel cannot do
/// without racing each other.
///
/// A relative value is ignored rather than resolved, per the XDG spec: a
/// server started from a different working directory would otherwise look
/// for its token hashes somewhere new and generate a fresh enrollment,
/// silently locking out the phone that was already enrolled.
fn xdg_dir(base: Option<OsString>, fallback: &str, product: &str) -> PathBuf {
base.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| {
std::env::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(fallback)
})
.join(product)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_absolute_setting_is_used_and_namespaced_by_product() {
assert_eq!(
xdg_dir(Some("/somewhere".into()), ".config", "ai-app"),
PathBuf::from("/somewhere/ai-app"),
);
assert_eq!(
xdg_dir(Some("/somewhere".into()), ".config", "dev-updater"),
PathBuf::from("/somewhere/dev-updater"),
);
}
#[test]
fn an_unset_value_falls_back_under_home() {
let dir = xdg_dir(None, ".local/share", "ai-app");
assert!(dir.ends_with("ai-app"));
assert!(dir.parent().expect("parent").ends_with("share"));
}
/// A relative setting lands on the same path as no setting at all,
/// rather than on something that moves with the working directory.
#[test]
fn a_relative_setting_is_ignored_rather_than_resolved() {
assert_eq!(
xdg_dir(Some("relative/path".into()), ".config", "ai-app"),
xdg_dir(None, ".config", "ai-app"),
);
}
}