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:
commit
f95bc77f7b
17 files changed
+2552
No files matched your search
Generated
+837
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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?"));
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user