From c879a8101bc086b8c85232fbbe730e2de913a212 Mon Sep 17 00:00:00 2001 From: sasa Date: Mon, 19 Oct 2026 23:11:29 +0300 Subject: [PATCH] Handle client keys, encrypted vault --- AGENTS.md | 2 +- Cargo.lock | 1414 ++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 9 +- PLANS.md | 419 +++++++++++- README.md | 28 +- docs/VAULT_FORMAT.md | 133 ++++ src/lib.rs | 1 + src/vault.rs | 723 +++++++++++++++++++++ src/vault/import.rs | 815 ++++++++++++++++++++++++ src/vault/source.rs | 511 +++++++++++++++ tests/vault_import.rs | 233 +++++++ 11 files changed, 4267 insertions(+), 21 deletions(-) create mode 100644 docs/VAULT_FORMAT.md create mode 100644 src/vault.rs create mode 100644 src/vault/import.rs create mode 100644 src/vault/source.rs create mode 100644 tests/vault_import.rs diff --git a/AGENTS.md b/AGENTS.md index eeac4d4..f6a5a9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ These instructions apply to the entire repository. Keep documentation, code comments, and identifiers in English; use the user's language in conversation. -The project contains a Rust package with startup, settings validation, a pure client domain model, and an in-memory, byte-preserving WireGuard configuration editor. The management interface, persistence, and VPN operations are not implemented yet. Do not report planned modules, tests, or commands as already working. +The project contains a Rust package with startup, settings validation, a pure client domain model, an in-memory, byte-preserving WireGuard configuration editor, and library APIs for an encrypted vault and read-only client import. The management interface, installed persistence, and VPN operations are not implemented yet. Do not report planned modules, tests, or commands as already working. - Read [SPEC.md](docs/SPEC.md) and [CONTEXT.md](CONTEXT.md) before implementing behavior. The approved specification is the product contract; do not change it to make tests pass. - For milestone execution, read and maintain [PLANS.md](PLANS.md), following [IMPLEMENTATION.md](docs/IMPLEMENTATION.md). Execute numbered milestones sequentially through implementation, independent review, repair, and acceptance with evidence. diff --git a/Cargo.lock b/Cargo.lock index 6aaa344..2629222 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,361 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "age" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" +dependencies = [ + "age-core", + "base64", + "bech32", + "chacha20poly1305", + "cipher", + "cookie-factory", + "hkdf", + "hmac", + "hpke", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "ml-kem", + "nom", + "p256", + "pin-project", + "rand", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "sha3", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d4375964d1501e5f1b32aef2ead573913893ff238448d4e9fdf1522d828656" +dependencies = [ + "base64", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "hpke", + "io_tee", + "nom", + "rand", + "secrecy", + "sha2 0.10.9", +] + +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array 0.4.15", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array 0.4.15", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[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.9", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -30,12 +373,353 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.20", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hpke" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4917627a14198c3603282c5158b815ad5534795451d3c074b53cf3cee0960b11" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array", + "hkdf", + "hmac", + "p256", + "rand_core", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a217bbb075dcaefb292efa78897fc0678245ca67f265d12c351e42268fcb0305" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602e6bd30c3db2749e13e38b363a3d98d9d41de1d8de7a79c31bb69e45b47cda" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error3", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -46,12 +730,71 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "kem" +version = "0.3.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" +dependencies = [ + "rand_core", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.190" @@ -64,6 +807,208 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "ml-kem" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" +dependencies = [ + "hybrid-array 0.2.3", + "kem", + "rand_core", + "sha3", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -82,6 +1027,101 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rust-embed" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" +dependencies = [ + "sha2 0.11.1", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.5" @@ -95,6 +1135,81 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -122,7 +1237,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -134,6 +1249,73 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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.9" @@ -145,13 +1327,46 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -162,7 +1377,27 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", ] [[package]] @@ -204,12 +1439,102 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-ident" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -235,10 +1560,89 @@ checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" name = "wireguard-admin" version = "0.1.0" dependencies = [ + "age", "base64", + "getrandom 0.3.4", "ipnet", "rustix", "serde", - "thiserror", - "toml", + "tempfile", + "thiserror 2.0.20", + "toml 1.1.8+spec-1.1.0", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "zerofrom", ] diff --git a/Cargo.toml b/Cargo.toml index d0b3353..237e48a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,16 @@ name = "wireguard-admin" path = "src/main.rs" [dependencies] +age = { version = "=0.12.1", default-features = false } base64 = "=0.22.1" +getrandom = "=0.3.4" ipnet = "=2.11.0" -rustix = { version = "=1.1.5", default-features = false, features = ["process"] } +rustix = { version = "=1.1.5", default-features = false, features = ["fs", "process", "std"] } serde = { version = "=1.0.229", features = ["derive"] } thiserror = "=2.0.20" toml = "=1.1.8" +x25519-dalek = { version = "=2.0.1", default-features = false, features = ["static_secrets", "zeroize"] } +zeroize = "=1.9.0" + +[dev-dependencies] +tempfile = "=3.23.0" diff --git a/PLANS.md b/PLANS.md index be2d68e..f2aec7e 100644 --- a/PLANS.md +++ b/PLANS.md @@ -266,11 +266,11 @@ No Alpine build, container tunnel, or VM/systemd check is claimed by M1 acceptan #### Review and repair -| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State | -| --- | --- | --- | --- | --- | --- | -| M1-R1-01 | R1/R2 | `src/domain.rs` constructor can claim verified without a matching applied snapshot; misleading state | repairer | constructor rejects inconsistent Verified; Commit/mismatch demote stale Verified; 13 focused, 22 total tests passed; R2 confirmed | closed | -| M1-R1-02 | R1/R2 | `AGENTS.md` still claims no Rust application exists | coordinator | opening statement updated to limited scaffold; R2 confirmed | closed | -| M1-R1-03 | R1/R2 | `Cargo.lock` existed outside Git index | coordinator | `git add -N Cargo.lock`; `git ls-files --error-unmatch Cargo.lock` succeeds; R2 confirmed; no commit/content staging | closed | +| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State | +| -------- | --------- | ---------------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | ------ | +| M1-R1-01 | R1/R2 | `src/domain.rs` constructor can claim verified without a matching applied snapshot; misleading state | repairer | constructor rejects inconsistent Verified; Commit/mismatch demote stale Verified; 13 focused, 22 total tests passed; R2 confirmed | closed | +| M1-R1-02 | R1/R2 | `AGENTS.md` still claims no Rust application exists | coordinator | opening statement updated to limited scaffold; R2 confirmed | closed | +| M1-R1-03 | R1/R2 | `Cargo.lock` existed outside Git index | coordinator | `git add -N Cargo.lock`; `git ls-files --error-unmatch Cargo.lock` succeeds; R2 confirmed; no commit/content staging | closed | #### Evidence and handoff @@ -619,9 +619,409 @@ remain. Stop here; milestone 3 is the next action only on a new request. ### Milestone 3 — Client keys, encrypted vault, and existing-client import -Implement separate vault/client key pairs, recipient encryption, password-protected vault identity, and versioned secret storage. Import from the configured directory on initial setup without deleting originals. Verify key correspondence and classify anomalies; conflicts block reissue until resolved. +Status: accepted (2026-10-19T20:08:32Z; independent R7 review and coordinator gates passed). +Prerequisites: milestones 1 and 2 accepted; pinned native Rust environment and +access to compatible approved cryptographic dependencies or a populated cache. +No root, running WireGuard, systemd, Docker, VM, or production input is needed. -Acceptance: new secrets encrypt without password entry, correct/incorrect password and corruption paths are covered, original files are unchanged, duplicates/conflicts are represented correctly, and persisted application data contains no plaintext client private keys. WireGuard key handling uses approved libraries, not utility subprocesses. +#### Outcome and scope + +Deliver library operations for WireGuard client key generation, encrypted vault +initialization, password-free encryption of new records, password-authorized +secret retrieval, and safe reconciliation/import of existing client files. +Reuse M1 identities/settings and M2 server inspection without rewriting originals. + +Read SPEC.md Data sources, Encrypted vault, Importing existing clients, Connection +delivery, and Backups and restoration; ADRs 0002 and 0003; CONFIG_FORMAT.md; +DEPENDENCIES.md, DEVELOPMENT.md, and TESTING.md. Preserve the distinction between +vault encryption, server secrets, and an unencrypted output file. + +This milestone prepares versioned vault bytes and encrypted import results; +filesystem persistence/Commit and its mandatory backups belong to milestone 4. +Initial setup prepares encryption of valid existing files immediately in the +same setup operation, but must not install a vault, rewrite the server, or bypass +the later persistence gate. Use supplied buffers and an explicit read-only source +adapter; no automatic scan at application startup. TUI/password prompts, delivery +files/QR and first-delivery session orchestration belong to milestones 6 and 7. +No runtime or service operations are introduced. + +#### Existing code and integration constraints + +- M1 provides `domain::PublicKey`, `Client`, `ClientAddresses`, `Snapshot`, + `ClientStates`, and validated/resolved settings, including the source directory + and data paths. Public keys, not filenames or repeated aliases, identify secrets. +- M2 exposes `wireguard::ConfigDocument::parse`, `render`, `peers`, `sections`, + and `source_slice`, plus explicit add/delete/rename/legacy-metadata edits. + `PeerInspection` provides identity candidates, AllowedIPs, metadata, and issues. + Its raw source may contain secrets; never expose it through import reports. +- `project_client` requires valid attached metadata and a supported address shape. + Do not make successful projection a prerequisite for preserving or reconciling + existing records. Missing metadata, IPv6-only peers, extra routes, and ambiguous + keys must remain inspectable rather than being silently dropped or normalized. +- Managed metadata is already v1: Base64url alias and optional strict UTC creation + string in an attached comment block. Keep that grammar unchanged. The server + alias wins; a filename/public-key fallback is display-only until an explicit + metadata edit. Do not assign import time as an unknown creation date. +- Server parsing is not client parsing: a client file's `[Interface] PrivateKey` + identifies the client, while its `[Peer] PublicKey` normally identifies the + server. Never use that server key as the client's identity. +- Cargo currently contains base64, ipnet, rustix, serde, thiserror, and toml. + Add only the approved crypto crates needed here: age, x25519-dalek, getrandom, + secrecy, and zeroize; tempfile may be a dev-dependency for source-adapter tests. + Verify current official APIs, maintained compatible versions, feature flags, + and secret-type compatibility before choosing them. No custom cipher/KDF, + OpenSSL subprocess, `wg genkey`, unapproved crate, or unrelated upgrade. + +#### Context and implementation steps + +Run these tasks sequentially with one writer. GPT-6-Sol/medium coordinates; +GPT-6-Luna/high implements, reviews independently, and repairs. The coordinator +supplies scope, owned files, relevant documents, accepted interfaces, checks, and +required report contents. Workers do not accept milestones or edit PLANS.md. + +| Task | Owner | Files and deliverable | +| --- | --- | --- | +| M3.1 | implementer | `src/vault.rs`, optional `src/vault/keys.rs`, `src/lib.rs`, Cargo files: secret-safe WireGuard key types, generation and matching | +| M3.2 | implementer | `src/vault/` modules as needed, `docs/VAULT_FORMAT.md`: asymmetric vault setup, password-protected identity, versioned envelopes and load validation | +| M3.3 | implementer | `src/vault/import.rs` or a narrow import module: caller-buffer client parsing, reconciliation reports and encrypted import preparation | +| M3.4 | implementer | read-only configured-directory adapter, `tests/vault_import.rs`, synthetic temporary inputs and README guidance: initial setup/import workflow and failure coverage | +| M3.5 | reviewer, repairer | Independent crypto/use-of-library, import and integrated regression review; bounded fixes and re-review | +| M3.6 | coordinator | Integrated gates, findings/evidence and acceptance/handoff in PLANS.md | + +M3.1 wraps private keys/passwords in secret-aware containers with redacted or no +Debug, no implicit Display/Serialize, and explicit exposure only at necessary +cryptographic/encoding boundaries. Generate WireGuard-compatible private keys +using OS randomness and derive their public key using x25519-dalek. Preserve +canonical WireGuard Base64 on output; validate decoded length/encoding on input. +Keep imported private-key bytes intact rather than replacing them with newly +generated keys. Test generation/derivation and matching without leaking values +in assertion failures. Handle random-source errors as recoverable errors. + +M3.2 initializes a separate age recipient/identity pair, never reusing a WireGuard +key. Store only the recipient and password-encrypted vault identity in the public +serialized bundle; encrypt each client record to that recipient without requiring +the password. Use the age library's established authenticated encryption and +password protection; do not implement the cryptographic construction manually. + +Document exact v1 vault/record formats, component filenames/layout intended for +M4, version handling, public record identifiers, and size limits before encoding +production data. Use serde/TOML or opaque age payloads as appropriate, without +silently introducing a second serializer dependency. Ensure serialization cannot +write plaintext secrets through a generic derive. Reject unknown versions and +truncated/oversized structures with redacted typed errors. + +Bind the decrypted payload to its expected client public key and vault context; +derive its public key again and reject mislabelled/swapped records. On unlocking, +verify the decrypted vault identity matches the declared recipient. Do not allow +public envelope metadata alone to authorize a key's identity or reissue status. +Do not invent cryptography to authenticate a plaintext index: document the limits +of unauthenticated public metadata and perform the necessary checks after decrypt. + +Each authorized retrieval accepts a fresh password, decrypts only for that +operation, validates binding, and returns an explicit short-lived secret result. +Do not cache an unlocked identity, retain the password between requests, or fall +back to original plaintext files after wrong password, missing/corrupt records, +or password loss. Keep cloning/exposure of secrets minimal; zeroize supported +owned buffers on drop and document unavoidable library-managed copies without +claiming all process memory can be erased. Password-policy/UI additions are not +part of this milestone. + +M3.3 parses supplied client content without generic lossy INI serialization. +Require one unambiguous client private key and derive its PublicKey for matching. +Validate client Address values while retaining enough information to report +unsupported shapes and compare addresses rather than conflating client prefixes +with server host routes. Preserve existing optional connection parameters needed +later, including any valid preshared secret, inside the encrypted payload; do not +introduce new MTU/keepalive/PSK settings or generation. Reject ambiguous or malformed +secret fields rather than choosing the last occurrence. Do not run PostUp/hooks, +resolve DNS, mutate routes, or execute text from imported files. + +Reconcile against the authoritative M2 server inspection and provide explicit, +secret-free results for server-only, file-only, matched, mismatched, invalid, +and ambiguous/duplicate candidates. Missing/invalid managed metadata and narrow +projection failures are represented separately from cryptographic key mismatch. +Compare the client file's server peer key with the server identity when it can +be determined safely; absent/ambiguous server identity is unresolved, not assumed +valid. Do not infer identity from filenames or friendly aliases. + +Matching key material may be encrypted even when address parameters conflict, +but record/report that reissue is blocked pending explicit reconciliation. Do +not truncate routes, overwrite server metadata, automatically add a file-only +client to the VPN, or treat public filename fallback as authoritative. Duplicate +files or existing vault entries must not cause silent overwrite or last-wins +selection; preserve the prior record and return an explicit resolution issue. +A repeat import without decryption must not claim plaintext equality it cannot +verify. Define deterministic handling and tests rather than guessing equivalence. + +M3.4 supplies a bounded read-only adapter for the configured source directory: +regular files only, documented candidate selection, no recursive traversal or +symlink following, bounded content sizes, and redacted per-file errors. A source +file failure must not abort other valid imports; a directory-enumeration failure +must be visible and must not be called a successful complete scan. Use safe file +opening for privileged future callers; expanding rustix's approved feature set +is permitted when required, but no new direct crate is implicitly approved. + +Initial setup combines vault creation with attempted import of valid candidates. +Return the encrypted prepared bundle and a report; invalid/conflicting candidates +remain visible and originals are untouched. Explicit later import reuses the +vault recipient without asking for a password or replacing the protected identity. +Reissue authorization remains separate. Keep directory IO out of pure parsing and +crypto tests; all adapter tests use synthetic temporary directories, never actual +configured server/client paths. All writes in this milestone are test artifacts +or explicit serialization buffers, not installed application state. + +#### Acceptance and validation + +- [x] M3-A1: generated WireGuard keys derive valid PublicKey values; imported key + correspondence and malformed Base64/length handling are tested without tools. +- [x] M3-A2: vault setup uses an independent key pair and password-protected + identity; serialized application vault data contains no plaintext private keys, + passwords, or imported secret-bearing configurations. +- [x] M3-A3: new record encryption and later explicit import need only the public + recipient, not a password/unlocked identity; loading ciphertext needs no password. +- [x] M3-A4: correct-password retrieval returns the selected secret; wrong/missing + password, unknown versions, malformed/truncated ciphertext, recipient mismatch, + and record relabelling fail without fallback or loss of the original bundle. +- [x] M3-A5: independent retrieval operations require fresh authorization, with no + reusable unlocked identity/password exposed through ordinary vault APIs. Errors, + reports and Debug are redacted; secret serialization is explicit and protected. +- [x] M3-A6: matching uses the public key derived from client Interface PrivateKey, + not its server peer key or alias. Reports cover matched/server-only/file-only, + conflicts, invalid files, duplicate/ambiguous keys and unsupported address shapes. +- [x] M3-A7: address/server-binding discrepancies remain explicit and block the + authorized reissue path until resolved; a valid secret may still be encrypted. + Duplicate imports never silently replace existing secrets or trusted metadata. +- [x] M3-A8: setup prepares encrypted imports from valid source files while skipping + invalid candidates with explanations. Regular-file selection, limits, symlink + rejection and enumeration failures are tested using temporary data only. +- [x] M3-A9: source files and original server bytes are unchanged; no metadata is + implicitly inserted, no creation dates invented, and M2 parsing/editing guarantees + remain intact. Missing metadata does not prevent encrypted preservation of a key. +- [x] M3-A10: serialized bundle round-trip preserves encrypted identity/records + without decryption, ready for M4 backup/persistence; no filesystem installation, + implicit Commit/runtime change, UI delivery or first-issue bypass is introduced. +- [x] M3-A11: standard integrated gates and M1/M2 regressions pass; independent + review closes actionable crypto, import, secret-lifetime and boundary findings. + +Planned commands from the repository root after M3 tests and lockfile exist: + +```sh +cargo test --locked vault +cargo test --locked --test vault_import +cargo test --locked --test config_preservation +cargo fmt --all -- --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --locked +cargo build --locked --release +./target/release/wireguard-admin --help +./target/release/wireguard-admin --version +git diff --check +``` + +Ensure the focused filter runs tests and record counts/results; all gates should +exit zero. Resolve approved new dependencies/update the lockfile before locked +checks. Keep password-encryption tests bounded while exercising real library +crypto; do not weaken production KDF parameters simply to speed up tests. Use +synthetic generated keys and no sensitive panic/assert output. No root, Docker, +VM, actual WireGuard interface, or production config is required or claimed. + +#### Progress + +- [x] Description prepared against accepted M1/M2 code and current documents. +- [x] M3.1 — implementer (`/root/implementer_m3_1`) completed 2026-10-18T19:41:39Z; secret-safe WireGuard key generation, parse, derivation and matching. +- [x] M3.2 — implementer (`/root/implementer_m3_2`) completed 2026-10-18T19:46:09Z; versioned age-encrypted bundle, fresh-password retrieval, format documentation. +- [x] M3.3 — implementer (`/root/implementer_m3_3`) completed 2026-10-18T19:52:45Z; caller-buffer parse/reconciliation and authenticated import status. +- [x] M3.4 — implementer (`/root/implementer_m3_4`) completed 2026-10-18T19:58:55Z; bounded read-only directory adapter, initial setup/import flow and synthetic tests. +- [x] M3.5 — fresh reviewer (`/root/reviewer_m3_r1`) completed R1–R7; repairer (`/root/repairer_m3_r1`) resolved all findings; R7 found no actionable issue. +- [x] M3.6 — coordinator integrated gates passed and acceptance recorded. + +#### Review and repair + +| Finding | Iteration | Location and impact | Owner | Resolution and evidence | State | +| --- | --- | --- | --- | --- | --- | +| M3-R1-01 | R1 | `src/vault.rs:134` Debug printed untrusted imported connection fields | repairer | Redacted imported payload Debug; sentinel regression and R2 passed | closed | +| M3-R1-02 | R1 | `src/vault/source.rs:110` directory path was reopened after secure FD open, allowing enumeration/read inventory mismatch | repairer | Enumerates through pinned `rustix::fs::Dir`; path-replacement regression and R2 passed | closed | +| M3-R1-03 | R1 | `src/vault/import.rs:409` unknown server identity was collapsed into a known key mismatch | repairer | Added Matches/Mismatch/Unresolved and blocked retrieval regression; R2 passed | closed | +| M3-R1-04 | R1 | `src/vault/source.rs:206` duplicate existing vault entry was generic encryption failure | repairer | Added equality-unverified duplicate issue; repeat-import regression and R2 passed | closed | +| M3-R2-01 | R2 | `docs/VAULT_FORMAT.md` omitted intended component filename/layout for M4 | repairer | Documented intended `data_directory/vault.toml` without persistence; R3 confirmed | closed | +| M3-R2-02 | R2 | Secret-bearing test `assert_eq!` output could disclose synthetic key/config bytes on failure | repairer | Replaced with redacted boolean assertions; R3 confirmed | closed | +| M3-R3-01 | R3 | `src/vault.rs:306` unsupported encrypted payload version reported as `RecordMismatch`, not actionable `UnsupportedVersion` | repairer | Split version/label checks; valid encrypted unsupported-version regression and R4 passed | closed | +| M3-R4-01 | R4 | `src/vault.rs:48` canonical Base64 comparison created a plaintext key `String` without zeroization | repairer | Wrapped in `Zeroizing`; R5 confirmed | closed | +| M3-R5-01 | R5 | `src/vault.rs:38` Base64 decode could drop partially decoded key bytes on malformed input before zeroizing wrapper | repairer | Decode into zeroizing buffer from allocation onward; R6 confirmed | closed | +| M3-R5-02 | R5 | `src/vault.rs:287` identity UTF-8 conversion cloned decrypted identity into ordinary Vec, vulnerable on error | repairer | Borrow from zeroizing plaintext; R6 confirmed | closed | +| M3-R6-01 | R6 | `src/vault/import.rs:112` allocated ordinary directive String before knowing whether value was secret/ignored | repairer | Borrowed dispatch; zeroizing PrivateKey/PSK allocations, duplicate/error paths and regressions passed; R7 confirmed | closed | + +The reviewer otherwise found crypto binding, fresh authorization and bounded +scan behavior sound at fixture level. No finding may be closed by hiding a +conflict, disabling tests, or relaxing encryption. + +#### Evidence and handoff + +Planning inspected Cargo.toml, exported M2 document/edit/metadata APIs, +CONFIG_FORMAT.md, M1 model constraints, approved dependencies and ADR 0002. +M2 records independent R3 acceptance, 42 unit plus 9 integration tests, and passing +integrated gates. Those results are historical evidence; this planning task did +not rerun application tests, select crypto versions, or add dependencies/source. +No workers were dispatched and no source directory, private key, service, or +network environment was accessed. + +Next execution action: verify configured model roles and the current repository, +review official APIs/compatibility for the approved cryptographic libraries, then +dispatch M3.1 with the bounded task and acceptance criteria. Escalate unavailable +packages as dependency/environment blockers rather than changing approved crypto. + +Execution verified the coordinator session as `gpt-6-sol`/`medium`; M1 and M2 +are accepted. At start, the only worktree change was this prepared M3 plan. +Rustc/Cargo 1.97.1, rustfmt 1.9.0 and Clippy 0.1.97 match the pinned baseline. +Official crate documentation was checked for `age` 0.12.1 (Rust 1.74 minimum, +recipient and passphrase APIs, no default features), `x25519-dalek`, +`getrandom`, and `secrecy`. `age` re-exports its compatible `secrecy` via +`age-core`; use that re-export rather than adding a conflicting direct version. +The local cache initially lacked `age`; a read-only `cargo info age@0.12.1` +failed under sandbox DNS restrictions, then succeeded with approved network +escalation. No manifest or source was changed during this prerequisite check. +Next: assign M3.1 to the configured implementer. +M3.1 added `vault::WireGuardPrivateKey` using approved `x25519-dalek` 2.0.1, +`getrandom` 0.3.4, and `zeroize` 1.9.0; no `age`/`secrecy` direct dependency was +introduced prematurely. The coordinator inspected the new key API and manifest. +The implementer ran `cargo test --locked vault` (3 tests), `cargo test --locked` +(45 unit and 9 preservation integration tests), fmt, Clippy, release build and +diff check successfully. Its turn context was verified `gpt-6-luna`/`high`. +No production data or service was accessed. Next: M3.2 versioned age vault +format and authorized retrieval on top of this key type. +M3.2 added approved `age` 0.12.1, `VaultBundle` recipient encryption and +passphrase-protected identity, bounded TOML loading, ciphertext/recipient/key +binding checks, and `docs/VAULT_FORMAT.md`. The coordinator inspected source, +format and manifest. The worker ran `cargo test --locked vault` (9 tests), +`cargo test --locked` (51 unit and 9 preservation integration tests), fmt, +Clippy, release build and diff check successfully; its turn context was +verified `gpt-6-luna`/`high`. This is task progress, not integrated M3 +acceptance. M3.3 must extend encrypted records to retain imported connection +parameters and enforce duplicate/no-overwrite and conflict reissue semantics; +independent review must scrutinize the current public `reissue_blocked` flag's +authority and secret lifetime. Next: M3.3 caller-buffer import/reconciliation. +M3.3 added `src/vault/import.rs` and extended the encrypted v1 record payload +to retain imported connection parameters, including valid PSKs. It changed +duplicate insertion to fail without overwrite and moved the conflict gate into +the authenticated payload; the public flag is advisory. The coordinator +inspected implementation and updated format documentation. The worker ran +`cargo test --locked vault` (15 tests), `cargo test --locked` (57 unit and 9 +preservation integration tests), fmt, Clippy, release build and diff check +successfully; its turn context was verified `gpt-6-luna`/`high`. This remains +pending independent review. Potential review focus: unresolved versus +mismatched server identity classification, preservation and lifetime of source +buffers, and absence of conflict-clearing bypass. Next: M3.4 bounded read-only +directory adapter and setup/import workflow. +M3.4 added `src/vault/source.rs`, `tests/vault_import.rs`, README guidance, +approved `tempfile` 3.23.0 as a development dependency, and `rustix` fs/std +features. It uses Linux `openat2` no-symlink file opens, per-file/aggregate +limits and incomplete-scan reporting. The coordinator inspected the adapter, +fixture tests and manifest. The worker ran `cargo test --locked vault` (16 +tests), `cargo test --locked --test vault_import` (5), +`cargo test --locked --test config_preservation` (9), `cargo test --locked` +(58 unit, 9 preservation and 5 vault-import tests), fmt, Clippy, release build +and diff check successfully; its turn context was verified `gpt-6-luna`/`high`. +No production path or service was accessed. Possible review focus: path-based +directory enumeration after secure fd open, explicit duplicate-existing-record +reporting, and whether server-key unknown is distinct from mismatch. Linux +`openat2` absence yields an incomplete scan, not success. Next: fresh integrated +independent review M3.5 before acceptance. +Fresh R1 reviewer turn used `gpt-6-luna`/`high`. It inspected integrated code +and ran focused vault (16), vault-import (5), and M2 preservation (9) tests; +the full suite passed 58 unit plus 14 integration tests, and fmt, Clippy, +release build, help/version and diff check passed. It found M3-R1-01 through +M3-R1-04 above. These passing tests do not close the review findings. Next: +repair, re-review, then coordinator integrated gates. +The repairer (`/root/repairer_m3_r1`, `gpt-6-luna`/`high`) reported all four +R1 findings repaired with focused regressions. It ran `cargo test --locked +vault` (18), vault-import (5), M2 preservation (9), full tests (60 unit plus +14 integration), fmt, Clippy, release build and diff check successfully. The +coordinator inspected changed paths. Findings remain pending independent R2 +re-review; no acceptance is claimed yet. +R2 independently confirmed M3-R1-01 through M3-R1-04 and found no remaining +defect in those paths. It found M3-R2-01 and M3-R2-02 above. Focused vault +(18), vault-import (5), preservation (9), full tests (60 unit plus 14 +integration), fmt, Clippy, release build, help/version and diff check passed; +the passing gates do not resolve the documentation and test-output gaps. Next: +narrow repair and R3 re-review. +R3 independently closed M3-R2-01/02 and confirmed R1 repairs remain intact. +It found M3-R3-01 and noted an M3-A7 evidence gap: the existing known +server-key mismatch test also has unsupported address projection, so an +isolated mismatch-blocks-reissue test is needed. Aggregate scan-byte and +directory-entry limits are implemented but lack dedicated boundary tests; +their coverage should be strengthened if practical. R3 reran focused vault +(18), vault-import (5), preservation (9), full suite (60 unit plus 14 +integration), fmt, Clippy, release build, help/version and diff check +successfully. Passing checks do not close M3-R3-01. Next: repair and R4. +The repairer (`/root/repairer_m3_r1`) split encrypted payload version and +label checks, added a valid age-encrypted unsupported-version regression, an +isolated known server-key-mismatch/reissue-block regression, and inexpensive +aggregate-byte/directory-entry cap tests through private testable limits. It +reported 22 focused vault tests, 5 vault-import tests, 9 M2 preservation +tests, 64 unit plus 14 integration tests in the full suite, and passing fmt, +Clippy, release build and diff check. The coordinator inspected the new code; +R4 independent re-review is pending. No M3 acceptance is claimed yet. +R4 independently confirmed M3-R3-01 and all earlier repairs. It reran focused +vault (22), vault-import (5), M2 preservation (9), full tests (64 unit plus +14 integration), fmt, Clippy, release build, help/version and diff check, +all passing. It found M3-R4-01 above; this must be repaired before M3 +acceptance. No production access or real-system result is claimed. +R5 independently confirmed M3-R4-01 but identified M3-R5-01/02 in adjacent +secret-lifetime paths. Focused vault (22), vault-import (5), preservation (9), +full tests (64 unit plus 14 integration), fmt, Clippy, release build, +help/version and diff check all passed; these checks do not prove zeroization +on failure. Earlier repairs remain intact. Next: targeted cleanup and R6 +re-review; M3 is not accepted. +R6 independently confirmed M3-R5-01/02: base64 decoding now occurs inside a +zeroizing Vec even on failure, and age identity parsing borrows from zeroizing +bytes. It found M3-R6-01 in the import parser. It reran focused vault (22), +vault-import (5), M2 preservation (9), full suite (64 unit plus 14 +integration), fmt, Clippy, release build, help/version and diff check, all +passing. No production access or real-system result was claimed. Next: +targeted parser cleanup and R7. +The repairer changed `src/vault/import.rs` to borrow section, directive key and +value until dispatch; ignored/misplaced values are not allocated. Recognized +PrivateKey/PSK values enter zeroizing storage immediately, including +duplicate/error paths. Redaction regressions were added. It reported focused +vault (22), full tests (64 unit plus 14 integration), fmt, Clippy, release +build and diff check passing; the coordinator inspected the changed parser. +Independent R7 re-review was the next gate after this repair. +R7 independent reviewer (configured `gpt-6-luna`/`high`) inspected the final +parser, prior repairs, cryptographic/secret lifetimes, source-directory safety +and M3-A1–A11. It found no remaining actionable issue and recommended +acceptance. The coordinator reran the full planned command set on the final +integrated source on 2026-10-19: `cargo test --locked vault` passed 22 tests; +`cargo test --locked --test vault_import` passed 5; `cargo test --locked --test +config_preservation` passed 9; `cargo fmt --all -- --check`, `cargo clippy +--locked --all-targets -- -D warnings`, `cargo build --locked --release`, and +`git diff --check` exited zero; `cargo test --locked` passed 64 unit tests, +9 preservation integration tests and 5 vault-import integration tests (0 doc +tests); release `--help` and `--version` both exited zero with existing safe +startup output. The focused vault filter ran 22 actual tests. These are +unprivileged synthetic/unit fixture results only. No production files, +running VPN, host service, Alpine container or systemd VM was accessed; +container/VM checks are not required for this M3 library milestone and are +not claimed. No M3 blocker remains. + +#### Decisions and discoveries + +The client's Interface private key and the server's Peer public key have different +roles. Existing M2 source/span APIs support authoritative reconciliation but must +not be reused as an unsafe source-dump parser for secrets. M1 projection cannot +represent all legacy peers; use richer import inspection without changing that +model or metadata v1 merely to suppress anomalies. + +Prepared vault encoding is the M3/M4 interface: M3 supplies versioned encrypted +components; M4 owns atomic installation, backup-before-mutation, restore, and +Commit deletion. M6 owns file/QR generation and first-delivery session policy. +This boundary avoids persisting unbacked state during an import/crypto milestone. + +#### Outcome + +Accepted after independent R7 review, closure of all findings and passing +M3-A1–A11 gates. Stop here; milestone 4 is the next action only on a new +request. No vault installation, Git commit, deployment or production access +occurred. ### Milestone 4 — Backups, safe Commit, and full restoration @@ -661,11 +1061,12 @@ Acceptance: real D-Bus job handling, separate restart verification, address rele ## Current progress and resume point -- Milestones 1 and 2 are accepted; milestones 3–9 remain pending. +- Milestones 1, 2 and 3 are accepted; milestones 4–9 remain pending. - The approved dependency list is in [DEPENDENCIES.md](docs/DEPENDENCIES.md); container testing methodology is in [TESTING.md](docs/TESTING.md). - Milestone 1 is expanded and the Rust environment is pinned. - Milestone 2 delivered a lossless in-memory WireGuard configuration editor, versioned metadata, and behavioral preservation tests. -- Next action: stop after accepted M2; begin M3 only if separately requested. +- Milestone 3 delivered secret-safe key handling, a versioned encrypted vault, caller-buffer reconciliation, and a bounded read-only source-directory import adapter. +- Next action: stop after accepted M3; begin M4 only if separately requested. - M1 integrated gates and independent re-review passed. No container or VM results are claimed. ## Plan maintenance diff --git a/README.md b/README.md index 898f5eb..0ece4da 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # wireguard-admin -This repository contains the initial Rust package, startup boundary, validated -settings loader, client domain model, and a pure in-memory WireGuard server -configuration parser/editor. The terminal management interface, file -persistence, vault, client import workflow, backups, and VPN operations have not -been implemented yet. +This repository contains the Rust package, startup boundary, validated settings +loader, client domain model, a byte-preserving in-memory WireGuard configuration +editor, and library APIs for encrypted client vaults and read-only client import. +The terminal management interface, installed vault persistence, backups, and VPN +operations have not been implemented yet. The package requires Rust 1.97.1 or newer and uses the Rust 2024 edition. From the repository root, run: @@ -69,3 +69,21 @@ let bytes_to_review = updated.render(); // no persistence or VPN operation Management startup still exits with an explicit “not implemented” error after its root check; the library APIs are not yet connected to a management session. +## Vault and existing-client import library + +`wireguard_admin::vault` provides WireGuard key handling, an encrypted versioned +vault bundle, caller-buffer client configuration parsing, and reconciliation +against a supplied server document. The source adapter can prepare an initial +encrypted bundle or explicitly import candidates into an existing prepared +bundle. These operations return in-memory data only: they do not install or save +a vault, edit source files or server configuration, Commit, or touch the VPN. + +Directory scans use the resolved `client_source_directory` only. A candidate is +an immediate regular file whose name ends in `.conf` (case-insensitive); the +adapter does not recurse, follow symlinks, or read files larger than 1 MiB. A +scan is limited to 4,096 directory entries, 1,024 candidates, and 32 MiB total +input. Per-file failures are redacted and do not prevent other files from being +examined. Enumeration, read, or limit failures mark the scan incomplete. The +original files remain untouched. Adapter examples and behavior are covered with +synthetic temporary directories in `tests/vault_import.rs`. + diff --git a/docs/VAULT_FORMAT.md b/docs/VAULT_FORMAT.md new file mode 100644 index 0000000..cf0c9b1 --- /dev/null +++ b/docs/VAULT_FORMAT.md @@ -0,0 +1,133 @@ +# Encrypted vault format + +The application stores the vault as UTF-8 TOML, schema version 1. The bundle is +an opaque encrypted unit suitable for persistence, backup, and restoration +without the vault password. Parsing is bounded and rejects unsupported versions, +unknown fields, malformed labels, invalid Base64, and oversized bundles. + +## Version 1 structure + +For M4, the current encrypted bundle is intended to live at +`ResolvedSettings::data_directory.join("vault.toml")`. This stable filename is +relative to the resolved data directory, whose default is `Backup/data` beside +the executable. M3 only prepares and serializes vault bytes in memory; it does +not create this file or install application state. M4 persistence will own that +write. + +The top level contains: + +| Field | Content | +| --- | --- | +| `version` | Integer `1`. Other values are rejected. | +| `recipient` | Canonical age x25519 public recipient (`age1...`). | +| `identity_ciphertext` | Standard Base64 of an age binary file encrypted with the passphrase recipient format (scrypt). Its plaintext is the age x25519 secret identity. | +| `records` | TOML table keyed by each client's canonical WireGuard public key. | + +Each record table contains `ciphertext`, standard Base64 of an age binary file +encrypted to `recipient`, and `reissue_blocked`, an advisory status snapshot. +The snapshot is not authorization. Retrieval decrypts the record and enforces its +authenticated `reissue_blocked` value; editing the TOML boolean cannot authorize +a blocked key. Record insertion rejects an existing public-key label and never +silently replaces ciphertext. + +The TOML shape is: + +```toml +version = 1 +recipient = "age1..." +identity_ciphertext = "" + +[records.""] +ciphertext = "" +reissue_blocked = false +``` + +The plaintext of a decrypted client record is UTF-8 TOML with this schema: + +```text +version = 1 +label = "" +private_key = "" +client_addresses = [""] +interface_dns = "" +interface_mtu = "" +server_public_key = "" +endpoint = "" +allowed_ips = [""] +persistent_keepalive = "" +preshared_key = "" +reissue_blocked = true +``` + +Optional fields are omitted when absent. Unknown input directives such as hooks +are not interpreted or executed, and are not part of the retained delivery +parameters. The record label is authenticated by age encryption and repeated +inside the ciphertext. On retrieval the implementation checks the embedded label, +derives the public key from the decrypted private key, and enforces the encrypted +conflict bit. Swapping a ciphertext to another public-key table entry therefore +fails closed. No alias, filename, or server peer key is used as client identity. + +Client imports derive identity only from `[Interface] PrivateKey`. The `[Peer] +PublicKey` is retained as the server identity. Address discrepancies, a confirmed +server-key mismatch, an unresolved authoritative server identity, file-only +clients, unsupported address projection, invalid inputs, and duplicate candidates +are separate report outcomes. An unresolved identity is not treated as a match. +A valid key may be encrypted while a conflict is present, but password-authorized +retrieval remains blocked. Clearing that authenticated state is deferred until an explicit reconciliation operation +can verify both client material and a fresh authoritative server configuration; +the current API intentionally provides no public-boolean or metadata-only bypass. + +## Read-only source scan + +The configured client source directory is scanned non-recursively. Immediate +regular files ending in `.conf` (ASCII case-insensitive) are candidates; other +names and nested directories are ignored. Symlinks and non-regular files are +not read. Each candidate is capped at 1 MiB, a scan at 4,096 directory entries, +1,024 candidates, and 32 MiB of input. File/open/read/size errors are reported +by safe category and do not stop other candidates. A directory or enumeration +failure and any per-file read/limit failure mark the scan incomplete. Initial setup returns +the encrypted in-memory bundle and report; installing it and committing remain +separate operations. Explicit later scans encrypt to the existing public +recipient and preserve the protected identity. Source files are never modified. +If a later import finds an existing public-key record, the prior ciphertext is +preserved and the report identifies that duplicate; the scanner cannot establish +plaintext equality without decrypting the prior record, so it does not claim the +files are equal. + +## Limits and failure behavior + +- Serialized TOML bundle: 16 MiB maximum. +- Records: 4,096 maximum. +- One encrypted record: 64 KiB maximum, before Base64 encoding. +- Password-encrypted identity: 16 KiB maximum, before Base64 encoding. +- The serialized representation must fit within the bundle limit. + +Malformed/truncated ciphertext, a recipient that differs from the protected +identity, unknown versions, missing records, and relabelled records return +secret-free typed errors. An unsupported decrypted record schema returns the +version error, while a payload whose label does not match the requested public +key returns the record mismatch error. There is no fallback to plaintext files. +The bundle loader does not unlock the identity, so backup and restore require no +password. + +## Secret lifetime during encoding + +New-client encryption needs only the public age recipient. Retrieval constructs +an age scrypt identity for one call, decrypts the protected x25519 identity, +verifies it against the public recipient, decrypts one record, and drops the +temporary identity and plaintext on return. The application API does not expose +an unlocked vault identity or retain a password between calls. + +The WireGuard key's explicit Base64 representation and the record plaintext +containing it must exist briefly in process memory before age can encrypt them. +Age also creates and buffers its file key, stream state, and output ciphertext. +These copies are inherent to the selected age streaming API; no plaintext is +written into the TOML bundle. The in-memory private-key type, passphrase type, +and temporary plaintext buffers use zeroizing secret containers where supported. +Age's standard passphrase format uses its normal scrypt work factor; the +application does not override or weaken it. The caller remains responsible for +keeping the input password string short-lived. + +The password protects only the vault identity. It does not encrypt server +configuration, retained original client files, the entire backup archive, or +WireGuard server secrets stored elsewhere. diff --git a/src/lib.rs b/src/lib.rs index fff7c78..552f75a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; pub mod domain; pub mod startup; +pub mod vault; pub mod wireguard; diff --git a/src/vault.rs b/src/vault.rs new file mode 100644 index 0000000..1f21a1e --- /dev/null +++ b/src/vault.rs @@ -0,0 +1,723 @@ +//! Secret-safe primitives used by the encrypted client vault. + +pub mod import; +pub mod source; + +use std::collections::BTreeMap; +use std::fmt; +use std::io::Write; +use std::str::FromStr; + +use age::Encryptor; +use age::secrecy::{ExposeSecret, SecretString}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::domain::PublicKey; + +/// A WireGuard private key held in memory and erased when dropped. +/// +/// Private key bytes are neither formatted nor serialized implicitly. Use +/// [`Self::to_base64`] only at an explicit WireGuard configuration boundary. +pub struct WireGuardPrivateKey(StaticSecret); + +impl WireGuardPrivateKey { + /// Generate key material from the operating system's random source. + pub fn generate() -> Result { + let mut bytes = Zeroizing::new([0_u8; 32]); + getrandom::fill(bytes.as_mut()).map_err(|_| KeyError::RandomUnavailable)?; + Ok(Self(StaticSecret::from(*bytes))) + } + + /// Parse canonical standard Base64 encoding of exactly 32 private key bytes. + pub fn parse_base64(value: &str) -> Result { + if value.len() != 44 { + return Err(KeyError::InvalidPrivateKey); + } + let mut decoded = Zeroizing::new(Vec::with_capacity(32)); + STANDARD + .decode_vec(value, &mut decoded) + .map_err(|_| KeyError::InvalidPrivateKey)?; + let bytes: Zeroizing<[u8; 32]> = Zeroizing::new( + decoded + .as_slice() + .try_into() + .map_err(|_| KeyError::InvalidPrivateKey)?, + ); + let canonical = Zeroizing::new(STANDARD.encode(*bytes)); + if canonical.as_str() != value { + return Err(KeyError::InvalidPrivateKey); + } + Ok(Self(StaticSecret::from(*bytes))) + } + + /// Explicitly encode this private key for a WireGuard configuration. + /// + /// The returned `String` contains plaintext key material and must be kept + /// short-lived by the caller. + pub fn to_base64(&self) -> String { + STANDARD.encode(self.0.as_bytes()) + } + + /// Derive the corresponding WireGuard public key using X25519. + pub fn public_key(&self) -> PublicKey { + let public = X25519PublicKey::from(&self.0); + PublicKey::from_bytes(public.to_bytes()) + } + + /// Check correspondence with an expected public identity. + pub fn matches_public_key(&self, expected: &PublicKey) -> bool { + self.public_key() == *expected + } +} + +const BUNDLE_VERSION: u32 = 1; +const MAX_BUNDLE_BYTES: usize = 16 * 1024 * 1024; +const MAX_RECORDS: usize = 4096; +const MAX_RECORD_CIPHERTEXT_BYTES: usize = 64 * 1024; +const MAX_IDENTITY_CIPHERTEXT_BYTES: usize = 16 * 1024; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RecordPayload { + version: u32, + label: String, + private_key: String, + client_addresses: Vec, + interface_dns: Option, + interface_mtu: Option, + server_public_key: Option, + endpoint: Option, + allowed_ips: Vec, + persistent_keepalive: Option, + preshared_key: Option, + reissue_blocked: bool, +} + +impl Drop for RecordPayload { + fn drop(&mut self) { + self.private_key.zeroize(); + if let Some(value) = &mut self.preshared_key { + value.zeroize(); + } + } +} + +/// Password-authorized, short-lived client configuration data for later delivery. +pub struct RetrievedClientRecord { + private_key: Option, + pub client_addresses: Vec, + pub interface_dns: Option, + pub interface_mtu: Option, + pub server_public_key: Option, + pub endpoint: Option, + pub allowed_ips: Vec, + pub persistent_keepalive: Option, + preshared_key: Option, +} + +impl RetrievedClientRecord { + pub fn private_key(&self) -> Option<&WireGuardPrivateKey> { + self.private_key.as_ref() + } + + /// Explicitly expose the optional preshared key for a delivery boundary. + pub fn preshared_key_base64(&self) -> Option<&str> { + self.preshared_key.as_deref() + } + + pub fn into_private_key(mut self) -> Result { + self.private_key.take().ok_or(VaultError::CorruptRecord) + } +} + +impl fmt::Debug for RetrievedClientRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RetrievedClientRecord") + .field("imported_fields", &"[REDACTED]") + .finish() + } +} + +impl Drop for RetrievedClientRecord { + fn drop(&mut self) { + if let Some(value) = &mut self.preshared_key { + value.zeroize(); + } + } +} + +/// A serializable encrypted vault. New records use the public recipient; retrieval unlocks the +/// password-protected identity only for the duration of that operation. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VaultBundle { + version: u32, + recipient: String, + identity_ciphertext: String, + records: BTreeMap, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct VaultRecord { + ciphertext: String, + reissue_blocked: bool, +} + +impl fmt::Debug for VaultBundle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("VaultBundle") + .field("version", &self.version) + .field("record_count", &self.records.len()) + .field("encrypted_material", &"[REDACTED]") + .finish() + } +} + +impl VaultBundle { + /// Create a separate age x25519 identity protected by age's standard passphrase format. + pub fn create(password: &str) -> Result { + if password.is_empty() { + return Err(VaultError::InvalidInput); + } + let identity = age::x25519::Identity::generate(); + let recipient = identity.to_public().to_string(); + let identity_text = identity.to_string(); + let encryptor = + Encryptor::with_user_passphrase(SecretString::new(password.to_owned().into())); + let mut ciphertext = Vec::new(); + let mut writer = encryptor + .wrap_output(&mut ciphertext) + .map_err(|_| VaultError::Crypto)?; + writer + .write_all(identity_text.expose_secret().as_bytes()) + .map_err(|_| VaultError::Crypto)?; + writer.finish().map_err(|_| VaultError::Crypto)?; + if ciphertext.len() > MAX_IDENTITY_CIPHERTEXT_BYTES { + return Err(VaultError::TooLarge); + } + Ok(Self { + version: BUNDLE_VERSION, + recipient, + identity_ciphertext: STANDARD.encode(ciphertext), + records: BTreeMap::new(), + }) + } + + /// Encrypt a client private key with the public recipient only; no password is required. + pub fn encrypt_record( + &mut self, + label: PublicKey, + secret: &WireGuardPrivateKey, + ) -> Result<(), VaultError> { + self.encrypt_record_with_status(label, secret, false) + } + + /// Encrypt a key and bind its conflict status inside the authenticated payload. + pub(crate) fn encrypt_record_with_status( + &mut self, + label: PublicKey, + secret: &WireGuardPrivateKey, + reissue_blocked: bool, + ) -> Result<(), VaultError> { + self.validate_shape()?; + if secret.public_key() != label { + return Err(VaultError::RecordMismatch); + } + let payload = RecordPayload { + version: 1, + label: label.to_string(), + private_key: secret.to_base64(), + client_addresses: Vec::new(), + interface_dns: None, + interface_mtu: None, + server_public_key: None, + endpoint: None, + allowed_ips: Vec::new(), + persistent_keepalive: None, + preshared_key: None, + reissue_blocked, + }; + self.encrypt_payload(label, payload, true) + } + + /// Return the unauthenticated display snapshot; retrieval checks encrypted status instead. + pub fn reissue_blocked(&self, label: PublicKey) -> Result { + self.records + .get(&label.to_string()) + .map(|record| record.reissue_blocked) + .ok_or(VaultError::MissingRecord) + } + + pub fn contains_record(&self, label: PublicKey) -> bool { + self.records.contains_key(&label.to_string()) + } + + /// Retrieve a single record, freshly unlocking and verifying the identity on every call. + pub fn retrieve_record( + &self, + label: PublicKey, + password: &str, + ) -> Result { + self.retrieve_client_record(label, password)? + .into_private_key() + } + + /// Retrieve all retained connection parameters with fresh password authorization. + pub fn retrieve_client_record( + &self, + label: PublicKey, + password: &str, + ) -> Result { + self.validate_shape()?; + if password.is_empty() { + return Err(VaultError::InvalidInput); + } + let encoded_identity = STANDARD + .decode(&self.identity_ciphertext) + .map_err(|_| VaultError::CorruptVault)?; + let password_identity = + age::scrypt::Identity::new(SecretString::new(password.to_owned().into())); + let identity_bytes = Zeroizing::new( + age::decrypt(&password_identity, &encoded_identity) + .map_err(|_| VaultError::UnlockFailed)?, + ); + let identity_text = + std::str::from_utf8(&identity_bytes).map_err(|_| VaultError::CorruptVault)?; + let identity = + age::x25519::Identity::from_str(identity_text).map_err(|_| VaultError::CorruptVault)?; + if identity.to_public().to_string() != self.recipient { + return Err(VaultError::RecipientMismatch); + } + let record = self + .records + .get(&label.to_string()) + .ok_or(VaultError::MissingRecord)?; + let ciphertext = STANDARD + .decode(&record.ciphertext) + .map_err(|_| VaultError::CorruptRecord)?; + let plaintext = + age::decrypt(&identity, &ciphertext).map_err(|_| VaultError::CorruptRecord)?; + let plaintext = Zeroizing::new(plaintext); + let text = std::str::from_utf8(&plaintext).map_err(|_| VaultError::CorruptRecord)?; + let payload: RecordPayload = toml::from_str(text).map_err(|_| VaultError::CorruptRecord)?; + if payload.version != 1 { + return Err(VaultError::UnsupportedVersion); + } + if payload.label != label.to_string() { + return Err(VaultError::RecordMismatch); + } + if payload.reissue_blocked { + return Err(VaultError::ReissueBlocked); + } + let secret = WireGuardPrivateKey::parse_base64(&payload.private_key) + .map_err(|_| VaultError::RecordMismatch)?; + if secret.public_key() != label { + return Err(VaultError::RecordMismatch); + } + Ok(RetrievedClientRecord { + private_key: Some(secret), + client_addresses: payload.client_addresses.clone(), + interface_dns: payload.interface_dns.clone(), + interface_mtu: payload.interface_mtu.clone(), + server_public_key: payload.server_public_key.clone(), + endpoint: payload.endpoint.clone(), + allowed_ips: payload.allowed_ips.clone(), + persistent_keepalive: payload.persistent_keepalive.clone(), + preshared_key: payload.preshared_key.clone(), + }) + } + + /// Serialize encrypted bundle contents for persistence or backup without unlocking. + pub fn to_toml(&self) -> Result { + self.validate_shape()?; + let encoded = toml::to_string(self).map_err(|_| VaultError::Serialization)?; + if encoded.len() > MAX_BUNDLE_BYTES { + return Err(VaultError::TooLarge); + } + Ok(encoded) + } + + /// Load bounded serialized contents without requesting the password. + pub fn from_toml(encoded: &str) -> Result { + if encoded.len() > MAX_BUNDLE_BYTES { + return Err(VaultError::TooLarge); + } + let bundle: Self = toml::from_str(encoded).map_err(|_| VaultError::CorruptVault)?; + if bundle.version != BUNDLE_VERSION { + return Err(VaultError::UnsupportedVersion); + } + bundle.validate_shape()?; + Ok(bundle) + } + + pub fn recipient(&self) -> &str { + &self.recipient + } + + pub fn record_count(&self) -> usize { + self.records.len() + } + + fn encrypt_payload( + &mut self, + label: PublicKey, + payload: RecordPayload, + reject_duplicate: bool, + ) -> Result<(), VaultError> { + self.validate_shape()?; + let key = label.to_string(); + if reject_duplicate && self.records.contains_key(&key) { + return Err(VaultError::DuplicateRecord); + } + if !self.records.contains_key(&key) && self.records.len() >= MAX_RECORDS { + return Err(VaultError::TooManyRecords); + } + let plaintext = + Zeroizing::new(toml::to_string(&payload).map_err(|_| VaultError::Serialization)?); + let recipient = age::x25519::Recipient::from_str(&self.recipient) + .map_err(|_| VaultError::CorruptVault)?; + let ciphertext = + age::encrypt(&recipient, plaintext.as_bytes()).map_err(|_| VaultError::Crypto)?; + if ciphertext.len() > MAX_RECORD_CIPHERTEXT_BYTES { + return Err(VaultError::TooLarge); + } + self.records.insert( + key, + VaultRecord { + ciphertext: STANDARD.encode(ciphertext), + // Advisory only. Retrieval trusts the authenticated encrypted payload. + reissue_blocked: payload.reissue_blocked, + }, + ); + Ok(()) + } + + fn validate_shape(&self) -> Result<(), VaultError> { + if self.version != BUNDLE_VERSION { + return Err(VaultError::UnsupportedVersion); + } + if self.records.len() > MAX_RECORDS + || age::x25519::Recipient::from_str(&self.recipient).is_err() + { + return Err(VaultError::CorruptVault); + } + let identity_ciphertext = STANDARD + .decode(&self.identity_ciphertext) + .map_err(|_| VaultError::CorruptVault)?; + if identity_ciphertext.len() > MAX_IDENTITY_CIPHERTEXT_BYTES + || STANDARD.encode(&identity_ciphertext) != self.identity_ciphertext + || age::Decryptor::new(identity_ciphertext.as_slice()).is_err() + { + return Err(VaultError::CorruptVault); + } + for (label, record) in &self.records { + let parsed = label + .parse::() + .map_err(|_| VaultError::CorruptVault)?; + let ciphertext = STANDARD + .decode(&record.ciphertext) + .map_err(|_| VaultError::CorruptRecord)?; + if parsed.to_string() != *label + || ciphertext.len() > MAX_RECORD_CIPHERTEXT_BYTES + || STANDARD.encode(&ciphertext) != record.ciphertext + || age::Decryptor::new(ciphertext.as_slice()).is_err() + { + return Err(VaultError::CorruptRecord); + } + } + Ok(()) + } +} + +/// Secret-safe errors for vault operations. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum VaultError { + #[error("vault input is invalid")] + InvalidInput, + #[error("cryptographic operation failed")] + Crypto, + #[error("vault serialization failed")] + Serialization, + #[error("vault data is corrupt or malformed")] + CorruptVault, + #[error("vault record is corrupt or malformed")] + CorruptRecord, + #[error("vault version is not supported")] + UnsupportedVersion, + #[error("vault data exceeds a size limit")] + TooLarge, + #[error("vault contains too many records")] + TooManyRecords, + #[error("vault identity could not be unlocked")] + UnlockFailed, + #[error("vault recipient does not match its protected identity")] + RecipientMismatch, + #[error("vault record does not match its public-key label")] + RecordMismatch, + #[error("vault record is missing")] + MissingRecord, + #[error("vault already contains a record for this public key")] + DuplicateRecord, + #[error("client reissue is blocked pending reconciliation")] + ReissueBlocked, +} + +/// Recoverable errors from private-key generation and parsing. +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum KeyError { + #[error("operating system random source is unavailable")] + RandomUnavailable, + #[error("private key must be canonical standard Base64 encoding of exactly 32 bytes")] + InvalidPrivateKey, +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD; + + use super::{KeyError, RecordPayload, VaultBundle, VaultError, WireGuardPrivateKey}; + use crate::domain::PublicKey; + + const PASSWORD: &str = "synthetic vault passphrase for tests"; + + fn prepared() -> (VaultBundle, WireGuardPrivateKey) { + let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed"); + let key = WireGuardPrivateKey::generate().expect("OS randomness should be available"); + bundle + .encrypt_record(key.public_key(), &key) + .expect("recipient encryption must not need a password"); + (bundle, key) + } + + #[test] + fn generated_keys_derive_matching_public_identities() { + let first = WireGuardPrivateKey::generate().expect("OS randomness should be available"); + let second = WireGuardPrivateKey::generate().expect("OS randomness should be available"); + + let first_public = first.public_key(); + let second_public = second.public_key(); + assert!(first.matches_public_key(&first_public)); + assert!(!first.matches_public_key(&second_public)); + assert_ne!(first_public, second_public); + } + + #[test] + fn explicit_base64_round_trip_preserves_key_identity() { + let original = WireGuardPrivateKey::generate().expect("OS randomness should be available"); + let public = original.public_key(); + let encoded = original.to_base64(); + assert_eq!(encoded.len(), 44); + + let imported = WireGuardPrivateKey::parse_base64(&encoded) + .expect("generated key encoding should parse canonically"); + assert!(imported.matches_public_key(&public)); + } + + #[test] + fn malformed_private_key_encodings_are_rejected() { + let short = STANDARD.encode([0_u8; 31]); + let full = STANDARD.encode([0_u8; 32]); + let without_padding = full.trim_end_matches('=').to_owned(); + let invalid_after_decoded_prefix = format!("{}!", &full[..43]); + + assert_eq!( + WireGuardPrivateKey::parse_base64(&short).err(), + Some(KeyError::InvalidPrivateKey) + ); + assert_eq!( + WireGuardPrivateKey::parse_base64(&without_padding).err(), + Some(KeyError::InvalidPrivateKey) + ); + assert_eq!( + WireGuardPrivateKey::parse_base64("not base64").err(), + Some(KeyError::InvalidPrivateKey) + ); + assert_eq!( + WireGuardPrivateKey::parse_base64(&invalid_after_decoded_prefix).err(), + Some(KeyError::InvalidPrivateKey) + ); + } + + #[test] + fn encrypted_bundle_round_trips_without_unlocking_and_retrieves_with_password() { + let (bundle, key) = prepared(); + let serialized = bundle.to_toml().expect("bundle should serialize"); + assert!(!serialized.contains(&key.to_base64())); + assert!(!serialized.contains(PASSWORD)); + + let loaded = VaultBundle::from_toml(&serialized).expect("bundle loads without password"); + assert_eq!(loaded.record_count(), 1); + assert_eq!(loaded.recipient(), bundle.recipient()); + let retrieved = loaded + .retrieve_record(key.public_key(), PASSWORD) + .expect("correct password should retrieve matching secret"); + assert!(retrieved.matches_public_key(&key.public_key())); + } + + #[test] + fn every_retrieval_requires_the_correct_password() { + let (bundle, key) = prepared(); + assert_eq!( + bundle + .retrieve_record(key.public_key(), "wrong password") + .err(), + Some(VaultError::UnlockFailed) + ); + assert_eq!( + bundle.retrieve_record(key.public_key(), "").err(), + Some(VaultError::InvalidInput) + ); + assert!( + bundle + .retrieve_record(key.public_key(), PASSWORD) + .expect("fresh correct authorization should work") + .matches_public_key(&key.public_key()) + ); + } + + #[test] + fn malformed_truncated_and_unknown_version_data_are_rejected() { + let (bundle, key) = prepared(); + let serialized = bundle.to_toml().expect("bundle should serialize"); + assert_eq!( + VaultBundle::from_toml(&serialized[..serialized.len() / 2]).err(), + Some(VaultError::CorruptVault) + ); + assert_eq!( + VaultBundle::from_toml(&serialized.replace("version = 1", "version = 9")).err(), + Some(VaultError::UnsupportedVersion) + ); + + let mut corrupt_record = bundle; + let record = corrupt_record + .records + .get_mut(&key.public_key().to_string()) + .expect("record exists"); + let mut bytes = STANDARD + .decode(&record.ciphertext) + .expect("ciphertext is base64"); + bytes.truncate(bytes.len() / 2); + record.ciphertext = STANDARD.encode(bytes); + assert_eq!( + corrupt_record + .retrieve_record(key.public_key(), PASSWORD) + .err(), + Some(VaultError::CorruptRecord) + ); + } + + #[test] + fn protected_identity_detects_corruption_and_recipient_replacement() { + let (mut bundle, key) = prepared(); + let mut identity_ciphertext = STANDARD + .decode(&bundle.identity_ciphertext) + .expect("identity ciphertext is base64"); + identity_ciphertext.truncate(identity_ciphertext.len() / 2); + bundle.identity_ciphertext = STANDARD.encode(identity_ciphertext); + assert!(matches!( + bundle.retrieve_record(key.public_key(), PASSWORD), + Err(VaultError::UnlockFailed | VaultError::CorruptVault) + )); + + let (mut bundle, key) = prepared(); + let different_identity = age::x25519::Identity::generate(); + bundle.recipient = different_identity.to_public().to_string(); + assert_eq!( + bundle.retrieve_record(key.public_key(), PASSWORD).err(), + Some(VaultError::RecipientMismatch) + ); + } + + #[test] + fn relabelled_record_is_rejected_and_authenticated_conflict_blocks_reissue() { + let (mut bundle, key) = prepared(); + let other_label = WireGuardPrivateKey::generate() + .expect("OS randomness should be available") + .public_key(); + let record = bundle + .records + .remove(&key.public_key().to_string()) + .expect("record exists"); + bundle.records.insert(other_label.to_string(), record); + assert_eq!( + bundle.retrieve_record(other_label, PASSWORD).err(), + Some(VaultError::RecordMismatch) + ); + + let key = WireGuardPrivateKey::generate().expect("OS randomness should be available"); + let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed"); + bundle + .encrypt_record_with_status(key.public_key(), &key, true) + .expect("blocked record should encrypt"); + assert_eq!( + bundle.retrieve_record(key.public_key(), PASSWORD).err(), + Some(VaultError::ReissueBlocked) + ); + let serialized = bundle + .to_toml() + .expect("blocked record should serialize") + .replace("reissue_blocked = true", "reissue_blocked = false"); + let changed = VaultBundle::from_toml(&serialized).expect("public status is advisory"); + assert_eq!(changed.reissue_blocked(key.public_key()), Ok(false)); + assert_eq!( + changed.retrieve_record(key.public_key(), PASSWORD).err(), + Some(VaultError::ReissueBlocked) + ); + } + + #[test] + fn authenticated_record_with_unsupported_version_has_specific_error() { + let mut bundle = VaultBundle::create(PASSWORD).expect("vault setup should succeed"); + let key = WireGuardPrivateKey::generate().expect("OS randomness should be available"); + let label = key.public_key(); + bundle + .encrypt_payload( + label, + RecordPayload { + version: 2, + label: label.to_string(), + private_key: key.to_base64(), + client_addresses: Vec::new(), + interface_dns: None, + interface_mtu: None, + server_public_key: None, + endpoint: None, + allowed_ips: Vec::new(), + persistent_keepalive: None, + preshared_key: None, + reissue_blocked: false, + }, + true, + ) + .expect("test record should be encrypted with the normal age path"); + + assert_eq!( + bundle.retrieve_record(label, PASSWORD).err(), + Some(VaultError::UnsupportedVersion) + ); + } + + #[test] + fn vault_debug_and_errors_do_not_include_secret_material() { + let (bundle, key) = prepared(); + let debug = format!("{bundle:?}"); + assert!(!debug.contains(PASSWORD)); + assert!(!debug.contains(&key.to_base64())); + let error = VaultError::UnlockFailed.to_string(); + assert!(!error.contains(PASSWORD)); + assert!(!error.contains(&key.to_base64())); + + let unknown = PublicKey::from_bytes([0xA5; 32]); + assert_eq!( + bundle.retrieve_record(unknown, PASSWORD).err(), + Some(VaultError::MissingRecord) + ); + } +} diff --git a/src/vault/import.rs b/src/vault/import.rs new file mode 100644 index 0000000..5f40a87 --- /dev/null +++ b/src/vault/import.rs @@ -0,0 +1,815 @@ +//! Caller-buffer parsing and reconciliation for existing WireGuard client files. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::str::FromStr; + +use ipnet::IpNet; +use zeroize::Zeroizing; + +use crate::domain::PublicKey; +use crate::wireguard::{ConfigDocument, MetadataAttachment, PeerIssue, SectionKind}; + +use super::{RecordPayload, VaultBundle, VaultError, WireGuardPrivateKey}; + +/// Secret-free outcome of parsing a supplied client configuration. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClientFileIssue { + InvalidEncoding, + InvalidStructure, + MissingPrivateKey, + AmbiguousPrivateKey, + InvalidPrivateKey, + MissingAddress, + InvalidAddress, + AmbiguousAddress, + MissingServerKey, + AmbiguousServerKey, + InvalidServerKey, + AmbiguousPresharedKey, + InvalidPresharedKey, +} + +/// Parsed client parameters. Secret-bearing fields are intentionally not formatted. +pub struct ParsedClientConfig { + private_key: WireGuardPrivateKey, + preshared_key: Option>, + pub identity: PublicKey, + pub addresses: Vec, + address_text: Vec, + pub dns: Option, + pub mtu: Option, + pub server_public_key: PublicKey, + pub endpoint: Option, + pub allowed_ips: Vec, + allowed_ips_text: Vec, + pub persistent_keepalive: Option, + pub unsupported_address_shape: bool, +} + +impl fmt::Debug for ParsedClientConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ParsedClientConfig") + .field("identity", &self.identity) + .field("addresses", &self.addresses) + .field("server_public_key", &self.server_public_key) + .field("unsupported_address_shape", &self.unsupported_address_shape) + .field("secret_parameters", &"[REDACTED]") + .finish() + } +} + +#[derive(Clone, Copy)] +enum ClientSection { + Interface, + Peer, + Other, +} + +/// Parse a caller-supplied client configuration without reading files or exposing its source. +pub fn parse_client_config(input: &[u8]) -> Result { + let text = std::str::from_utf8(input).map_err(|_| ClientFileIssue::InvalidEncoding)?; + let mut section: Option = None; + let mut interface_count = 0; + let mut peer_count = 0; + let mut private_keys = Vec::>::new(); + let mut psk = Vec::>::new(); + let (mut addresses, mut dns, mut mtu) = (Vec::new(), Vec::new(), Vec::new()); + let (mut server_keys, mut endpoints, mut allowed, mut keepalive) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + + for raw_line in text.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if line.starts_with('[') || line.ends_with(']') { + let Some(name) = line.strip_prefix('[').and_then(|n| n.strip_suffix(']')) else { + return Err(ClientFileIssue::InvalidStructure); + }; + let name = name.trim(); + section = if name.eq_ignore_ascii_case("interface") { + interface_count += 1; + Some(ClientSection::Interface) + } else if name.eq_ignore_ascii_case("peer") { + peer_count += 1; + Some(ClientSection::Peer) + } else { + Some(ClientSection::Other) + }; + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(ClientFileIssue::InvalidStructure); + }; + let key = key.trim(); + let value = value.trim(); + match section { + Some(ClientSection::Interface) => { + if key.eq_ignore_ascii_case("privatekey") { + private_keys.push(Zeroizing::new(value.to_owned())); + } else if key.eq_ignore_ascii_case("address") { + addresses.push(value.to_owned()); + } else if key.eq_ignore_ascii_case("dns") { + dns.push(value.to_owned()); + } else if key.eq_ignore_ascii_case("mtu") { + mtu.push(value.to_owned()); + } // Hooks and unknown fields are never interpreted or executed. + } + Some(ClientSection::Peer) => { + if key.eq_ignore_ascii_case("publickey") { + server_keys.push(value.to_owned()); + } else if key.eq_ignore_ascii_case("presharedkey") { + psk.push(Zeroizing::new(value.to_owned())); + } else if key.eq_ignore_ascii_case("endpoint") { + endpoints.push(value.to_owned()); + } else if key.eq_ignore_ascii_case("allowedips") { + allowed.push(value.to_owned()); + } else if key.eq_ignore_ascii_case("persistentkeepalive") { + keepalive.push(value.to_owned()); + } + } + _ => return Err(ClientFileIssue::InvalidStructure), + } + } + if interface_count != 1 || peer_count != 1 { + return Err(ClientFileIssue::InvalidStructure); + } + let private_text = match private_keys.as_slice() { + [] => return Err(ClientFileIssue::MissingPrivateKey), + [value] => value, + _ => return Err(ClientFileIssue::AmbiguousPrivateKey), + }; + let private_key = WireGuardPrivateKey::parse_base64(private_text) + .map_err(|_| ClientFileIssue::InvalidPrivateKey)?; + let address_text = match addresses.as_slice() { + [] => return Err(ClientFileIssue::MissingAddress), + [value] => value, + _ => return Err(ClientFileIssue::AmbiguousAddress), + }; + let address_items = split_network_list(address_text).ok_or(ClientFileIssue::InvalidAddress)?; + let parsed_addresses = address_items + .iter() + .map(|item| IpNet::from_str(item).map_err(|_| ClientFileIssue::InvalidAddress)) + .collect::, _>>()?; + let dns = single_optional(dns).ok_or(ClientFileIssue::InvalidStructure)?; + let mtu = single_optional(mtu).ok_or(ClientFileIssue::InvalidStructure)?; + let server_key_text = match server_keys.as_slice() { + [] => return Err(ClientFileIssue::MissingServerKey), + [value] => value, + _ => return Err(ClientFileIssue::AmbiguousServerKey), + }; + let server_public_key = + PublicKey::from_str(server_key_text).map_err(|_| ClientFileIssue::InvalidServerKey)?; + let preshared_key = match psk.as_slice() { + [] => None, + [value] => { + WireGuardPrivateKey::parse_base64(value) + .map_err(|_| ClientFileIssue::InvalidPresharedKey)?; + Some(value.clone()) + } + _ => return Err(ClientFileIssue::AmbiguousPresharedKey), + }; + let endpoint = single_optional(endpoints).ok_or(ClientFileIssue::InvalidStructure)?; + let mut allowed_ips_text = Vec::new(); + for value in &allowed { + allowed_ips_text.extend(split_network_list(value).ok_or(ClientFileIssue::InvalidAddress)?); + } + let allowed_ips = allowed_ips_text + .iter() + .map(|item| IpNet::from_str(item).map_err(|_| ClientFileIssue::InvalidAddress)) + .collect::, _>>()?; + let persistent_keepalive = + single_optional(keepalive).ok_or(ClientFileIssue::InvalidStructure)?; + let unsupported_address_shape = parsed_addresses.iter().any(|net| match net { + IpNet::V4(v4) => v4.prefix_len() != 32, + IpNet::V6(v6) => v6.prefix_len() != 128, + }); + + Ok(ParsedClientConfig { + identity: private_key.public_key(), + private_key, + preshared_key, + addresses: parsed_addresses, + address_text: address_items, + dns, + mtu, + server_public_key, + endpoint, + allowed_ips, + allowed_ips_text, + persistent_keepalive, + unsupported_address_shape, + }) +} + +fn single_optional(values: Vec) -> Option> { + match values.as_slice() { + [] => Some(None), + [value] => Some(Some(value.clone())), + _ => None, + } +} + +fn split_network_list(value: &str) -> Option> { + let items = value.split(',').map(str::trim).collect::>(); + if items.is_empty() || items.iter().any(|item| item.is_empty()) { + return None; + } + items + .into_iter() + .map(|item| { + IpNet::from_str(item).ok()?; + Some(item.to_owned()) + }) + .collect() +} + +/// Reconciliation status for one supplied candidate; it contains no source text or secrets. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CandidateStatus { + Invalid(ClientFileIssue), + DuplicateFileIdentity, + AmbiguousServerPeer, + FileOnly, + Matched { + address_discrepancy: bool, + server_identity: ServerIdentityComparison, + missing_metadata: bool, + invalid_metadata: bool, + unsupported_projection: bool, + }, +} + +/// Whether the client file's peer key could be checked against the authoritative server identity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServerIdentityComparison { + Matches, + Mismatch, + Unresolved, +} + +struct Candidate { + display_name: String, + parsed: Result, + status: CandidateStatus, +} + +/// Secret-free reconciliation summary. The display name is never used as identity. +pub struct ImportReport { + candidates: Vec, + server_only: Vec, +} + +impl fmt::Debug for ImportReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ImportReport") + .field("candidate_count", &self.candidates.len()) + .field("server_only", &self.server_only) + .field("details", &"[REDACTED SOURCE DETAILS]") + .finish() + } +} + +impl ImportReport { + pub fn candidate_summaries(&self) -> Vec<(&str, CandidateStatus)> { + self.candidates + .iter() + .map(|item| (item.display_name.as_str(), item.status.clone())) + .collect() + } + + pub fn server_only(&self) -> &[PublicKey] { + &self.server_only + } + + /// Encrypt one valid candidate. Duplicate vault records are rejected without replacement. + pub fn encrypt_candidate( + &self, + bundle: &mut VaultBundle, + candidate_index: usize, + ) -> Result<(), VaultError> { + let candidate = self + .candidates + .get(candidate_index) + .ok_or(VaultError::InvalidInput)?; + let parsed = candidate + .parsed + .as_ref() + .map_err(|_| VaultError::InvalidInput)?; + if matches!( + candidate.status, + CandidateStatus::DuplicateFileIdentity | CandidateStatus::Invalid(_) + ) { + return Err(VaultError::InvalidInput); + } + let blocked = match candidate.status { + CandidateStatus::Matched { + address_discrepancy, + server_identity, + unsupported_projection, + .. + } => { + address_discrepancy + || server_identity != ServerIdentityComparison::Matches + || unsupported_projection + } + CandidateStatus::AmbiguousServerPeer | CandidateStatus::FileOnly => true, + CandidateStatus::Invalid(_) | CandidateStatus::DuplicateFileIdentity => true, + }; + let payload = RecordPayload { + version: 1, + label: parsed.identity.to_string(), + private_key: parsed.private_key.to_base64(), + client_addresses: parsed.address_text.clone(), + interface_dns: parsed.dns.clone(), + interface_mtu: parsed.mtu.clone(), + server_public_key: Some(parsed.server_public_key.to_string()), + endpoint: parsed.endpoint.clone(), + allowed_ips: parsed.allowed_ips_text.clone(), + persistent_keepalive: parsed.persistent_keepalive.clone(), + preshared_key: parsed.preshared_key.as_ref().map(|value| value.to_string()), + reissue_blocked: blocked, + }; + bundle.encrypt_payload(parsed.identity, payload, true) + } +} + +/// Reconcile supplied caller-buffer files with the authoritative server document. +pub fn reconcile( + server: &ConfigDocument, + files: impl IntoIterator)>, +) -> ImportReport { + reconcile_zeroizing( + server, + files + .into_iter() + .map(|(display_name, bytes)| (display_name, Zeroizing::new(bytes))), + ) +} + +pub(crate) fn reconcile_zeroizing( + server: &ConfigDocument, + files: impl IntoIterator>)>, +) -> ImportReport { + let mut candidates = files + .into_iter() + .map(|(display_name, bytes)| Candidate { + display_name, + parsed: parse_client_config(&bytes), + status: CandidateStatus::FileOnly, + }) + .collect::>(); + let mut identity_counts = BTreeMap::::new(); + for candidate in &candidates { + if let Ok(parsed) = &candidate.parsed { + *identity_counts.entry(parsed.identity).or_default() += 1; + } + } + let server_key = interface_public_key(server); + let mut represented = BTreeSet::new(); + for candidate in &mut candidates { + let parsed = match &candidate.parsed { + Ok(parsed) => parsed, + Err(issue) => { + candidate.status = CandidateStatus::Invalid(*issue); + continue; + } + }; + represented.insert(parsed.identity); + if identity_counts + .get(&parsed.identity) + .copied() + .unwrap_or_default() + > 1 + { + candidate.status = CandidateStatus::DuplicateFileIdentity; + continue; + } + let peers = server + .peers() + .iter() + .filter(|peer| peer.public_key_candidates.contains(&parsed.identity)) + .collect::>(); + if peers.is_empty() { + candidate.status = CandidateStatus::FileOnly; + continue; + } + if peers.len() != 1 + || peers[0].identity != Some(parsed.identity) + || peers[0].issues.iter().any(|issue| { + matches!( + issue, + PeerIssue::DuplicatePeerIdentity { .. } + | PeerIssue::DuplicatePublicKeyField { .. } + | PeerIssue::MalformedPublicKey { .. } + ) + }) + { + candidate.status = CandidateStatus::AmbiguousServerPeer; + continue; + } + let peer = peers[0]; + let file_addresses = parsed + .addresses + .iter() + .map(|net| net.addr()) + .collect::>(); + let server_addresses = peer + .allowed_ips + .iter() + .map(|net| net.addr()) + .collect::>(); + let address_discrepancy = file_addresses != server_addresses; + let server_identity = match server_key { + Some(server_key) if server_key == parsed.server_public_key => { + ServerIdentityComparison::Matches + } + Some(_) => ServerIdentityComparison::Mismatch, + None => ServerIdentityComparison::Unresolved, + }; + let missing_metadata = matches!(peer.metadata, MetadataAttachment::Absent); + let invalid_metadata = matches!(peer.metadata, MetadataAttachment::Invalid(_)); + let server_unsupported = peer.issues.iter().any(|issue| { + matches!( + issue, + PeerIssue::MalformedAllowedIp { .. } + | PeerIssue::UnsupportedAllowedIpShape { .. } + | PeerIssue::MissingIpv4HostRoute + ) + }); + candidate.status = CandidateStatus::Matched { + address_discrepancy, + server_identity, + missing_metadata, + invalid_metadata, + unsupported_projection: parsed.unsupported_address_shape || server_unsupported, + }; + } + let server_only = server + .peers() + .iter() + .filter_map(|peer| peer.identity) + .filter(|identity| !represented.contains(identity)) + .collect(); + ImportReport { + candidates, + server_only, + } +} + +fn interface_public_key(document: &ConfigDocument) -> Option { + let interfaces = document + .sections() + .iter() + .filter(|section| section.kind == SectionKind::Interface) + .collect::>(); + if interfaces.len() != 1 { + return None; + } + let text = document.source_slice(interfaces[0].span)?; + let keys = text + .lines() + .filter_map(|line| { + let (key, value) = line.split_once('=')?; + key.trim() + .eq_ignore_ascii_case("PrivateKey") + .then(|| value.trim()) + }) + .collect::>(); + let [private] = keys.as_slice() else { + return None; + }; + WireGuardPrivateKey::parse_base64(private) + .ok() + .map(|key| key.public_key()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::{VaultBundle, VaultError}; + + const PASSWORD: &str = "synthetic passphrase"; + + fn generated() -> (String, String, String) { + let client = WireGuardPrivateKey::generate().unwrap(); + let server = WireGuardPrivateKey::generate().unwrap(); + let psk = WireGuardPrivateKey::generate().unwrap(); + (client.to_base64(), server.to_base64(), psk.to_base64()) + } + + fn client_config(client: &str, server: &str, psk: &str, address: &str) -> String { + let server_public = WireGuardPrivateKey::parse_base64(server) + .unwrap() + .public_key(); + format!( + "[Interface]\nPrivateKey = {client}\nAddress = {address}\nDNS = 1.1.1.1\nMTU = 1380\n[Peer]\nPublicKey = {server_public}\nPresharedKey = {psk}\nEndpoint = example.invalid:51820\nAllowedIPs = 0.0.0.0/0, ::/0\nPersistentKeepalive = 25\n" + ) + } + + fn server_config(server: &str, client_public: &PublicKey, address: &str) -> String { + format!( + "[Interface]\nPrivateKey = {server}\n[Peer]\nPublicKey = {client_public}\nAllowedIPs = {address}\n" + ) + } + + #[test] + fn client_interface_private_key_is_identity_and_peer_public_key_is_server() { + let (client, server, psk) = generated(); + let parsed = + parse_client_config(client_config(&client, &server, &psk, "10.0.0.2/32").as_bytes()) + .unwrap(); + let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap(); + let server_key = WireGuardPrivateKey::parse_base64(&server).unwrap(); + assert_eq!(parsed.identity, client_key.public_key()); + assert_ne!(parsed.identity, server_key.public_key()); + assert_eq!(parsed.server_public_key, server_key.public_key()); + } + + #[test] + fn import_preserves_secrets_and_parameters_only_inside_encrypted_record() { + let (client, server, psk) = generated(); + let private = WireGuardPrivateKey::parse_base64(&client).unwrap(); + let server_private = WireGuardPrivateKey::parse_base64(&server).unwrap(); + let source = client_config(&client, &server, &psk, "10.0.0.2/32, fd00::2/128"); + let parsed = parse_client_config(source.as_bytes()).unwrap(); + assert!(parsed.preshared_key.as_ref().map(|value| value.as_str()) == Some(psk.as_str())); + let server_doc = ConfigDocument::parse(server_config( + &server, + &private.public_key(), + "10.0.0.2/32, fd00::2/128", + )) + .unwrap(); + let report = reconcile( + &server_doc, + [("alias-looking-name.conf".into(), source.as_bytes().to_vec())], + ); + let summaries = report.candidate_summaries(); + assert_eq!( + summaries[0].1, + CandidateStatus::Matched { + address_discrepancy: false, + server_identity: ServerIdentityComparison::Matches, + missing_metadata: true, + invalid_metadata: false, + unsupported_projection: false, + } + ); + let mut bundle = VaultBundle::create(PASSWORD).unwrap(); + report.encrypt_candidate(&mut bundle, 0).unwrap(); + let serialized = bundle.to_toml().unwrap(); + assert!(!serialized.contains(&client)); + assert!(!serialized.contains(&psk)); + assert!(!serialized.contains("example.invalid")); + let retrieved = bundle + .retrieve_client_record(parsed.identity, PASSWORD) + .unwrap(); + assert!(retrieved.preshared_key_base64() == Some(psk.as_str())); + assert_eq!(retrieved.client_addresses, ["10.0.0.2/32", "fd00::2/128"]); + assert_eq!(retrieved.endpoint.as_deref(), Some("example.invalid:51820")); + assert_eq!(retrieved.interface_dns.as_deref(), Some("1.1.1.1")); + assert_eq!(retrieved.interface_mtu.as_deref(), Some("1380")); + assert_eq!(retrieved.allowed_ips, ["0.0.0.0/0", "::/0"]); + let debug = format!("{retrieved:?}"); + for raw_value in [ + psk.as_str(), + "10.0.0.2/32", + "1.1.1.1", + "1380", + "example.invalid:51820", + "0.0.0.0/0", + "25", + ] { + assert!(!debug.contains(raw_value)); + } + assert!(debug.contains("[REDACTED]")); + let _ = server_private; + } + + #[test] + fn conflicts_invalid_duplicates_and_server_only_are_reported_and_blocked() { + let (client, server, psk) = generated(); + let key = WireGuardPrivateKey::parse_base64(&client).unwrap(); + let server_private = WireGuardPrivateKey::parse_base64(&server).unwrap(); + let file = client_config(&client, &server, &psk, "10.0.0.9/32"); + let doc = ConfigDocument::parse(server_config(&server, &key.public_key(), "10.0.0.2/32")) + .unwrap(); + let report = reconcile( + &doc, + [ + ("one.conf".into(), file.as_bytes().to_vec()), + ("two.conf".into(), file.as_bytes().to_vec()), + ( + "bad.conf".into(), + b"[Interface]\nPrivateKey = nope\n".to_vec(), + ), + ], + ); + let statuses = report.candidate_summaries(); + assert_eq!(statuses[0].1, CandidateStatus::DuplicateFileIdentity); + assert_eq!(statuses[1].1, CandidateStatus::DuplicateFileIdentity); + assert!(matches!(statuses[2].1, CandidateStatus::Invalid(_))); + assert!(report.server_only().is_empty()); + + let single = reconcile(&doc, [("file.conf".into(), file.as_bytes().to_vec())]); + assert_eq!( + single.candidate_summaries()[0].1, + CandidateStatus::Matched { + address_discrepancy: true, + server_identity: ServerIdentityComparison::Matches, + missing_metadata: true, + invalid_metadata: false, + unsupported_projection: false, + } + ); + let mut bundle = VaultBundle::create(PASSWORD).unwrap(); + single.encrypt_candidate(&mut bundle, 0).unwrap(); + assert_eq!(bundle.reissue_blocked(key.public_key()), Ok(true)); + assert_eq!( + bundle.retrieve_record(key.public_key(), PASSWORD).err(), + Some(VaultError::ReissueBlocked) + ); + + let empty_doc = + ConfigDocument::parse(format!("[Interface]\nPrivateKey = {server}\n")).unwrap(); + let file_only = reconcile(&empty_doc, [("file.conf".into(), file.as_bytes().to_vec())]); + assert_eq!( + file_only.candidate_summaries()[0].1, + CandidateStatus::FileOnly + ); + let mut bundle = VaultBundle::create(PASSWORD).unwrap(); + file_only.encrypt_candidate(&mut bundle, 0).unwrap(); + assert!(bundle.reissue_blocked(key.public_key()).unwrap()); + + let unused_key = server_private.public_key(); + assert!(!report.server_only().contains(&unused_key)); + } + + #[test] + fn mutable_public_gate_cannot_authorize_reissue_and_duplicates_never_overwrite() { + let (client, server, psk) = generated(); + let key = WireGuardPrivateKey::parse_base64(&client).unwrap(); + let doc = ConfigDocument::parse(server_config(&server, &key.public_key(), "10.0.0.2/32")) + .unwrap(); + let report = reconcile( + &doc, + [( + "file.conf".into(), + client_config(&client, &server, &psk, "10.0.0.3/32").into_bytes(), + )], + ); + let mut bundle = VaultBundle::create(PASSWORD).unwrap(); + report.encrypt_candidate(&mut bundle, 0).unwrap(); + let before = bundle.to_toml().unwrap(); + assert_eq!( + bundle.encrypt_record(key.public_key(), &key), + Err(VaultError::DuplicateRecord) + ); + let mut changed = before.replace("reissue_blocked = true", "reissue_blocked = false"); + let loaded = VaultBundle::from_toml(&changed).unwrap(); + assert_eq!(loaded.reissue_blocked(key.public_key()), Ok(false)); + assert_eq!( + loaded.retrieve_record(key.public_key(), PASSWORD).err(), + Some(VaultError::ReissueBlocked) + ); + changed.clear(); + } + + #[test] + fn parse_and_report_debug_are_redacted() { + let (client, server, psk) = generated(); + let source = format!( + "{}PrivateKey = SYNTHETIC-MISPLACED-PRIVATE-VALUE\n", + client_config(&client, &server, &psk, "10.0.0.2/32") + ); + let parsed = parse_client_config(source.as_bytes()).unwrap(); + let debug = format!("{parsed:?}"); + assert!(!debug.contains(&client)); + assert!(!debug.contains(&psk)); + assert!(!debug.contains("SYNTHETIC-MISPLACED-PRIVATE-VALUE")); + + let malformed = + format!("{source}[Unknown]\nCredential = SYNTHETIC-REJECTED-SECRET-VALUE\n"); + let error = parse_client_config(malformed.as_bytes()).unwrap_err(); + assert_eq!(error, ClientFileIssue::InvalidStructure); + assert!(!format!("{error:?}").contains("SYNTHETIC-REJECTED-SECRET-VALUE")); + + let invalid_psk = client_config( + &client, + &server, + "SYNTHETIC-INVALID-PSK-VALUE", + "10.0.0.2/32", + ); + let error = parse_client_config(invalid_psk.as_bytes()).unwrap_err(); + assert_eq!(error, ClientFileIssue::InvalidPresharedKey); + assert!(!format!("{error:?}").contains("SYNTHETIC-INVALID-PSK-VALUE")); + + let report_debug = format!( + "{:?}", + reconcile( + &ConfigDocument::parse("[Interface]\n").unwrap(), + [("file".into(), malformed.into_bytes())] + ) + ); + assert!(!report_debug.contains("SYNTHETIC-MISPLACED-PRIVATE-VALUE")); + assert!(!report_debug.contains("SYNTHETIC-REJECTED-SECRET-VALUE")); + } + + #[test] + fn report_separates_server_only_binding_and_projection_anomalies() { + let (client, server, psk) = generated(); + let (_, other_server, _) = generated(); + let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap(); + let unmatched = WireGuardPrivateKey::generate().unwrap().public_key(); + let config = format!( + "[Interface]\nPrivateKey = {server}\n# wg-admin:begin\ninvalid metadata\n# wg-admin:end\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.0.0.2/32\n[Peer]\nPublicKey = {unmatched}\nAllowedIPs = 10.9.0.1/24\n", + client_key.public_key() + ); + let client_file = client_config(&client, &other_server, &psk, "10.0.0.2/24"); + let report = reconcile( + &ConfigDocument::parse(config).unwrap(), + [("candidate.conf".into(), client_file.into_bytes())], + ); + assert_eq!(report.server_only(), &[unmatched]); + assert_eq!( + report.candidate_summaries()[0].1, + CandidateStatus::Matched { + address_discrepancy: false, + server_identity: ServerIdentityComparison::Mismatch, + missing_metadata: false, + invalid_metadata: true, + unsupported_projection: true, + } + ); + } + + #[test] + fn unresolved_server_identity_is_distinct_and_blocks_reissue() { + let (client, server, psk) = generated(); + let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap(); + let server_doc = ConfigDocument::parse(format!( + "[Interface]\nAddress = 10.0.0.1/24\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.0.0.2/32\n", + client_key.public_key() + )) + .unwrap(); + let file = client_config(&client, &server, &psk, "10.0.0.2/32"); + let report = reconcile(&server_doc, [("client.conf".into(), file.into_bytes())]); + assert_eq!( + report.candidate_summaries()[0].1, + CandidateStatus::Matched { + address_discrepancy: false, + server_identity: ServerIdentityComparison::Unresolved, + missing_metadata: true, + invalid_metadata: false, + unsupported_projection: false, + } + ); + + let mut bundle = VaultBundle::create(PASSWORD).unwrap(); + report.encrypt_candidate(&mut bundle, 0).unwrap(); + assert_eq!(bundle.reissue_blocked(client_key.public_key()), Ok(true)); + assert_eq!( + bundle + .retrieve_record(client_key.public_key(), PASSWORD) + .err(), + Some(VaultError::ReissueBlocked) + ); + } + + #[test] + fn server_key_mismatch_alone_allows_encryption_but_blocks_reissue() { + let (client, server, psk) = generated(); + let (_, other_server, _) = generated(); + let client_key = WireGuardPrivateKey::parse_base64(&client).unwrap(); + let server_doc = ConfigDocument::parse(server_config( + &server, + &client_key.public_key(), + "10.0.0.2/32", + )) + .unwrap(); + let file = client_config(&client, &other_server, &psk, "10.0.0.2/32"); + let report = reconcile(&server_doc, [("client.conf".into(), file.into_bytes())]); + assert_eq!( + report.candidate_summaries()[0].1, + CandidateStatus::Matched { + address_discrepancy: false, + server_identity: ServerIdentityComparison::Mismatch, + missing_metadata: true, + invalid_metadata: false, + unsupported_projection: false, + } + ); + + let mut bundle = VaultBundle::create(PASSWORD).unwrap(); + assert!(report.encrypt_candidate(&mut bundle, 0).is_ok()); + assert_eq!(bundle.record_count(), 1); + assert_eq!(bundle.reissue_blocked(client_key.public_key()), Ok(true)); + assert_eq!( + bundle + .retrieve_record(client_key.public_key(), PASSWORD) + .err(), + Some(VaultError::ReissueBlocked) + ); + } +} diff --git a/src/vault/source.rs b/src/vault/source.rs new file mode 100644 index 0000000..c7ee8b2 --- /dev/null +++ b/src/vault/source.rs @@ -0,0 +1,511 @@ +//! Bounded, read-only access to configured client source files. + +use std::ffi::OsString; +use std::fs::File; +use std::io::{Read, Take}; +use std::path::{Path, PathBuf}; + +use rustix::fd::OwnedFd; +use rustix::fs::{self as rfs, FileType, Mode, OFlags, ResolveFlags}; +use thiserror::Error; + +use crate::config::ResolvedSettings; +use crate::wireguard::ConfigDocument; + +use super::import::{ImportReport, reconcile, reconcile_zeroizing}; +use super::{VaultBundle, VaultError}; +use zeroize::Zeroizing; + +/// Only immediate `*.conf` entries are candidates. Matching is ASCII-case-insensitive. +pub const MAX_CANDIDATE_BYTES: usize = 1024 * 1024; +pub const MAX_SCAN_BYTES: usize = 32 * 1024 * 1024; +pub const MAX_CANDIDATES: usize = 1024; +pub const MAX_DIRECTORY_ENTRIES: usize = 4096; + +/// A source failure safe to show in a UI or log. It never contains I/O error text. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SourceFailureKind { + DirectoryUnavailable, + EnumerationFailed, + TooManyEntries, + FileUnavailable, + NotRegularFile, + FileTooLarge, + ScanTooLarge, + ReadFailed, + EncryptionFailed, + DuplicateRecordEqualityUnverified, +} + +/// Per-file issue with a display-only filename and a redacted failure category. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SourceIssue { + pub file_name: Option, + pub kind: SourceFailureKind, +} + +/// Directory scan and reconciliation result. `scan_complete` is false whenever +/// enumeration or a configured limit prevented a complete inventory. +pub struct SourceImportReport { + pub scan_complete: bool, + pub issues: Vec, + pub reconciliation: ImportReport, +} + +impl std::fmt::Debug for SourceImportReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SourceImportReport") + .field("scan_complete", &self.scan_complete) + .field("issues", &self.issues) + .field("reconciliation", &self.reconciliation) + .finish() + } +} + +/// Bundle prepared for a later explicit persistence/Commit operation. +pub struct PreparedImportSetup { + pub bundle: VaultBundle, + pub report: SourceImportReport, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum SourceImportError { + #[error("vault setup failed")] + Vault(#[from] VaultError), + #[error("source directory is invalid")] + InvalidDirectory, +} + +/// Create a new password-protected vault and encrypt valid candidates from the +/// configured source directory. This only returns prepared in-memory data. +pub fn prepare_initial_setup( + settings: &ResolvedSettings, + server: &ConfigDocument, + vault_password: &str, +) -> Result { + let mut bundle = VaultBundle::create(vault_password)?; + let report = import_directory(&mut bundle, settings, server)?; + Ok(PreparedImportSetup { bundle, report }) +} + +/// Explicitly import configured candidates into an existing prepared bundle. +/// It uses the bundle's public recipient and never unlocks or replaces its identity. +pub fn import_configured_directory( + bundle: &mut VaultBundle, + settings: &ResolvedSettings, + server: &ConfigDocument, +) -> Result { + import_directory(bundle, settings, server) +} + +fn import_directory( + bundle: &mut VaultBundle, + settings: &ResolvedSettings, + server: &ConfigDocument, +) -> Result { + let source = &settings.client_source_directory; + if !source.is_absolute() { + return Err(SourceImportError::InvalidDirectory); + } + + let directory = match open_source_directory(source) { + Ok(fd) => fd, + Err(_) => { + return Ok(SourceImportReport { + scan_complete: false, + issues: vec![SourceIssue { + file_name: None, + kind: SourceFailureKind::DirectoryUnavailable, + }], + reconciliation: reconcile(server, []), + }); + } + }; + + let entries = match rfs::Dir::read_from(&directory) { + Ok(entries) => entries, + Err(_) => { + return Ok(SourceImportReport { + scan_complete: false, + issues: vec![SourceIssue { + file_name: None, + kind: SourceFailureKind::EnumerationFailed, + }], + reconciliation: reconcile(server, []), + }); + } + }; + + Ok(import_open_directory(bundle, server, &directory, entries)) +} + +fn import_open_directory( + bundle: &mut VaultBundle, + server: &ConfigDocument, + directory: &OwnedFd, + mut entries: rfs::Dir, +) -> SourceImportReport { + let entries = std::iter::from_fn(move || { + entries.read().map(|entry| { + entry.map_err(std::io::Error::from).map(|entry| { + use std::os::unix::ffi::OsStrExt; + OsString::from(std::ffi::OsStr::from_bytes(entry.file_name().to_bytes())) + }) + }) + }); + import_entries(bundle, server, directory, entries) +} + +fn import_entries( + bundle: &mut VaultBundle, + server: &ConfigDocument, + directory: &OwnedFd, + entries: impl IntoIterator>, +) -> SourceImportReport { + import_entries_with_limits( + bundle, + server, + directory, + entries, + MAX_SCAN_BYTES, + MAX_DIRECTORY_ENTRIES, + ) +} + +fn import_entries_with_limits( + bundle: &mut VaultBundle, + server: &ConfigDocument, + directory: &OwnedFd, + entries: impl IntoIterator>, + max_scan_bytes: usize, + max_directory_entries: usize, +) -> SourceImportReport { + let mut complete = true; + let mut issues = Vec::new(); + let mut candidates = Vec::new(); + let mut aggregate_bytes = 0usize; + let mut candidate_count = 0usize; + for (examined_entries, entry) in entries.into_iter().enumerate() { + if examined_entries >= max_directory_entries { + complete = false; + issues.push(SourceIssue { + file_name: None, + kind: SourceFailureKind::TooManyEntries, + }); + break; + } + let entry = match entry { + Ok(entry) => entry, + Err(_) => { + complete = false; + issues.push(SourceIssue { + file_name: None, + kind: SourceFailureKind::EnumerationFailed, + }); + continue; + } + }; + let name = entry; + if !is_candidate_name(&name) { + continue; + } + if candidate_count >= MAX_CANDIDATES { + complete = false; + issues.push(SourceIssue { + file_name: None, + kind: SourceFailureKind::TooManyEntries, + }); + break; + } + candidate_count += 1; + let display_name = safe_file_name(&name); + match read_candidate(directory, &name, aggregate_bytes, max_scan_bytes) { + Ok(bytes) => { + aggregate_bytes += bytes.len(); + candidates.push((display_name, bytes)); + } + Err(kind) => { + complete = false; + issues.push(SourceIssue { + file_name: Some(display_name), + kind, + }); + } + } + } + + let reconciliation = reconcile_zeroizing(server, candidates); + let summaries = reconciliation.candidate_summaries(); + for (index, (name, status)) in summaries.iter().enumerate() { + if matches!( + status, + super::import::CandidateStatus::Invalid(_) + | super::import::CandidateStatus::DuplicateFileIdentity + ) { + continue; + } + if let Err(error) = reconciliation.encrypt_candidate(bundle, index) { + complete = false; + issues.push(SourceIssue { + file_name: Some((*name).to_owned()), + kind: match error { + VaultError::DuplicateRecord => { + SourceFailureKind::DuplicateRecordEqualityUnverified + } + _ => SourceFailureKind::EncryptionFailed, + }, + }); + } + } + + SourceImportReport { + scan_complete: complete, + issues, + reconciliation, + } +} + +#[cfg(target_os = "linux")] +fn open_source_directory(path: &Path) -> std::io::Result { + rfs::openat2( + rfs::CWD, + path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC, + Mode::empty(), + ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS, + ) + .map_err(std::io::Error::from) +} + +#[cfg(not(target_os = "linux"))] +fn open_source_directory(_path: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "secure directory opening is unavailable", + )) +} + +fn read_candidate( + directory: &OwnedFd, + name: &std::ffi::OsStr, + aggregate_bytes: usize, + max_scan_bytes: usize, +) -> Result>, SourceFailureKind> { + let file = open_source_file(directory, name).map_err(|_| SourceFailureKind::FileUnavailable)?; + let stat = rfs::fstat(&file).map_err(|_| SourceFailureKind::FileUnavailable)?; + if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile { + return Err(SourceFailureKind::NotRegularFile); + } + let size = usize::try_from(stat.st_size).map_err(|_| SourceFailureKind::FileTooLarge)?; + if size > MAX_CANDIDATE_BYTES { + return Err(SourceFailureKind::FileTooLarge); + } + let remaining_scan_bytes = max_scan_bytes.saturating_sub(aggregate_bytes); + if size > remaining_scan_bytes { + return Err(SourceFailureKind::ScanTooLarge); + } + let mut file = File::from(file); + let mut bytes = Zeroizing::new(Vec::with_capacity(size.min(MAX_CANDIDATE_BYTES))); + let read_limit = MAX_CANDIDATE_BYTES.min(remaining_scan_bytes); + let mut reader: Take<&mut File> = (&mut file).take((read_limit + 1) as u64); + reader + .read_to_end(&mut bytes) + .map_err(|_| SourceFailureKind::ReadFailed)?; + if bytes.len() > MAX_CANDIDATE_BYTES { + return Err(SourceFailureKind::FileTooLarge); + } + if bytes.len() > remaining_scan_bytes { + return Err(SourceFailureKind::ScanTooLarge); + } + Ok(bytes) +} + +#[cfg(target_os = "linux")] +fn open_source_file(directory: &OwnedFd, name: &std::ffi::OsStr) -> std::io::Result { + rfs::openat2( + directory, + PathBuf::from(name), + OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS, + ) + .map_err(std::io::Error::from) +} + +#[cfg(not(target_os = "linux"))] +fn open_source_file(_directory: &OwnedFd, _name: &std::ffi::OsStr) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "secure file opening is unavailable", + )) +} + +fn is_candidate_name(name: &std::ffi::OsStr) -> bool { + Path::new(name) + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("conf")) +} + +fn safe_file_name(name: &std::ffi::OsStr) -> String { + let raw = name.to_string_lossy(); + raw.chars() + .map(|character| { + if character.is_control() { + '\u{fffd}' + } else { + character + } + }) + .take(255) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vault::WireGuardPrivateKey; + use std::fs; + + #[test] + fn entry_enumeration_error_is_visible_and_other_files_still_import() { + let directory = tempfile::tempdir().unwrap(); + let server_key = WireGuardPrivateKey::generate().unwrap(); + let client_key = WireGuardPrivateKey::generate().unwrap(); + let server = ConfigDocument::parse( + format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n", + server_key.to_base64(), + client_key.public_key() + ) + .as_bytes(), + ) + .unwrap(); + let client_bytes = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\n", + client_key.to_base64(), + server_key.public_key() + ); + fs::write(directory.path().join("valid.conf"), &client_bytes).unwrap(); + let dirfd = open_source_directory(directory.path()).unwrap(); + let entries = [ + Ok(OsString::from("valid.conf")), + Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)), + ]; + let mut bundle = VaultBundle::create("password").unwrap(); + let report = import_entries(&mut bundle, &server, &dirfd, entries); + assert!(!report.scan_complete); + assert_eq!(bundle.record_count(), 1); + assert!( + report + .issues + .iter() + .any(|issue| issue.kind == SourceFailureKind::EnumerationFailed) + ); + assert!(fs::read(directory.path().join("valid.conf")).unwrap() == client_bytes.as_bytes()); + } + + #[test] + fn enumeration_and_reads_stay_pinned_after_source_path_is_replaced() { + let parent = tempfile::tempdir().unwrap(); + let source = parent.path().join("source"); + let pinned_path = parent.path().join("pinned"); + fs::create_dir(&source).unwrap(); + let server_key = WireGuardPrivateKey::generate().unwrap(); + let client_key = WireGuardPrivateKey::generate().unwrap(); + let decoy_key = WireGuardPrivateKey::generate().unwrap(); + let server = ConfigDocument::parse(format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n", + server_key.to_base64(), client_key.public_key() + )) + .unwrap(); + let bytes = format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\n", + client_key.to_base64(), + server_key.public_key() + ); + fs::write(source.join("client.conf"), &bytes).unwrap(); + + let directory = open_source_directory(&source).unwrap(); + fs::rename(&source, &pinned_path).unwrap(); + fs::create_dir(&source).unwrap(); + fs::write( + source.join("replacement.conf"), + format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.3/32\n\n[Peer]\nPublicKey = {}\n", + decoy_key.to_base64(), + server_key.public_key() + ), + ) + .unwrap(); + + let entries = rfs::Dir::read_from(&directory).unwrap(); + let mut bundle = VaultBundle::create("password").unwrap(); + let report = import_open_directory(&mut bundle, &server, &directory, entries); + + assert!(report.scan_complete); + assert_eq!(bundle.record_count(), 1); + assert!(report.reconciliation.server_only().is_empty()); + assert_eq!( + report.reconciliation.candidate_summaries()[0].1, + super::super::import::CandidateStatus::Matched { + address_discrepancy: false, + server_identity: super::super::import::ServerIdentityComparison::Matches, + missing_metadata: true, + invalid_metadata: false, + unsupported_projection: false, + } + ); + assert!(fs::read(pinned_path.join("client.conf")).unwrap() == bytes.as_bytes()); + assert!(fs::read_dir(&source).unwrap().next().is_some()); + } + + #[test] + fn aggregate_scan_byte_limit_is_enforced_after_successful_reads() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("one.conf"), b"one").unwrap(); + fs::write(directory.path().join("two.conf"), b"two").unwrap(); + let server = ConfigDocument::parse("[Interface]\n").unwrap(); + let dirfd = open_source_directory(directory.path()).unwrap(); + let entries = [ + Ok(OsString::from("one.conf")), + Ok(OsString::from("two.conf")), + ]; + let mut bundle = VaultBundle::create("password").unwrap(); + + let report = import_entries_with_limits(&mut bundle, &server, &dirfd, entries, 4, 10); + + assert!(!report.scan_complete); + assert_eq!( + report.issues, + [SourceIssue { + file_name: Some("two.conf".to_owned()), + kind: SourceFailureKind::ScanTooLarge, + }] + ); + } + + #[test] + fn directory_entry_limit_marks_inventory_incomplete() { + let directory = tempfile::tempdir().unwrap(); + let dirfd = open_source_directory(directory.path()).unwrap(); + let server = ConfigDocument::parse("[Interface]\n").unwrap(); + let entries = [ + Ok(OsString::from("one.txt")), + Ok(OsString::from("two.txt")), + Ok(OsString::from("three.txt")), + ]; + let mut bundle = VaultBundle::create("password").unwrap(); + + let report = import_entries_with_limits(&mut bundle, &server, &dirfd, entries, 4, 2); + + assert!(!report.scan_complete); + assert_eq!( + report.issues, + [SourceIssue { + file_name: None, + kind: SourceFailureKind::TooManyEntries, + }] + ); + } +} diff --git a/tests/vault_import.rs b/tests/vault_import.rs new file mode 100644 index 0000000..4220509 --- /dev/null +++ b/tests/vault_import.rs @@ -0,0 +1,233 @@ +use std::fs; +use std::path::Path; + +use wireguard_admin::config::{ResolvedSettings, SettingsFile}; +use wireguard_admin::domain::PublicKey; +use wireguard_admin::vault::import::{CandidateStatus, parse_client_config}; +use wireguard_admin::vault::source::{ + MAX_CANDIDATE_BYTES, SourceFailureKind, import_configured_directory, prepare_initial_setup, +}; +use wireguard_admin::vault::{VaultBundle, WireGuardPrivateKey}; +use wireguard_admin::wireguard::ConfigDocument; + +fn settings(source: &Path) -> ResolvedSettings { + let raw = format!( + r#" +interface_name = "wg0" +server_config_path = "server.conf" +client_source_directory = {:?} +server_endpoint = "vpn.example:51820" +ipv4_allocation_range = "10.8.0.0/24" +ipv6_allocation_range = "fd00::/64" +dns_addresses = ["10.8.0.1"] +systemd_unit = "wg-quick@wg0.service" +restart_timeout_seconds = 30 +"#, + source.display().to_string() + ); + SettingsFile::parse_toml(&raw) + .unwrap() + .validate() + .unwrap() + .resolve_paths(Path::new("/synthetic/app")) +} + +fn server_and_client() -> (ConfigDocument, WireGuardPrivateKey, PublicKey) { + let server_key = WireGuardPrivateKey::generate().unwrap(); + let client_key = WireGuardPrivateKey::generate().unwrap(); + let client_public = client_key.public_key(); + let server = ConfigDocument::parse( + format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.1/24\n\n[Peer]\nPublicKey = {}\nAllowedIPs = 10.8.0.2/32\n# admin note\n", + server_key.to_base64(), + client_public + ) + .as_bytes(), + ) + .unwrap(); + (server, client_key, client_public) +} + +fn client_file(client: &WireGuardPrivateKey, server: &ConfigDocument) -> Vec { + let server_key = server + .source_slice(server.sections()[0].span) + .unwrap() + .lines() + .find_map(|line| line.strip_prefix("PrivateKey = ")) + .map(|value| { + WireGuardPrivateKey::parse_base64(value) + .unwrap() + .public_key() + }) + .unwrap(); + format!( + "[Interface]\nPrivateKey = {}\nAddress = 10.8.0.2/32\n\n[Peer]\nPublicKey = {}\nEndpoint = vpn.example:51820\nAllowedIPs = 0.0.0.0/0, ::/0\n", + client.to_base64(), server_key + ) + .into_bytes() +} + +#[test] +fn initial_setup_encrypts_valid_files_and_leaves_sources_and_server_unchanged() { + let directory = tempfile::tempdir().unwrap(); + let (server, client, identity) = server_and_client(); + let original_server = server.render(); + let source_bytes = client_file(&client, &server); + fs::write(directory.path().join("laptop.conf"), &source_bytes).unwrap(); + fs::write( + directory.path().join("broken.conf"), + b"not a WireGuard file", + ) + .unwrap(); + fs::write(directory.path().join("ignored.txt"), &source_bytes).unwrap(); + fs::create_dir(directory.path().join("nested")).unwrap(); + fs::write(directory.path().join("nested/server.conf"), &source_bytes).unwrap(); + + let prepared = + prepare_initial_setup(&settings(directory.path()), &server, "setup password").unwrap(); + assert!(prepared.report.scan_complete); + assert_eq!(prepared.bundle.record_count(), 1); + let statuses = prepared.report.reconciliation.candidate_summaries(); + assert!(statuses.iter().any(|(_, status)| matches!( + status, + CandidateStatus::Matched { + address_discrepancy: false, + server_identity: wireguard_admin::vault::import::ServerIdentityComparison::Matches, + missing_metadata: true, + invalid_metadata: false, + unsupported_projection: false, + } + ))); + assert!( + statuses + .iter() + .any(|(_, status)| matches!(status, CandidateStatus::Invalid(_))) + ); + let encoded = prepared.bundle.to_toml().unwrap(); + assert!(!encoded.contains(&client.to_base64())); + let loaded = VaultBundle::from_toml(&encoded).unwrap(); + assert_eq!(loaded.record_count(), 1); + assert!(loaded.contains_record(identity)); + assert_eq!( + loaded + .retrieve_record(identity, "setup password") + .unwrap() + .public_key(), + identity + ); + assert!(loaded.retrieve_record(identity, "wrong password").is_err()); + assert!(fs::read(directory.path().join("laptop.conf")).unwrap() == source_bytes); + assert_eq!( + fs::read(directory.path().join("broken.conf")).unwrap(), + b"not a WireGuard file" + ); + assert!(server.render() == original_server); +} + +#[test] +fn symlinks_and_oversized_candidates_are_reported_without_import() { + let directory = tempfile::tempdir().unwrap(); + let external = tempfile::NamedTempFile::new().unwrap(); + fs::write(external.path(), b"outside source root").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(external.path(), directory.path().join("linked.conf")).unwrap(); + fs::write( + directory.path().join("large.conf"), + vec![b'x'; MAX_CANDIDATE_BYTES + 1], + ) + .unwrap(); + let (server, _, _) = server_and_client(); + let mut bundle = VaultBundle::create("password").unwrap(); + let report = + import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap(); + assert_eq!(bundle.record_count(), 0); + assert!(!report.scan_complete); + assert!(report.issues.iter().any(|issue| { + issue.kind == SourceFailureKind::FileTooLarge + && issue.file_name.as_deref() == Some("large.conf") + })); + #[cfg(unix)] + assert!(report.issues.iter().any(|issue| { + issue.kind == SourceFailureKind::FileUnavailable + && issue.file_name.as_deref() == Some("linked.conf") + })); +} + +#[test] +fn enumeration_failure_is_visible_and_does_not_claim_a_complete_scan() { + let absent = tempfile::tempdir().unwrap().path().join("missing"); + let (server, _, _) = server_and_client(); + let mut bundle = VaultBundle::create("password").unwrap(); + let report = import_configured_directory(&mut bundle, &settings(&absent), &server).unwrap(); + assert!(!report.scan_complete); + assert_eq!( + report.issues[0].kind, + SourceFailureKind::DirectoryUnavailable + ); + assert_eq!(bundle.record_count(), 0); +} + +#[test] +fn candidate_count_limit_marks_the_scan_incomplete() { + let directory = tempfile::tempdir().unwrap(); + for index in 0..=wireguard_admin::vault::source::MAX_CANDIDATES { + fs::write( + directory.path().join(format!("candidate-{index}.conf")), + b"bad", + ) + .unwrap(); + } + let (server, _, _) = server_and_client(); + let mut bundle = VaultBundle::create("password").unwrap(); + let report = + import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap(); + assert!(!report.scan_complete); + assert!( + report + .issues + .iter() + .any(|issue| issue.kind == SourceFailureKind::TooManyEntries) + ); + assert_eq!(bundle.record_count(), 0); +} + +#[test] +fn explicit_import_uses_existing_identity_and_rejects_duplicate_without_replacement() { + let directory = tempfile::tempdir().unwrap(); + let (server, client, identity) = server_and_client(); + let initial_source = tempfile::tempdir().unwrap(); + let initial = + prepare_initial_setup(&settings(initial_source.path()), &server, "password").unwrap(); + let original_recipient = initial.bundle.recipient().to_owned(); + let mut bundle = initial.bundle; + fs::write( + directory.path().join("client.conf"), + client_file(&client, &server), + ) + .unwrap(); + let report = + import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap(); + assert_eq!(bundle.recipient(), original_recipient); + assert_eq!(bundle.record_count(), 1); + assert!( + report + .reconciliation + .candidate_summaries() + .iter() + .any(|(_, status)| matches!(status, CandidateStatus::Matched { .. })) + ); + let before_repeat = bundle.to_toml().unwrap(); + let repeated = + import_configured_directory(&mut bundle, &settings(directory.path()), &server).unwrap(); + assert_eq!(bundle.to_toml().unwrap(), before_repeat); + assert_eq!(bundle.record_count(), 1); + assert!( + repeated + .issues + .iter() + .any(|issue| issue.kind == SourceFailureKind::DuplicateRecordEqualityUnverified) + ); + let result = bundle.retrieve_record(identity, "password").unwrap(); + assert_eq!(result.public_key(), identity); + assert!(parse_client_config(&client_file(&client, &server)).is_ok()); +}