From 9673e5cfe17678abfeed2334634d5da00649b3f8 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Thu, 13 Aug 2026 23:22:40 +0800 Subject: [PATCH 01/67] [feat][Protocol] Implement signed kernel Root cause: NA Solution: Add typed protocol objects, Domain credentials, exact-byte Ed25519 envelopes, bilateral contracts, scoped grants, hash-linked events, and deterministic idempotent contract reduction. Risks: The v0.1 framing is provisional and requires versioned migration if changed after external consumers exist. Dependency: NA Links: plan/00-v1-local-loopback-mvp.md --- Cargo.lock | 2369 +++++++++++++++++++++++++++++++ Cargo.toml | 38 + README.md | 1 + ROADMAP.md | 8 +- docs/design/agenet-v0.1.md | 17 +- src/lib.rs | 1 + src/main.rs | 3 + src/protocol/contract.rs | 102 ++ src/protocol/envelope.rs | 107 ++ src/protocol/error.rs | 35 + src/protocol/identity.rs | 75 + src/protocol/mod.rs | 20 + src/protocol/sealed_contract.rs | 102 ++ src/protocol/types.rs | 213 +++ tests/protocol_kernel.rs | 479 +++++++ 15 files changed, 3568 insertions(+), 2 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/protocol/contract.rs create mode 100644 src/protocol/envelope.rs create mode 100644 src/protocol/error.rs create mode 100644 src/protocol/identity.rs create mode 100644 src/protocol/mod.rs create mode 100644 src/protocol/sealed_contract.rs create mode 100644 src/protocol/types.rs create mode 100644 tests/protocol_kernel.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4646eb2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2369 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agenet" +version = "0.1.0" +dependencies = [ + "axum", + "base64 0.23.1", + "clap", + "dotenvy", + "ed25519-dalek", + "proptest", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rand_core 0.10.1", + "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 = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "sha2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[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-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[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 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[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 = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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 = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +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" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2ee81f6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "agenet" +version = "0.1.0" +edition = "2024" +rust-version = "1.97.1" +description = "Experimental AgenNet loopback coordination substrate" +license = "MIT" + +[lib] +name = "agenet" +path = "src/lib.rs" + +[[bin]] +name = "agenet" +path = "src/main.rs" + +[dependencies] +axum = "=0.8.9" +base64 = "=0.23.1" +clap = { version = "=4.6.6", features = ["derive"] } +dotenvy = "=0.15.7" +ed25519-dalek = { version = "=3.0.0", features = ["rand_core"] } +reqwest = { version = "=0.13.4", features = ["json"] } +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +sha2 = "=0.11.0" +tokio = { version = "=1.53.1", features = ["full"] } +tracing = "=0.1.44" +uuid = { version = "=1.24.0", features = ["serde", "v4"] } + +[dev-dependencies] +proptest = "=1.11.0" +tempfile = "=3.27.0" + +[profile.release] +strip = true +lto = "thin" + diff --git a/README.md b/README.md index 4939e6d..94a5a2b 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,4 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test --all-targets ``` +The crate currently exposes a pure `protocol` kernel. Runtime, transport, and adapter modules are introduced only after their corresponding behavior tests exist. diff --git a/ROADMAP.md b/ROADMAP.md index 329aa77..106595a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,15 @@ # ROADMAP +## 2026-08-13 22:30 CST + +- **Change**: Implemented the pure signed protocol kernel and its unit/property tests. +- **Files**: `Cargo.toml`, `Cargo.lock`, `src/protocol/`, `tests/protocol_kernel.rs`, `README.md`, `docs/design/agenet-v0.1.md`. +- **Decision**: Sign preserved payload bytes instead of depending on JSON canonicalization; isolate network and disk behavior from the protocol module. +- **Reason**: Make identity, authorization, Contract transition, idempotency, and tamper-rejection rules testable without runtime side effects. + ## 2026-08-13 22:00 CST - **Change**: Initialized the AgenNet project with a provisional v0.1 design and a single-machine, multi-process MVP plan. - **Files**: `README.md`, `CONTEXT.md`, `docs/design/agenet-v0.1.md`, `plan/00-v1-local-loopback-mvp.md`, `.gitignore`, `.env.example`. - **Decision**: Start with a real read-only source-metrics workflow on isolated loopback processes. Preserve future multi-machine protocol semantics while deferring TLS, arbitrary code execution, replication, quota, and federation. - **Reason**: Validate the smallest honest coordination loop before expanding deployment and workload complexity. - diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 6e860e0..05cc33d 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -20,6 +20,22 @@ The reference runtime is deployed as four independent processes with unique iden Peer traffic is signed with Ed25519 but uses plaintext HTTP restricted to loopback. Filesystem paths never appear in peer protocol objects; content is imported into a requester-owned content-addressed Artifact store and read only through a matching Contract and Grant. +## Signed protocol kernel + +The signed envelope preserves the originally serialized payload bytes and signs a domain-separated message containing `kernel_version`, `object_type`, `issuer_id`, payload length, and those exact bytes. String fields use a big-endian `u32` byte-length prefix and payloads use a big-endian `u64` byte-length prefix. Credentials and bilateral Contracts use separate `AGENET\0credential\0` and `AGENET\0contract\0` domains. A framing change therefore requires a kernel-version change and compatibility tests. + +Implemented and tested at the pure library layer: + +- Domain-issued, expiring Ed25519 Node Credentials; +- strict verification of exact-byte signed envelopes; +- scoped, expiring Artifact Grants; +- bilateral signatures over one Contract payload; +- deterministic Contract transitions and operation idempotency; +- hash-linked Event sequences; +- typed manifests, routes, intents, artifacts, metrics, evidence, and errors. + +These statements cover protocol-library invariants only. Durable storage, HTTP authorization, and the multi-process flow remain separate gates. + ## Deliberately deferred - TLS and cross-machine peer sessions @@ -31,4 +47,3 @@ Peer traffic is signed with Ed25519 but uses plaintext HTTP restricted to loopba - multi-party contracts, reputation, payments, and federation Each deferred feature must preserve the MVP's protocol object and Capability-handler seams or document why evidence requires changing them. - diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1b800ec --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod protocol; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..19f3be5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + eprintln!("AgenNet CLI is not implemented yet"); +} diff --git a/src/protocol/contract.rs b/src/protocol/contract.rs new file mode 100644 index 0000000..281c8d3 --- /dev/null +++ b/src/protocol/contract.rs @@ -0,0 +1,102 @@ +use std::{collections::HashSet, fmt::Write}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use super::{ContractDraft, ContractEvent, ContractState, EventKind, ProtocolError}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractProjection { + pub draft: ContractDraft, + pub state: ContractState, + pub events: Vec, + #[serde(skip)] + operations: HashSet, +} + +impl ContractProjection { + pub fn new(draft: ContractDraft) -> Self { + Self { + draft, + state: ContractState::Proposed, + events: Vec::new(), + operations: HashSet::new(), + } + } + + pub fn rebuild_operations(&mut self) { + self.operations = self + .events + .iter() + .map(|event| event.operation_id.clone()) + .collect(); + } +} + +pub fn apply_event( + projection: &mut ContractProjection, + event: &ContractEvent, +) -> Result { + if event.contract_id != projection.draft.contract_id { + return Err(ProtocolError::ContractMismatch); + } + if projection.operations.contains(&event.operation_id) { + return Ok(projection.state); + } + if event.sequence != projection.events.len() as u64 + 1 { + return Err(ProtocolError::InvalidEventSequence); + } + let expected_previous_hash = projection.events.last().map(event_hash); + if event.previous_hash != expected_previous_hash { + return Err(ProtocolError::PreviousEventHashMismatch); + } + authorize_event(projection, event)?; + let next_state = transition(projection.state, event.kind)?; + projection.state = next_state; + projection.operations.insert(event.operation_id.clone()); + projection.events.push(event.clone()); + Ok(next_state) +} + +pub fn event_hash(event: &ContractEvent) -> String { + let bytes = serde_json::to_vec(event).expect("ContractEvent serialization is infallible"); + let digest = Sha256::digest(bytes); + let mut hash = String::with_capacity(71); + hash.push_str("sha256:"); + for byte in digest { + write!(&mut hash, "{byte:02x}").expect("writing to String cannot fail"); + } + hash +} + +fn authorize_event( + projection: &ContractProjection, + event: &ContractEvent, +) -> Result<(), ProtocolError> { + let expected = match event.kind { + EventKind::Accepted | EventKind::VerificationFailed => &projection.draft.requester, + EventKind::Activated | EventKind::Started | EventKind::Delivered | EventKind::Failed => { + &projection.draft.provider + } + }; + if &event.issuer != expected { + return Err(ProtocolError::UnauthorizedEventIssuer); + } + Ok(()) +} + +fn transition(state: ContractState, event: EventKind) -> Result { + let next = match (state, event) { + (ContractState::Proposed, EventKind::Activated) => ContractState::Active, + (ContractState::Active, EventKind::Started) => ContractState::Running, + (ContractState::Running, EventKind::Delivered) => ContractState::Delivered, + (ContractState::Delivered, EventKind::Accepted) => ContractState::Accepted, + (ContractState::Delivered, EventKind::VerificationFailed) => ContractState::Delivered, + ( + ContractState::Proposed | ContractState::Active | ContractState::Running, + EventKind::Failed, + ) => ContractState::Failed, + _ => return Err(ProtocolError::IllegalContractTransition { from: state, event }), + }; + Ok(next) +} diff --git a/src/protocol/envelope.rs b/src/protocol/envelope.rs new file mode 100644 index 0000000..82daad2 --- /dev/null +++ b/src/protocol/envelope.rs @@ -0,0 +1,107 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +use super::{KERNEL_VERSION, NodeId, ProtocolError, SignedNodeCredential}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WireEnvelope { + pub kernel_version: String, + pub object_type: String, + pub issuer_id: NodeId, + pub credential: SignedNodeCredential, + pub payload_base64: String, + pub signature_base64: String, +} + +impl WireEnvelope { + pub fn seal( + object_type: &str, + payload: &T, + signer: &SigningKey, + credential: SignedNodeCredential, + ) -> Result { + let payload_bytes = + serde_json::to_vec(payload).map_err(|_| ProtocolError::SerializationFailed)?; + let claims_bytes = STANDARD + .decode(&credential.claims_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let claims: super::CredentialClaims = serde_json::from_slice(&claims_bytes) + .map_err(|_| ProtocolError::SerializationFailed)?; + if signer.verifying_key().to_bytes() != claims.public_key { + return Err(ProtocolError::CredentialIssuerMismatch); + } + let signature = signer.sign(&signature_message( + KERNEL_VERSION, + object_type, + claims.node_id.as_str(), + &payload_bytes, + )); + Ok(Self { + kernel_version: KERNEL_VERSION.to_owned(), + object_type: object_type.to_owned(), + issuer_id: claims.node_id, + credential, + payload_base64: STANDARD.encode(payload_bytes), + signature_base64: STANDARD.encode(signature.to_bytes()), + }) + } + + pub fn open( + &self, + expected_object_type: &str, + root: &VerifyingKey, + now_unix_ms: u64, + ) -> Result { + if self.kernel_version != KERNEL_VERSION || self.object_type != expected_object_type { + return Err(ProtocolError::UnexpectedObjectType); + } + let claims = self.credential.verify(root, now_unix_ms)?; + if claims.node_id != self.issuer_id { + return Err(ProtocolError::CredentialIssuerMismatch); + } + let payload_bytes = STANDARD + .decode(&self.payload_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let signature_bytes = STANDARD + .decode(&self.signature_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| ProtocolError::InvalidEnvelopeSignature)?; + let verifying_key = VerifyingKey::from_bytes(&claims.public_key) + .map_err(|_| ProtocolError::InvalidEnvelopeSignature)?; + verifying_key + .verify_strict( + &signature_message( + &self.kernel_version, + &self.object_type, + self.issuer_id.as_str(), + &payload_bytes, + ), + &signature, + ) + .map_err(|_| ProtocolError::InvalidEnvelopeSignature)?; + serde_json::from_slice(&payload_bytes).map_err(|_| ProtocolError::SerializationFailed) + } +} + +fn signature_message( + kernel_version: &str, + object_type: &str, + issuer_id: &str, + payload: &[u8], +) -> Vec { + let mut message = Vec::new(); + message.extend_from_slice(b"AGENET\0"); + append_field(&mut message, kernel_version.as_bytes()); + append_field(&mut message, object_type.as_bytes()); + append_field(&mut message, issuer_id.as_bytes()); + message.extend_from_slice(&(payload.len() as u64).to_be_bytes()); + message.extend_from_slice(payload); + message +} + +fn append_field(target: &mut Vec, field: &[u8]) { + target.extend_from_slice(&(field.len() as u32).to_be_bytes()); + target.extend_from_slice(field); +} diff --git a/src/protocol/error.rs b/src/protocol/error.rs new file mode 100644 index 0000000..f58564a --- /dev/null +++ b/src/protocol/error.rs @@ -0,0 +1,35 @@ +use std::fmt::{Display, Formatter}; + +use super::{ContractState, EventKind}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProtocolError { + InvalidIdentifier, + SerializationFailed, + InvalidBase64, + InvalidCredentialSignature, + CredentialExpired, + CredentialNotYetValid, + CredentialIssuerMismatch, + InvalidEnvelopeSignature, + InvalidContractSignature, + UnexpectedObjectType, + GrantExpired, + GrantScopeViolation, + ContractMismatch, + InvalidEventSequence, + PreviousEventHashMismatch, + UnauthorizedEventIssuer, + IllegalContractTransition { + from: ContractState, + event: EventKind, + }, +} + +impl Display for ProtocolError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for ProtocolError {} diff --git a/src/protocol/identity.rs b/src/protocol/identity.rs new file mode 100644 index 0000000..a1721d4 --- /dev/null +++ b/src/protocol/identity.rs @@ -0,0 +1,75 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use super::{NodeId, NodeRole, ProtocolError}; + +const CREDENTIAL_DOMAIN: &[u8] = b"AGENET\0credential\0"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialClaims { + pub node_id: NodeId, + pub public_key: [u8; 32], + pub role: NodeRole, + pub issued_at_unix_ms: u64, + pub expires_at_unix_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedNodeCredential { + pub claims_base64: String, + pub signature_base64: String, +} + +impl SignedNodeCredential { + pub fn issue(root: &SigningKey, claims: CredentialClaims) -> Result { + let claims_bytes = + serde_json::to_vec(&claims).map_err(|_| ProtocolError::SerializationFailed)?; + let signature = root.sign(&credential_message(&claims_bytes)); + Ok(Self { + claims_base64: STANDARD.encode(claims_bytes), + signature_base64: STANDARD.encode(signature.to_bytes()), + }) + } + + pub fn verify( + &self, + root: &VerifyingKey, + now_unix_ms: u64, + ) -> Result { + let claims_bytes = STANDARD + .decode(&self.claims_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let signature_bytes = STANDARD + .decode(&self.signature_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| ProtocolError::InvalidCredentialSignature)?; + root.verify_strict(&credential_message(&claims_bytes), &signature) + .map_err(|_| ProtocolError::InvalidCredentialSignature)?; + let claims: CredentialClaims = serde_json::from_slice(&claims_bytes) + .map_err(|_| ProtocolError::SerializationFailed)?; + if now_unix_ms < claims.issued_at_unix_ms { + return Err(ProtocolError::CredentialNotYetValid); + } + if now_unix_ms > claims.expires_at_unix_ms { + return Err(ProtocolError::CredentialExpired); + } + Ok(claims) + } + + pub(crate) fn decode_claims(&self) -> Result { + let claims_bytes = STANDARD + .decode(&self.claims_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + serde_json::from_slice(&claims_bytes).map_err(|_| ProtocolError::SerializationFailed) + } +} + +fn credential_message(claims: &[u8]) -> Vec { + let mut message = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 8 + claims.len()); + message.extend_from_slice(CREDENTIAL_DOMAIN); + message.extend_from_slice(&(claims.len() as u64).to_be_bytes()); + message.extend_from_slice(claims); + message +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 0000000..f20acce --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,20 @@ +mod contract; +mod envelope; +mod error; +mod identity; +mod sealed_contract; +mod types; + +pub use contract::{ContractProjection, apply_event, event_hash}; +pub use envelope::WireEnvelope; +pub use error::ProtocolError; +pub use identity::{CredentialClaims, SignedNodeCredential}; +pub use sealed_contract::SealedContract; +pub use types::{ + AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, CandidateSet, CapabilityId, + CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractState, ErrorEnvelope, + EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, NodeRole, RouteQuery, + SideEffectProfile, SourceMetrics, +}; + +pub const KERNEL_VERSION: &str = "agenet-kernel-v0.1"; diff --git a/src/protocol/sealed_contract.rs b/src/protocol/sealed_contract.rs new file mode 100644 index 0000000..1ac1454 --- /dev/null +++ b/src/protocol/sealed_contract.rs @@ -0,0 +1,102 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use super::{ContractDraft, ProtocolError, SignedNodeCredential}; + +const CONTRACT_DOMAIN: &[u8] = b"AGENET\0contract\0"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PartySignature { + credential: SignedNodeCredential, + signature_base64: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SealedContract { + pub draft_payload_base64: String, + requester_signature: PartySignature, + provider_signature: PartySignature, +} + +impl SealedContract { + pub fn seal( + draft: &ContractDraft, + requester: &SigningKey, + requester_credential: SignedNodeCredential, + provider: &SigningKey, + provider_credential: SignedNodeCredential, + ) -> Result { + let draft_bytes = + serde_json::to_vec(draft).map_err(|_| ProtocolError::SerializationFailed)?; + let requester_signature = sign_party(&draft_bytes, requester, requester_credential)?; + let provider_signature = sign_party(&draft_bytes, provider, provider_credential)?; + Ok(Self { + draft_payload_base64: STANDARD.encode(draft_bytes), + requester_signature, + provider_signature, + }) + } + + pub fn verify( + &self, + root: &VerifyingKey, + now_unix_ms: u64, + ) -> Result { + let draft_bytes = STANDARD + .decode(&self.draft_payload_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let draft: ContractDraft = + serde_json::from_slice(&draft_bytes).map_err(|_| ProtocolError::SerializationFailed)?; + let requester = verify_party(&draft_bytes, &self.requester_signature, root, now_unix_ms)?; + let provider = verify_party(&draft_bytes, &self.provider_signature, root, now_unix_ms)?; + if requester.node_id != draft.requester || provider.node_id != draft.provider { + return Err(ProtocolError::InvalidContractSignature); + } + Ok(draft) + } +} + +fn sign_party( + draft_bytes: &[u8], + signer: &SigningKey, + credential: SignedNodeCredential, +) -> Result { + let claims = credential.decode_claims()?; + if claims.public_key != signer.verifying_key().to_bytes() { + return Err(ProtocolError::CredentialIssuerMismatch); + } + let signature = signer.sign(&contract_message(draft_bytes)); + Ok(PartySignature { + credential, + signature_base64: STANDARD.encode(signature.to_bytes()), + }) +} + +fn verify_party( + draft_bytes: &[u8], + party: &PartySignature, + root: &VerifyingKey, + now_unix_ms: u64, +) -> Result { + let claims = party.credential.verify(root, now_unix_ms)?; + let verifying_key = VerifyingKey::from_bytes(&claims.public_key) + .map_err(|_| ProtocolError::InvalidContractSignature)?; + let signature_bytes = STANDARD + .decode(&party.signature_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| ProtocolError::InvalidContractSignature)?; + verifying_key + .verify_strict(&contract_message(draft_bytes), &signature) + .map_err(|_| ProtocolError::InvalidContractSignature)?; + Ok(claims) +} + +fn contract_message(draft_bytes: &[u8]) -> Vec { + let mut message = Vec::with_capacity(CONTRACT_DOMAIN.len() + 8 + draft_bytes.len()); + message.extend_from_slice(CONTRACT_DOMAIN); + message.extend_from_slice(&(draft_bytes.len() as u64).to_be_bytes()); + message.extend_from_slice(draft_bytes); + message +} diff --git a/src/protocol/types.rs b/src/protocol/types.rs new file mode 100644 index 0000000..7d8ecd5 --- /dev/null +++ b/src/protocol/types.rs @@ -0,0 +1,213 @@ +use serde::{Deserialize, Serialize}; + +use super::ProtocolError; + +macro_rules! identifier { + ($name:ident) => { + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.len() > 256 || value.chars().any(char::is_whitespace) { + return Err(ProtocolError::InvalidIdentifier); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + }; +} + +identifier!(NodeId); +identifier!(CapabilityId); +identifier!(ArtifactId); +identifier!(IntentId); +identifier!(ContractId); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NodeRole { + Directory, + Requester, + Executor, + Verifier, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactRef { + pub artifact_id: ArtifactId, + pub byte_count: u64, + pub media_type: String, + pub owner: NodeId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SideEffectProfile { + ReadOnly, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityManifest { + pub capability_id: CapabilityId, + pub provider: NodeId, + pub kind: String, + pub version: String, + pub description: String, + pub input_profile: String, + pub output_profile: String, + pub side_effect: SideEffectProfile, + pub endpoint: String, + pub evidence_types: Vec, + pub expires_at_unix_ms: u64, +} + +impl CapabilityManifest { + pub fn capability_kind_version(&self) -> String { + format!("{}.{}", self.kind, self.version) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RouteQuery { + pub required_capability: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CandidateSet { + pub query: RouteQuery, + pub candidates: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IntentProjection { + pub intent_id: IntentId, + pub requester: NodeId, + pub required_capability: String, + pub artifact_id: ArtifactId, + pub acceptance_profile: AcceptanceProfile, + pub expires_at_unix_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Grant { + pub issuer: NodeId, + pub subject: NodeId, + pub capability_id: CapabilityId, + pub artifact_id: ArtifactId, + pub expires_at_unix_ms: u64, + pub delegation_depth: u8, +} + +impl Grant { + pub fn allows( + &self, + subject: &NodeId, + capability_id: &CapabilityId, + artifact_id: &ArtifactId, + now_unix_ms: u64, + ) -> Result<(), ProtocolError> { + if now_unix_ms > self.expires_at_unix_ms { + return Err(ProtocolError::GrantExpired); + } + if &self.subject != subject + || &self.capability_id != capability_id + || &self.artifact_id != artifact_id + { + return Err(ProtocolError::GrantScopeViolation); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AcceptanceProfile { + ExactSourceMetricsV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractDraft { + pub contract_id: ContractId, + pub parent_contract_id: Option, + pub intent_id: IntentId, + pub requester: NodeId, + pub provider: NodeId, + pub capability_id: CapabilityId, + pub grant: Grant, + pub artifact: ArtifactRef, + pub acceptance: AcceptanceProfile, + pub expires_at_unix_ms: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractState { + Proposed, + Active, + Running, + Delivered, + Accepted, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventKind { + Activated, + Started, + Delivered, + Accepted, + VerificationFailed, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractEvent { + pub contract_id: ContractId, + pub event_id: String, + pub sequence: u64, + pub previous_hash: Option, + pub operation_id: String, + pub issuer: NodeId, + pub kind: EventKind, + pub payload: serde_json::Value, + pub occurred_at_unix_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactReadRequest { + pub contract_id: ContractId, + pub artifact_id: ArtifactId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceMetrics { + pub sha256: String, + pub byte_count: u64, + pub line_count: u64, + pub non_empty_line_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EvidenceClaim { + pub artifact: ArtifactRef, + pub capability_version: String, + pub metrics: SourceMetrics, + pub producer: NodeId, + pub executed_at_unix_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorEnvelope { + pub code: String, + pub message: String, + pub retryable: bool, + pub operation_id: String, +} diff --git a/tests/protocol_kernel.rs b/tests/protocol_kernel.rs new file mode 100644 index 0000000..719b533 --- /dev/null +++ b/tests/protocol_kernel.rs @@ -0,0 +1,479 @@ +use agenet::protocol::{ + AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, CandidateSet, CapabilityId, + CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractProjection, + ContractState, CredentialClaims, ErrorEnvelope, EventKind, EvidenceClaim, Grant, IntentId, + IntentProjection, NodeId, NodeRole, ProtocolError, RouteQuery, SealedContract, + SideEffectProfile, SignedNodeCredential, SourceMetrics, WireEnvelope, apply_event, event_hash, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; +use proptest::prelude::*; +use serde::{Deserialize, Serialize}; + +const NOW: u64 = 1_800_000_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct TestPayload { + value: String, +} + +fn signing_key(byte: u8) -> SigningKey { + SigningKey::from_bytes(&[byte; 32]) +} + +fn credential( + root: &SigningKey, + node: &SigningKey, + node_id: &str, + role: NodeRole, +) -> SignedNodeCredential { + SignedNodeCredential::issue( + root, + CredentialClaims { + node_id: NodeId::new(node_id).expect("valid node id"), + public_key: node.verifying_key().to_bytes(), + role, + issued_at_unix_ms: NOW - 1_000, + expires_at_unix_ms: NOW + 60_000, + }, + ) + .expect("credential issued") +} + +#[test] +fn credential_rejects_expiry_and_wrong_root() { + let root = signing_key(1); + let other_root = signing_key(2); + let node = signing_key(3); + let credential = credential(&root, &node, "node:executor", NodeRole::Executor); + + assert_eq!( + credential + .verify(&root.verifying_key(), NOW) + .expect("valid credential") + .node_id + .as_str(), + "node:executor" + ); + assert_eq!( + credential.verify(&root.verifying_key(), NOW + 60_001), + Err(ProtocolError::CredentialExpired) + ); + assert_eq!( + credential.verify(&other_root.verifying_key(), NOW), + Err(ProtocolError::InvalidCredentialSignature) + ); +} + +#[test] +fn envelope_binds_exact_payload_type_and_issuer() { + let root = signing_key(4); + let node = signing_key(5); + let credential = credential(&root, &node, "node:requester", NodeRole::Requester); + let payload = TestPayload { + value: "preserve exact bytes".to_owned(), + }; + let envelope = WireEnvelope::seal("test.payload.v1", &payload, &node, credential) + .expect("envelope sealed"); + + assert_eq!( + envelope + .open::("test.payload.v1", &root.verifying_key(), NOW,) + .expect("envelope verified"), + payload + ); + + let mut tampered_payload = envelope.clone(); + tampered_payload.payload_base64.push('A'); + assert!( + tampered_payload + .open::("test.payload.v1", &root.verifying_key(), NOW) + .is_err() + ); + + let mut tampered_type = envelope.clone(); + tampered_type.object_type = "test.other.v1".to_owned(); + assert!( + tampered_type + .open::("test.other.v1", &root.verifying_key(), NOW) + .is_err() + ); + + let mut tampered_issuer = envelope.clone(); + tampered_issuer.issuer_id = NodeId::new("node:someone-else").expect("valid node id"); + assert!( + tampered_issuer + .open::("test.payload.v1", &root.verifying_key(), NOW) + .is_err() + ); +} + +#[test] +fn strict_verification_rejects_a_weak_public_key() { + let root = signing_key(6); + let weak_credential = SignedNodeCredential::issue( + &root, + CredentialClaims { + node_id: NodeId::new("node:weak").unwrap(), + public_key: [0; 32], + role: NodeRole::Executor, + issued_at_unix_ms: NOW - 1, + expires_at_unix_ms: NOW + 1, + }, + ) + .unwrap(); + let envelope = WireEnvelope { + kernel_version: "agenet-kernel-v0.1".to_owned(), + object_type: "test.payload.v1".to_owned(), + issuer_id: NodeId::new("node:weak").unwrap(), + credential: weak_credential, + payload_base64: STANDARD.encode(br#"{"value":"x"}"#), + signature_base64: STANDARD.encode([0; 64]), + }; + + assert_eq!( + envelope.open::("test.payload.v1", &root.verifying_key(), NOW), + Err(ProtocolError::InvalidEnvelopeSignature) + ); +} + +#[test] +fn grant_enforces_subject_capability_artifact_and_expiry() { + let grant = Grant { + issuer: NodeId::new("node:requester").unwrap(), + subject: NodeId::new("node:executor").unwrap(), + capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + artifact_id: ArtifactId::new("sha256:abcd").unwrap(), + expires_at_unix_ms: NOW + 1_000, + delegation_depth: 0, + }; + + assert!( + grant + .allows( + &NodeId::new("node:executor").unwrap(), + &CapabilityId::new("source.metrics.v1").unwrap(), + &ArtifactId::new("sha256:abcd").unwrap(), + NOW, + ) + .is_ok() + ); + assert_eq!( + grant.allows( + &NodeId::new("node:verifier").unwrap(), + &CapabilityId::new("source.metrics.v1").unwrap(), + &ArtifactId::new("sha256:abcd").unwrap(), + NOW, + ), + Err(ProtocolError::GrantScopeViolation) + ); + assert_eq!( + grant.allows( + &NodeId::new("node:executor").unwrap(), + &CapabilityId::new("source.metrics.v1").unwrap(), + &ArtifactId::new("sha256:ffff").unwrap(), + NOW, + ), + Err(ProtocolError::GrantScopeViolation) + ); + assert_eq!( + grant.allows( + &NodeId::new("node:executor").unwrap(), + &CapabilityId::new("source.metrics.v1").unwrap(), + &ArtifactId::new("sha256:abcd").unwrap(), + NOW + 1_001, + ), + Err(ProtocolError::GrantExpired) + ); +} + +#[test] +fn public_protocol_objects_round_trip_without_losing_typed_fields() { + let artifact = ArtifactRef { + artifact_id: ArtifactId::new("sha256:abcd").unwrap(), + byte_count: 4, + media_type: "text/x-rust".to_owned(), + owner: NodeId::new("node:requester").unwrap(), + }; + let manifest = CapabilityManifest { + capability_id: CapabilityId::new("capability:executor-source-metrics").unwrap(), + provider: NodeId::new("node:executor").unwrap(), + kind: "source.metrics".to_owned(), + version: "v1".to_owned(), + description: "Compute source metrics".to_owned(), + input_profile: "artifact.source.utf8.v1".to_owned(), + output_profile: "source.metrics.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: "http://127.0.0.1:12345".to_owned(), + evidence_types: vec!["source.metrics.evidence.v1".to_owned()], + expires_at_unix_ms: NOW + 60_000, + }; + let intent = IntentProjection { + intent_id: IntentId::new("intent:metrics").unwrap(), + requester: NodeId::new("node:requester").unwrap(), + required_capability: "source.metrics.v1".to_owned(), + artifact_id: artifact.artifact_id.clone(), + acceptance_profile: AcceptanceProfile::ExactSourceMetricsV1, + expires_at_unix_ms: NOW + 60_000, + }; + let evidence = EvidenceClaim { + artifact: artifact.clone(), + capability_version: "source.metrics.v1".to_owned(), + metrics: SourceMetrics { + sha256: artifact.artifact_id.as_str().to_owned(), + byte_count: 4, + line_count: 1, + non_empty_line_count: 1, + }, + producer: NodeId::new("node:executor").unwrap(), + executed_at_unix_ms: NOW, + }; + let candidates = CandidateSet { + query: RouteQuery { + required_capability: "source.metrics.v1".to_owned(), + }, + candidates: vec![manifest], + }; + let read = ArtifactReadRequest { + contract_id: ContractId::new("contract:source").unwrap(), + artifact_id: artifact.artifact_id.clone(), + }; + let error = ErrorEnvelope { + code: "GrantScopeViolation".to_owned(), + message: "request is outside the granted scope".to_owned(), + retryable: false, + operation_id: "op:read".to_owned(), + }; + + for value in [ + serde_json::to_value(intent).unwrap(), + serde_json::to_value(evidence).unwrap(), + serde_json::to_value(candidates).unwrap(), + serde_json::to_value(read).unwrap(), + serde_json::to_value(error).unwrap(), + ] { + assert!(value.is_object()); + } +} + +fn draft() -> ContractDraft { + let artifact_id = ArtifactId::new("sha256:abcd").unwrap(); + ContractDraft { + contract_id: ContractId::new("contract:source").unwrap(), + parent_contract_id: None, + intent_id: IntentId::new("intent:metrics").unwrap(), + requester: NodeId::new("node:requester").unwrap(), + provider: NodeId::new("node:executor").unwrap(), + capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + grant: Grant { + issuer: NodeId::new("node:requester").unwrap(), + subject: NodeId::new("node:executor").unwrap(), + capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + artifact_id: artifact_id.clone(), + expires_at_unix_ms: NOW + 60_000, + delegation_depth: 0, + }, + artifact: ArtifactRef { + artifact_id, + byte_count: 4, + media_type: "text/x-rust".to_owned(), + owner: NodeId::new("node:requester").unwrap(), + }, + acceptance: AcceptanceProfile::ExactSourceMetricsV1, + expires_at_unix_ms: NOW + 60_000, + } +} + +fn event(sequence: u64, operation: &str, issuer: &str, kind: EventKind) -> ContractEvent { + ContractEvent { + contract_id: ContractId::new("contract:source").unwrap(), + event_id: format!("event:{sequence}"), + sequence, + previous_hash: None, + operation_id: operation.to_owned(), + issuer: NodeId::new(issuer).unwrap(), + kind, + payload: serde_json::json!({}), + occurred_at_unix_ms: NOW + sequence, + } +} + +fn next_event( + projection: &ContractProjection, + operation: &str, + issuer: &str, + kind: EventKind, +) -> ContractEvent { + let mut event = event(projection.events.len() as u64 + 1, operation, issuer, kind); + event.previous_hash = projection.events.last().map(event_hash); + event +} + +#[test] +fn bilateral_contract_signatures_cover_identical_draft_bytes() { + let root = signing_key(20); + let requester = signing_key(21); + let executor = signing_key(22); + let requester_credential = credential(&root, &requester, "node:requester", NodeRole::Requester); + let executor_credential = credential(&root, &executor, "node:executor", NodeRole::Executor); + + let contract = SealedContract::seal( + &draft(), + &requester, + requester_credential, + &executor, + executor_credential, + ) + .expect("contract sealed"); + assert_eq!( + contract + .verify(&root.verifying_key(), NOW) + .expect("bilateral signatures valid"), + draft() + ); + + let mut tampered = contract; + tampered.draft_payload_base64.push('A'); + assert!(tampered.verify(&root.verifying_key(), NOW).is_err()); +} + +#[test] +fn reducer_rejects_illegal_transitions_and_duplicate_effects() { + let draft = draft(); + let mut projection = ContractProjection::new(draft.clone()); + + assert_eq!( + apply_event( + &mut projection, + &event(1, "op:accepted", "node:requester", EventKind::Accepted), + ), + Err(ProtocolError::IllegalContractTransition { + from: ContractState::Proposed, + event: EventKind::Accepted, + }) + ); + + for (_sequence, operation, issuer, kind, expected) in [ + ( + 1, + "op:active", + "node:executor", + EventKind::Activated, + ContractState::Active, + ), + ( + 2, + "op:running", + "node:executor", + EventKind::Started, + ContractState::Running, + ), + ( + 3, + "op:delivered", + "node:executor", + EventKind::Delivered, + ContractState::Delivered, + ), + ( + 4, + "op:accepted", + "node:requester", + EventKind::Accepted, + ContractState::Accepted, + ), + ] { + let next = next_event(&projection, operation, issuer, kind); + assert_eq!(apply_event(&mut projection, &next), Ok(expected)); + } + + let count = projection.events.len(); + let duplicate = projection.events.last().unwrap().clone(); + assert_eq!( + apply_event(&mut projection, &duplicate), + Ok(ContractState::Accepted) + ); + assert_eq!(projection.events.len(), count); + + let mut unauthorized = ContractProjection::new(draft); + for (_sequence, operation, kind) in [ + (1, "op:a", EventKind::Activated), + (2, "op:b", EventKind::Started), + (3, "op:c", EventKind::Delivered), + ] { + let next = next_event(&unauthorized, operation, "node:executor", kind); + apply_event(&mut unauthorized, &next).unwrap(); + } + let unauthorized_accept = + next_event(&unauthorized, "op:d", "node:executor", EventKind::Accepted); + assert_eq!( + apply_event(&mut unauthorized, &unauthorized_accept), + Err(ProtocolError::UnauthorizedEventIssuer) + ); +} + +#[test] +fn reducer_rejects_a_broken_previous_hash_chain() { + let mut projection = ContractProjection::new(draft()); + let active = next_event( + &projection, + "op:active", + "node:executor", + EventKind::Activated, + ); + apply_event(&mut projection, &active).unwrap(); + + let mut running = next_event( + &projection, + "op:running", + "node:executor", + EventKind::Started, + ); + running.previous_hash = Some("sha256:not-the-previous-event".to_owned()); + assert_eq!( + apply_event(&mut projection, &running), + Err(ProtocolError::PreviousEventHashMismatch) + ); +} + +proptest! { + #[test] + fn any_payload_bit_flip_breaks_the_envelope_signature(byte_index in 0usize..32, bit in 0u8..8) { + let root = signing_key(30); + let node = signing_key(31); + let credential = credential(&root, &node, "node:requester", NodeRole::Requester); + let payload = [7_u8; 32]; + let mut envelope = WireEnvelope::seal("test.bytes.v1", &payload, &node, credential).unwrap(); + let mut bytes = STANDARD.decode(&envelope.payload_base64).unwrap(); + bytes[byte_index] ^= 1 << bit; + envelope.payload_base64 = STANDARD.encode(bytes); + + prop_assert!(envelope + .open::<[u8; 32]>("test.bytes.v1", &root.verifying_key(), NOW) + .is_err()); + } + + #[test] + fn grant_never_authorizes_a_different_artifact(suffix in "[a-z0-9]{1,32}") { + let authorized = ArtifactId::new("sha256:authorized").unwrap(); + let candidate = ArtifactId::new(format!("sha256:different-{suffix}")).unwrap(); + let grant = Grant { + issuer: NodeId::new("node:requester").unwrap(), + subject: NodeId::new("node:executor").unwrap(), + capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + artifact_id: authorized, + expires_at_unix_ms: NOW + 1, + delegation_depth: 0, + }; + + prop_assert_eq!( + grant.allows( + &NodeId::new("node:executor").unwrap(), + &CapabilityId::new("source.metrics.v1").unwrap(), + &candidate, + NOW, + ), + Err(ProtocolError::GrantScopeViolation) + ); + } +} From 6029c1f85e4f5fdf566d6bd45180398ae7d10358 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Thu, 13 Aug 2026 23:52:29 +0800 Subject: [PATCH 02/67] [feat][Runtime] Implement loopback runtime Root cause: NA Solution: Add durable node state, owner-only key storage, content-addressed artifacts, synchronized journal replay, deterministic signed discovery, and a bounded loopback HTTP client. Risks: Plaintext HTTP remains restricted to loopback and journal replication is not implemented. Dependency: 9673e5c Links: plan/00-v1-local-loopback-mvp.md --- .gitignore | 2 +- Cargo.lock | 102 ++++++++++++++ Cargo.toml | 5 +- ROADMAP.md | 7 + docs/design/agenet-v0.1.md | 6 + src/lib.rs | 2 + src/main.rs | 2 +- src/protocol/mod.rs | 7 +- src/protocol/sealed_contract.rs | 74 +++++++++-- src/protocol/types.rs | 25 +++- src/runtime/artifact.rs | 87 ++++++++++++ src/runtime/artifact_access.rs | 62 +++++++++ src/runtime/directory.rs | 77 +++++++++++ src/runtime/error.rs | 53 ++++++++ src/runtime/identity.rs | 86 ++++++++++++ src/runtime/key_store.rs | 37 ++++++ src/runtime/mod.rs | 17 +++ src/runtime/recorder.rs | 165 +++++++++++++++++++++++ src/transport/client.rs | 185 ++++++++++++++++++++++++++ src/transport/directory.rs | 133 +++++++++++++++++++ src/transport/mod.rs | 7 + tests/http_client.rs | 86 ++++++++++++ tests/http_directory.rs | 159 ++++++++++++++++++++++ tests/protocol_kernel.rs | 28 +++- tests/runtime_storage.rs | 226 ++++++++++++++++++++++++++++++++ 25 files changed, 1622 insertions(+), 18 deletions(-) create mode 100644 src/runtime/artifact.rs create mode 100644 src/runtime/artifact_access.rs create mode 100644 src/runtime/directory.rs create mode 100644 src/runtime/error.rs create mode 100644 src/runtime/identity.rs create mode 100644 src/runtime/key_store.rs create mode 100644 src/runtime/mod.rs create mode 100644 src/runtime/recorder.rs create mode 100644 src/transport/client.rs create mode 100644 src/transport/directory.rs create mode 100644 src/transport/mod.rs create mode 100644 tests/http_client.rs create mode 100644 tests/http_directory.rs create mode 100644 tests/runtime_storage.rs diff --git a/.gitignore b/.gitignore index 5b27839..86f5915 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,6 @@ target/ *.key *.pem +*.token *.log *.jsonl - diff --git a/Cargo.lock b/Cargo.lock index 4646eb2..5eb266c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,8 @@ dependencies = [ "clap", "dotenvy", "ed25519-dalek", + "getrandom 0.4.3", + "http-body-util", "proptest", "reqwest", "serde", @@ -18,10 +20,21 @@ dependencies = [ "sha2", "tempfile", "tokio", + "tower", "tracing", + "tracing-subscriber", "uuid", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -940,6 +953,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -979,6 +998,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -1008,6 +1036,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1279,6 +1316,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.8.11" @@ -1596,6 +1644,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1773,6 +1830,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1926,6 +1992,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1994,6 +2090,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 2ee81f6..b76fb63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,19 +20,22 @@ base64 = "=0.23.1" clap = { version = "=4.6.6", features = ["derive"] } dotenvy = "=0.15.7" ed25519-dalek = { version = "=3.0.0", features = ["rand_core"] } +getrandom = "=0.4.3" reqwest = { version = "=0.13.4", features = ["json"] } serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" sha2 = "=0.11.0" tokio = { version = "=1.53.1", features = ["full"] } tracing = "=0.1.44" +tracing-subscriber = { version = "=0.3.20", features = ["env-filter", "fmt"] } uuid = { version = "=1.24.0", features = ["serde", "v4"] } [dev-dependencies] +http-body-util = "=0.1.5" proptest = "=1.11.0" tempfile = "=3.27.0" +tower = "=0.5.3" [profile.release] strip = true lto = "thin" - diff --git a/ROADMAP.md b/ROADMAP.md index 106595a..c996af4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,12 @@ # ROADMAP +## 2026-08-13 23:10 CST + +- **Change**: Implemented durable per-node storage and the loopback Directory/HTTP transport baseline. +- **Files**: `src/runtime/`, `src/transport/`, `tests/runtime_storage.rs`, `tests/http_directory.rs`, `tests/http_client.rs`. +- **Decision**: Use content-addressed Artifact files, owner-only private-key files, serialized append-and-sync journal writes, and explicit loopback URL validation. +- **Reason**: Make recovery, tamper detection, request limits, timeout behavior, and signed discovery independently testable before orchestration. + ## 2026-08-13 22:30 CST - **Change**: Implemented the pure signed protocol kernel and its unit/property tests. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 05cc33d..50ee06c 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -36,6 +36,12 @@ Implemented and tested at the pure library layer: These statements cover protocol-library invariants only. Durable storage, HTTP authorization, and the multi-process flow remain separate gates. +## Runtime and transport baseline + +Each node state directory owns an append-only `journal.jsonl`, an Artifact directory where applicable, and a `0600` Ed25519 signing-key file. Journal entries are serialized under a mutex, flushed, and synchronized before the in-memory projection is advanced. Replay verifies both Contract and Event signatures again. + +The HTTP adapter rejects non-loopback Capability endpoints, caps JSON bodies at 256 KiB, maps failures to sanitized typed errors, and gives read-only requests a bounded retry path. Directory matching is deterministic equality on the public `kind.version`; it neither invokes a model nor selects a final provider. + ## Deliberately deferred - TLS and cross-machine peer sessions diff --git a/src/lib.rs b/src/lib.rs index 1b800ec..7e57202 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,3 @@ pub mod protocol; +pub mod runtime; +pub mod transport; diff --git a/src/main.rs b/src/main.rs index 19f3be5..88f1995 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,3 @@ fn main() { - eprintln!("AgenNet CLI is not implemented yet"); + eprintln!("AgenNet CLI runtime is under construction"); } diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index f20acce..278f156 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -9,10 +9,11 @@ pub use contract::{ContractProjection, apply_event, event_hash}; pub use envelope::WireEnvelope; pub use error::ProtocolError; pub use identity::{CredentialClaims, SignedNodeCredential}; -pub use sealed_contract::SealedContract; +pub use sealed_contract::{ContractOffer, SealedContract}; pub use types::{ - AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, CandidateSet, CapabilityId, - CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractState, ErrorEnvelope, + AcceptanceProfile, ArtifactId, ArtifactPayload, ArtifactReadRequest, ArtifactRef, CandidateSet, + CapabilityId, CapabilityManifest, ContractDraft, ContractEvent, ContractId, + ContractProposeRequest, ContractProposeResponse, ContractQuery, ContractState, ErrorEnvelope, EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, NodeRole, RouteQuery, SideEffectProfile, SourceMetrics, }; diff --git a/src/protocol/sealed_contract.rs b/src/protocol/sealed_contract.rs index 1ac1454..e8b793d 100644 --- a/src/protocol/sealed_contract.rs +++ b/src/protocol/sealed_contract.rs @@ -12,6 +12,12 @@ struct PartySignature { signature_base64: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractOffer { + pub draft_payload_base64: String, + requester_signature: PartySignature, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SealedContract { pub draft_payload_base64: String, @@ -19,33 +25,79 @@ pub struct SealedContract { provider_signature: PartySignature, } -impl SealedContract { - pub fn seal( +impl ContractOffer { + pub fn create( draft: &ContractDraft, requester: &SigningKey, requester_credential: SignedNodeCredential, - provider: &SigningKey, - provider_credential: SignedNodeCredential, ) -> Result { let draft_bytes = serde_json::to_vec(draft).map_err(|_| ProtocolError::SerializationFailed)?; let requester_signature = sign_party(&draft_bytes, requester, requester_credential)?; - let provider_signature = sign_party(&draft_bytes, provider, provider_credential)?; + let claims = requester_signature.credential.decode_claims()?; + if claims.node_id != draft.requester { + return Err(ProtocolError::InvalidContractSignature); + } Ok(Self { draft_payload_base64: STANDARD.encode(draft_bytes), requester_signature, + }) + } + + pub fn verify( + &self, + root: &VerifyingKey, + now_unix_ms: u64, + ) -> Result { + let draft_bytes = decode_draft_bytes(&self.draft_payload_base64)?; + let draft: ContractDraft = + serde_json::from_slice(&draft_bytes).map_err(|_| ProtocolError::SerializationFailed)?; + let requester = verify_party(&draft_bytes, &self.requester_signature, root, now_unix_ms)?; + if requester.node_id != draft.requester { + return Err(ProtocolError::InvalidContractSignature); + } + Ok(draft) + } + + pub fn countersign( + self, + provider: &SigningKey, + provider_credential: SignedNodeCredential, + ) -> Result { + let draft_bytes = decode_draft_bytes(&self.draft_payload_base64)?; + let draft: ContractDraft = + serde_json::from_slice(&draft_bytes).map_err(|_| ProtocolError::SerializationFailed)?; + let provider_signature = sign_party(&draft_bytes, provider, provider_credential)?; + let provider_claims = provider_signature.credential.decode_claims()?; + if provider_claims.node_id != draft.provider { + return Err(ProtocolError::InvalidContractSignature); + } + Ok(SealedContract { + draft_payload_base64: self.draft_payload_base64, + requester_signature: self.requester_signature, provider_signature, }) } +} + +impl SealedContract { + pub fn seal( + draft: &ContractDraft, + requester: &SigningKey, + requester_credential: SignedNodeCredential, + provider: &SigningKey, + provider_credential: SignedNodeCredential, + ) -> Result { + ContractOffer::create(draft, requester, requester_credential)? + .countersign(provider, provider_credential) + } pub fn verify( &self, root: &VerifyingKey, now_unix_ms: u64, ) -> Result { - let draft_bytes = STANDARD - .decode(&self.draft_payload_base64) - .map_err(|_| ProtocolError::InvalidBase64)?; + let draft_bytes = decode_draft_bytes(&self.draft_payload_base64)?; let draft: ContractDraft = serde_json::from_slice(&draft_bytes).map_err(|_| ProtocolError::SerializationFailed)?; let requester = verify_party(&draft_bytes, &self.requester_signature, root, now_unix_ms)?; @@ -57,6 +109,12 @@ impl SealedContract { } } +fn decode_draft_bytes(encoded: &str) -> Result, ProtocolError> { + STANDARD + .decode(encoded) + .map_err(|_| ProtocolError::InvalidBase64) +} + fn sign_party( draft_bytes: &[u8], signer: &SigningKey, diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 7d8ecd5..f2ee53f 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use super::ProtocolError; +use super::{ContractOffer, ProtocolError}; macro_rules! identifier { ($name:ident) => { @@ -187,6 +187,29 @@ pub struct ArtifactReadRequest { pub artifact_id: ArtifactId, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactPayload { + pub artifact: ArtifactRef, + pub bytes_base64: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractProposeRequest { + pub offer: ContractOffer, + pub artifact_endpoint: String, + pub expected_metrics: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractProposeResponse { + pub contract: super::SealedContract, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractQuery { + pub contract_id: ContractId, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SourceMetrics { pub sha256: String, diff --git a/src/runtime/artifact.rs b/src/runtime/artifact.rs new file mode 100644 index 0000000..3236f8f --- /dev/null +++ b/src/runtime/artifact.rs @@ -0,0 +1,87 @@ +use std::{ + fmt::Write, + fs::{self, OpenOptions}, + io::Write as IoWrite, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, +}; + +use sha2::{Digest, Sha256}; + +use crate::protocol::{ArtifactId, ArtifactRef, NodeId}; + +use super::{MAX_ARTIFACT_BYTES, RuntimeError}; + +#[derive(Debug, Clone)] +pub struct ArtifactStore { + directory: PathBuf, + owner: NodeId, +} + +impl ArtifactStore { + pub fn open(state_directory: &Path, owner: NodeId) -> Result { + let directory = state_directory.join("artifacts"); + fs::create_dir_all(&directory)?; + Ok(Self { directory, owner }) + } + + pub fn import(&self, bytes: &[u8], media_type: &str) -> Result { + if bytes.len() > MAX_ARTIFACT_BYTES { + return Err(RuntimeError::ArtifactTooLarge); + } + let artifact_id = hash_bytes(bytes)?; + let path = self.path_for(&artifact_id); + if path.exists() { + let existing = fs::read(&path)?; + if existing != bytes { + return Err(RuntimeError::ArtifactIntegrityMismatch); + } + } else { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(path)?; + file.write_all(bytes)?; + file.flush()?; + file.sync_data()?; + } + Ok(ArtifactRef { + artifact_id, + byte_count: bytes.len() as u64, + media_type: media_type.to_owned(), + owner: self.owner.clone(), + }) + } + + pub fn read(&self, artifact: &ArtifactRef) -> Result, RuntimeError> { + let bytes = + fs::read(self.path_for(&artifact.artifact_id)).map_err(|error| match error.kind() { + std::io::ErrorKind::NotFound => RuntimeError::ArtifactNotFound, + _ => RuntimeError::Io, + })?; + if bytes.len() as u64 != artifact.byte_count || hash_bytes(&bytes)? != artifact.artifact_id + { + return Err(RuntimeError::ArtifactIntegrityMismatch); + } + Ok(bytes) + } + + fn path_for(&self, artifact_id: &ArtifactId) -> PathBuf { + let filename = artifact_id + .as_str() + .strip_prefix("sha256:") + .unwrap_or(artifact_id.as_str()); + self.directory.join(filename) + } +} + +fn hash_bytes(bytes: &[u8]) -> Result { + let digest = Sha256::digest(bytes); + let mut value = String::with_capacity(71); + value.push_str("sha256:"); + for byte in digest { + write!(&mut value, "{byte:02x}").map_err(|_| RuntimeError::Serialization)?; + } + ArtifactId::new(value).map_err(RuntimeError::from) +} diff --git a/src/runtime/artifact_access.rs b/src/runtime/artifact_access.rs new file mode 100644 index 0000000..ec5e958 --- /dev/null +++ b/src/runtime/artifact_access.rs @@ -0,0 +1,62 @@ +use std::{collections::HashMap, sync::Arc}; + +use ed25519_dalek::VerifyingKey; +use tokio::sync::RwLock; + +use crate::protocol::{ArtifactPayload, ArtifactReadRequest, ContractId, NodeId, SealedContract}; + +use super::{ArtifactStore, RuntimeError}; + +#[derive(Clone)] +pub struct ArtifactAccessService { + store: ArtifactStore, + root: VerifyingKey, + validation_time_unix_ms: u64, + contracts: Arc>>, +} + +impl ArtifactAccessService { + pub fn new(store: ArtifactStore, root: VerifyingKey, validation_time_unix_ms: u64) -> Self { + Self { + store, + root, + validation_time_unix_ms, + contracts: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn authorize(&self, contract: SealedContract) -> Result<(), RuntimeError> { + let draft = contract.verify(&self.root, self.validation_time_unix_ms)?; + self.contracts + .write() + .await + .insert(draft.contract_id, contract); + Ok(()) + } + + pub async fn read( + &self, + caller: &NodeId, + request: &ArtifactReadRequest, + ) -> Result { + let contracts = self.contracts.read().await; + let contract = contracts + .get(&request.contract_id) + .ok_or(RuntimeError::ArtifactAccessDenied)?; + let draft = contract.verify(&self.root, self.validation_time_unix_ms)?; + draft.grant.allows( + caller, + &draft.capability_id, + &request.artifact_id, + self.validation_time_unix_ms, + )?; + if request.artifact_id != draft.artifact.artifact_id { + return Err(RuntimeError::ArtifactAccessDenied); + } + let bytes = self.store.read(&draft.artifact)?; + Ok(ArtifactPayload { + artifact: draft.artifact, + bytes_base64: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes), + }) + } +} diff --git a/src/runtime/directory.rs b/src/runtime/directory.rs new file mode 100644 index 0000000..b25d69e --- /dev/null +++ b/src/runtime/directory.rs @@ -0,0 +1,77 @@ +use std::{collections::HashMap, net::IpAddr, sync::Arc}; + +use tokio::sync::RwLock; + +use crate::protocol::{CandidateSet, CapabilityId, CapabilityManifest, NodeId, RouteQuery}; + +use super::RuntimeError; + +#[derive(Debug, Clone, Default)] +pub struct DirectoryRegistry { + manifests: Arc>>, +} + +impl DirectoryRegistry { + pub fn new() -> Self { + Self::default() + } + + pub async fn register( + &self, + issuer: &NodeId, + manifest: CapabilityManifest, + now_unix_ms: u64, + ) -> Result<(), RuntimeError> { + if issuer != &manifest.provider { + return Err(RuntimeError::ManifestProviderMismatch); + } + validate_loopback_endpoint(&manifest.endpoint)?; + if manifest.expires_at_unix_ms <= now_unix_ms { + return Err(RuntimeError::Protocol( + crate::protocol::ProtocolError::CredentialExpired, + )); + } + self.manifests + .write() + .await + .insert(manifest.capability_id.clone(), manifest); + Ok(()) + } + + pub async fn query(&self, query: RouteQuery, now_unix_ms: u64) -> CandidateSet { + let mut candidates: Vec<_> = self + .manifests + .read() + .await + .values() + .filter(|manifest| { + manifest.expires_at_unix_ms > now_unix_ms + && manifest.capability_kind_version() == query.required_capability + }) + .cloned() + .collect(); + candidates.sort_by(|left, right| { + left.capability_id + .as_str() + .cmp(right.capability_id.as_str()) + }); + CandidateSet { query, candidates } + } +} + +fn validate_loopback_endpoint(endpoint: &str) -> Result<(), RuntimeError> { + let url = + reqwest::Url::parse(endpoint).map_err(|_| RuntimeError::UnsupportedNonLoopbackTransport)?; + if url.scheme() != "http" { + return Err(RuntimeError::UnsupportedNonLoopbackTransport); + } + let address: IpAddr = url + .host_str() + .ok_or(RuntimeError::UnsupportedNonLoopbackTransport)? + .parse() + .map_err(|_| RuntimeError::UnsupportedNonLoopbackTransport)?; + if !address.is_loopback() || url.port().is_none() { + return Err(RuntimeError::UnsupportedNonLoopbackTransport); + } + Ok(()) +} diff --git a/src/runtime/error.rs b/src/runtime/error.rs new file mode 100644 index 0000000..b9216ac --- /dev/null +++ b/src/runtime/error.rs @@ -0,0 +1,53 @@ +use std::fmt::{Display, Formatter}; + +use crate::protocol::ProtocolError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuntimeError { + Io, + Serialization, + InvalidPrivateKey, + ArtifactTooLarge, + ArtifactNotFound, + ArtifactIntegrityMismatch, + ArtifactAccessDenied, + UnknownContract, + ContractAlreadyExists, + CredentialRoleMismatch, + ManifestProviderMismatch, + UnsupportedNonLoopbackTransport, + UnsupportedArtifactEncoding, + EvidenceMismatch, + DecisionFailed, + TransportFailed, + CapabilityUnavailable, + ContractExecutionFailed, + VerificationFailed, + Protocol(ProtocolError), +} + +impl Display for RuntimeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for RuntimeError {} + +impl From for RuntimeError { + fn from(_: std::io::Error) -> Self { + Self::Io + } +} + +impl From for RuntimeError { + fn from(_: serde_json::Error) -> Self { + Self::Serialization + } +} + +impl From for RuntimeError { + fn from(error: ProtocolError) -> Self { + Self::Protocol(error) + } +} diff --git a/src/runtime/identity.rs b/src/runtime/identity.rs new file mode 100644 index 0000000..c06e60e --- /dev/null +++ b/src/runtime/identity.rs @@ -0,0 +1,86 @@ +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::Serialize; + +use crate::protocol::{ + ContractDraft, ContractOffer, CredentialClaims, NodeId, ProtocolError, SealedContract, + SignedNodeCredential, WireEnvelope, +}; + +use super::RuntimeError; + +#[derive(Clone)] +pub struct NodeIdentity { + signing_key: SigningKey, + credential: SignedNodeCredential, + claims: CredentialClaims, + root: VerifyingKey, + validation_time_unix_ms: u64, +} + +impl NodeIdentity { + pub fn new( + signing_key: SigningKey, + credential: SignedNodeCredential, + root: VerifyingKey, + now_unix_ms: u64, + ) -> Result { + let claims = credential.verify(&root, now_unix_ms)?; + if claims.public_key != signing_key.verifying_key().to_bytes() { + return Err(ProtocolError::CredentialIssuerMismatch.into()); + } + Ok(Self { + signing_key, + credential, + claims, + root, + validation_time_unix_ms: now_unix_ms, + }) + } + + pub fn node_id(&self) -> &NodeId { + &self.claims.node_id + } + + pub fn claims(&self) -> &CredentialClaims { + &self.claims + } + + pub fn root(&self) -> &VerifyingKey { + &self.root + } + + pub fn validation_time_unix_ms(&self) -> u64 { + self.validation_time_unix_ms + } + + pub fn seal( + &self, + object_type: &str, + payload: &T, + ) -> Result { + WireEnvelope::seal( + object_type, + payload, + &self.signing_key, + self.credential.clone(), + ) + .map_err(RuntimeError::from) + } + + pub fn create_contract_offer( + &self, + draft: &ContractDraft, + ) -> Result { + ContractOffer::create(draft, &self.signing_key, self.credential.clone()) + .map_err(RuntimeError::from) + } + + pub fn countersign_contract( + &self, + offer: ContractOffer, + ) -> Result { + offer + .countersign(&self.signing_key, self.credential.clone()) + .map_err(RuntimeError::from) + } +} diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs new file mode 100644 index 0000000..7d999c0 --- /dev/null +++ b/src/runtime/key_store.rs @@ -0,0 +1,37 @@ +use std::{ + fs::{self, OpenOptions}, + io::Write, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + path::Path, +}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; + +use super::RuntimeError; + +pub fn write_signing_key(path: &Path, key: &SigningKey) -> Result<(), RuntimeError> { + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + file.write_all(STANDARD.encode(key.to_bytes()).as_bytes())?; + file.write_all(b"\n")?; + file.flush()?; + file.sync_data()?; + Ok(()) +} + +pub fn read_signing_key(path: &Path) -> Result { + let encoded = fs::read_to_string(path)?; + let bytes = STANDARD + .decode(encoded.trim()) + .map_err(|_| RuntimeError::InvalidPrivateKey)?; + let secret: [u8; 32] = bytes + .try_into() + .map_err(|_| RuntimeError::InvalidPrivateKey)?; + Ok(SigningKey::from_bytes(&secret)) +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 0000000..ac4bc1b --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,17 @@ +mod artifact; +mod artifact_access; +mod directory; +mod error; +mod identity; +mod key_store; +mod recorder; + +pub use artifact::ArtifactStore; +pub use artifact_access::ArtifactAccessService; +pub use directory::DirectoryRegistry; +pub use error::RuntimeError; +pub use identity::NodeIdentity; +pub use key_store::{read_signing_key, write_signing_key}; +pub use recorder::ContractRecorder; + +pub const MAX_ARTIFACT_BYTES: usize = 64 * 1024; diff --git a/src/runtime/recorder.rs b/src/runtime/recorder.rs new file mode 100644 index 0000000..3d6f40f --- /dev/null +++ b/src/runtime/recorder.rs @@ -0,0 +1,165 @@ +use std::{ + collections::HashMap, + fs::{self, File, OpenOptions}, + io::Write, + path::Path, +}; + +use ed25519_dalek::VerifyingKey; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use crate::protocol::{ + ContractEvent, ContractId, ContractProjection, ContractState, SealedContract, WireEnvelope, + apply_event, +}; + +use super::RuntimeError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "entry_type", rename_all = "snake_case")] +enum JournalEntry { + Contract { contract: SealedContract }, + Event { envelope: WireEnvelope }, +} + +struct RecorderState { + journal: File, + projections: HashMap, +} + +pub struct ContractRecorder { + root: VerifyingKey, + validation_time_unix_ms: u64, + state: Mutex, +} + +impl ContractRecorder { + pub async fn open( + state_directory: &Path, + root: VerifyingKey, + validation_time_unix_ms: u64, + ) -> Result { + fs::create_dir_all(state_directory)?; + let journal_path = state_directory.join("journal.jsonl"); + let contents = if journal_path.exists() { + fs::read_to_string(&journal_path)? + } else { + String::new() + }; + let mut projections = HashMap::new(); + for line in contents.lines() { + let entry: JournalEntry = serde_json::from_str(line)?; + replay_entry(entry, &root, validation_time_unix_ms, &mut projections)?; + } + let journal = OpenOptions::new() + .create(true) + .append(true) + .read(true) + .open(journal_path)?; + Ok(Self { + root, + validation_time_unix_ms, + state: Mutex::new(RecorderState { + journal, + projections, + }), + }) + } + + pub async fn register_contract( + &self, + contract: SealedContract, + ) -> Result { + let draft = contract.verify(&self.root, self.validation_time_unix_ms)?; + let mut state = self.state.lock().await; + if let Some(existing) = state.projections.get(&draft.contract_id) { + if existing.draft == draft { + return Ok(existing.state); + } + return Err(RuntimeError::ContractAlreadyExists); + } + append_entry(&mut state.journal, &JournalEntry::Contract { contract })?; + state + .projections + .insert(draft.contract_id.clone(), ContractProjection::new(draft)); + Ok(ContractState::Proposed) + } + + pub async fn append_event( + &self, + envelope: WireEnvelope, + ) -> Result { + let event: ContractEvent = envelope.open( + "contract.event.v1", + &self.root, + self.validation_time_unix_ms, + )?; + let mut state = self.state.lock().await; + let projection = state + .projections + .get(&event.contract_id) + .ok_or(RuntimeError::UnknownContract)?; + if projection + .events + .iter() + .any(|existing| existing.operation_id == event.operation_id) + { + return Ok(projection.state); + } + let mut next_projection = projection.clone(); + let next_state = apply_event(&mut next_projection, &event)?; + append_entry(&mut state.journal, &JournalEntry::Event { envelope })?; + state + .projections + .insert(event.contract_id.clone(), next_projection); + Ok(next_state) + } + + pub async fn projection( + &self, + contract_id: &ContractId, + ) -> Result { + self.state + .lock() + .await + .projections + .get(contract_id) + .cloned() + .ok_or(RuntimeError::UnknownContract) + } +} + +fn append_entry(journal: &mut File, entry: &JournalEntry) -> Result<(), RuntimeError> { + serde_json::to_writer(&mut *journal, entry)?; + journal.write_all(b"\n")?; + journal.flush()?; + journal.sync_data()?; + Ok(()) +} + +fn replay_entry( + entry: JournalEntry, + root: &VerifyingKey, + validation_time_unix_ms: u64, + projections: &mut HashMap, +) -> Result<(), RuntimeError> { + match entry { + JournalEntry::Contract { contract } => { + let draft = contract.verify(root, validation_time_unix_ms)?; + if projections.contains_key(&draft.contract_id) { + return Err(RuntimeError::ContractAlreadyExists); + } + projections.insert(draft.contract_id.clone(), ContractProjection::new(draft)); + } + JournalEntry::Event { envelope } => { + let event: ContractEvent = + envelope.open("contract.event.v1", root, validation_time_unix_ms)?; + let projection = projections + .get_mut(&event.contract_id) + .ok_or(RuntimeError::UnknownContract)?; + apply_event(projection, &event)?; + } + } + Ok(()) +} diff --git a/src/transport/client.rs b/src/transport/client.rs new file mode 100644 index 0000000..225a10c --- /dev/null +++ b/src/transport/client.rs @@ -0,0 +1,185 @@ +use std::{ + fmt::{Debug, Formatter}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +use ed25519_dalek::VerifyingKey; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::protocol::WireEnvelope; + +use super::MAX_JSON_BODY_BYTES; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct HttpStats { + pub requests: u64, + pub bytes_sent: u64, + pub bytes_received: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransportError { + InvalidEndpoint, + RequestFailed, + NonSuccessStatus(u16), + ResponseTooLarge, + InvalidResponse, + InvalidSignedResponse, +} + +#[derive(Default)] +struct Counters { + requests: AtomicU64, + bytes_sent: AtomicU64, + bytes_received: AtomicU64, +} + +#[derive(Clone)] +pub struct PeerClient { + client: reqwest::Client, + root: VerifyingKey, + validation_time_unix_ms: u64, + counters: Arc, +} + +impl Debug for PeerClient { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("PeerClient").finish_non_exhaustive() + } +} + +impl PeerClient { + pub fn new(root: VerifyingKey, validation_time_unix_ms: u64) -> Result { + Self::with_timeouts( + root, + validation_time_unix_ms, + Duration::from_secs(2), + Duration::from_secs(5), + ) + } + + pub fn with_timeouts( + root: VerifyingKey, + validation_time_unix_ms: u64, + connect_timeout: Duration, + request_timeout: Duration, + ) -> Result { + let client = reqwest::Client::builder() + .connect_timeout(connect_timeout) + .timeout(request_timeout) + .build() + .map_err(|_| TransportError::RequestFailed)?; + Ok(Self { + client, + root, + validation_time_unix_ms, + counters: Arc::new(Counters::default()), + }) + } + + pub async fn post_signed( + &self, + endpoint: &str, + path: &str, + envelope: &WireEnvelope, + expected_object_type: &str, + ) -> Result { + let url = endpoint_url(endpoint, path)?; + let request_bytes = + serde_json::to_vec(envelope).map_err(|_| TransportError::InvalidResponse)?; + self.counters.requests.fetch_add(1, Ordering::Relaxed); + self.counters + .bytes_sent + .fetch_add(request_bytes.len() as u64, Ordering::Relaxed); + let response = self + .client + .post(url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(request_bytes) + .send() + .await + .map_err(|_| TransportError::RequestFailed)?; + if !response.status().is_success() { + return Err(TransportError::NonSuccessStatus(response.status().as_u16())); + } + if response + .content_length() + .is_some_and(|length| length > MAX_JSON_BODY_BYTES as u64) + { + return Err(TransportError::ResponseTooLarge); + } + let bytes = response + .bytes() + .await + .map_err(|_| TransportError::RequestFailed)?; + if bytes.len() > MAX_JSON_BODY_BYTES { + return Err(TransportError::ResponseTooLarge); + } + self.counters + .bytes_received + .fetch_add(bytes.len() as u64, Ordering::Relaxed); + let response_envelope: WireEnvelope = + serde_json::from_slice(&bytes).map_err(|_| TransportError::InvalidResponse)?; + response_envelope + .open( + expected_object_type, + &self.root, + self.validation_time_unix_ms, + ) + .map_err(|_| TransportError::InvalidSignedResponse) + } + + pub async fn post_signed_read( + &self, + endpoint: &str, + path: &str, + envelope: &WireEnvelope, + expected_object_type: &str, + ) -> Result { + let mut last_error = TransportError::RequestFailed; + for attempt in 0..=2 { + match self + .post_signed(endpoint, path, envelope, expected_object_type) + .await + { + Ok(value) => return Ok(value), + Err( + error @ (TransportError::RequestFailed | TransportError::NonSuccessStatus(409)), + ) if attempt < 2 => { + last_error = error; + tokio::time::sleep(Duration::from_millis(50 * (attempt + 1))).await; + } + Err(error) => return Err(error), + } + } + Err(last_error) + } + + pub fn stats(&self) -> HttpStats { + HttpStats { + requests: self.counters.requests.load(Ordering::Relaxed), + bytes_sent: self.counters.bytes_sent.load(Ordering::Relaxed), + bytes_received: self.counters.bytes_received.load(Ordering::Relaxed), + } + } +} + +fn endpoint_url(endpoint: &str, path: &str) -> Result { + let endpoint = endpoint.trim_end_matches('/'); + let url = reqwest::Url::parse(&format!("{endpoint}{path}")) + .map_err(|_| TransportError::InvalidEndpoint)?; + let address: std::net::IpAddr = url + .host_str() + .ok_or(TransportError::InvalidEndpoint)? + .parse() + .map_err(|_| TransportError::InvalidEndpoint)?; + if url.scheme() != "http" || !address.is_loopback() || url.port().is_none() { + return Err(TransportError::InvalidEndpoint); + } + Ok(url) +} diff --git a/src/transport/directory.rs b/src/transport/directory.rs new file mode 100644 index 0000000..2be2d16 --- /dev/null +++ b/src/transport/directory.rs @@ -0,0 +1,133 @@ +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, State, rejection::JsonRejection}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde_json::json; + +use crate::{ + protocol::{CapabilityManifest, ErrorEnvelope, RouteQuery, WireEnvelope}, + runtime::{DirectoryRegistry, NodeIdentity}, +}; + +use super::MAX_JSON_BODY_BYTES; + +struct DirectoryHttpState { + registry: DirectoryRegistry, + identity: NodeIdentity, + now_unix_ms: u64, +} + +pub fn directory_router( + registry: DirectoryRegistry, + identity: NodeIdentity, + now_unix_ms: u64, +) -> Router { + let state = Arc::new(DirectoryHttpState { + registry, + identity, + now_unix_ms, + }); + Router::new() + .route("/healthz", get(health)) + .route("/v0/capabilities/register", post(register)) + .route("/v0/routes/query", post(query)) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .with_state(state) +} + +async fn health() -> Json { + Json(json!({"status": "ok"})) +} + +async fn register( + State(state): State>, + payload: Result, JsonRejection>, +) -> Response { + let envelope = match envelope_or_error(payload) { + Ok(envelope) => envelope, + Err(response) => return *response, + }; + let manifest: CapabilityManifest = match envelope.open( + "capability.manifest.v1", + state.identity.root(), + state.now_unix_ms, + ) { + Ok(manifest) => manifest, + Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; + if state + .registry + .register(&envelope.issuer_id, manifest, state.now_unix_ms) + .await + .is_err() + { + return error_response(StatusCode::FORBIDDEN, "InvalidCapabilityManifest"); + } + match state + .identity + .seal("capability.registration.v1", &json!({"registered": true})) + { + Ok(response) => (StatusCode::OK, Json(response)).into_response(), + Err(_) => error_response(StatusCode::INTERNAL_SERVER_ERROR, "InternalError"), + } +} + +async fn query( + State(state): State>, + payload: Result, JsonRejection>, +) -> Response { + let envelope = match envelope_or_error(payload) { + Ok(envelope) => envelope, + Err(response) => return *response, + }; + let query: RouteQuery = + match envelope.open("route.query.v1", state.identity.root(), state.now_unix_ms) { + Ok(query) => query, + Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; + let candidates = state.registry.query(query, state.now_unix_ms).await; + match state.identity.seal("route.candidates.v1", &candidates) { + Ok(response) => (StatusCode::OK, Json(response)).into_response(), + Err(_) => error_response(StatusCode::INTERNAL_SERVER_ERROR, "InternalError"), + } +} + +fn envelope_or_error( + payload: Result, JsonRejection>, +) -> Result> { + match payload { + Ok(Json(envelope)) => Ok(envelope), + Err(rejection) if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE => Err(Box::new( + error_response(StatusCode::PAYLOAD_TOO_LARGE, "RequestBodyTooLarge"), + )), + Err(_) => Err(Box::new(error_response( + StatusCode::UNAUTHORIZED, + "SignedEnvelopeRequired", + ))), + } +} + +fn error_response(status: StatusCode, code: &str) -> Response { + let message = match code { + "RequestBodyTooLarge" => "request body exceeds the configured limit", + "SignedEnvelopeRequired" => "a valid signed envelope is required", + "InvalidSignedEnvelope" => "the signed envelope could not be verified", + "InvalidCapabilityManifest" => "the capability manifest was rejected", + _ => "the request could not be completed", + }; + ( + status, + Json(ErrorEnvelope { + code: code.to_owned(), + message: message.to_owned(), + retryable: false, + operation_id: "http:unavailable".to_owned(), + }), + ) + .into_response() +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs new file mode 100644 index 0000000..a90ddcf --- /dev/null +++ b/src/transport/mod.rs @@ -0,0 +1,7 @@ +mod client; +mod directory; + +pub use client::{HttpStats, PeerClient, TransportError}; +pub use directory::directory_router; + +pub const MAX_JSON_BODY_BYTES: usize = 256 * 1024; diff --git a/tests/http_client.rs b/tests/http_client.rs new file mode 100644 index 0000000..aae9b45 --- /dev/null +++ b/tests/http_client.rs @@ -0,0 +1,86 @@ +use std::time::Duration; + +use agenet::{ + protocol::{CredentialClaims, NodeId, NodeRole, SignedNodeCredential, WireEnvelope}, + transport::{MAX_JSON_BODY_BYTES, PeerClient, TransportError}, +}; +use axum::{Router, http::StatusCode, response::IntoResponse, routing::post}; +use ed25519_dalek::SigningKey; +use serde_json::json; +use tokio::net::TcpListener; + +const NOW: u64 = 1_800_000_000; + +async fn unavailable() -> impl IntoResponse { + ( + StatusCode::SERVICE_UNAVAILABLE, + "do not expose upstream details", + ) +} + +async fn oversized() -> impl IntoResponse { + vec![b'x'; MAX_JSON_BODY_BYTES + 1] +} + +async fn slow() -> impl IntoResponse { + tokio::time::sleep(Duration::from_millis(250)).await; + "late" +} + +#[tokio::test] +async fn peer_client_maps_non_success_oversize_and_timeout_without_urls() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new() + .route("/unavailable", post(unavailable)) + .route("/oversized", post(oversized)) + .route("/slow", post(slow)), + ) + .await + .unwrap(); + }); + let root = SigningKey::from_bytes(&[70; 32]); + let node = SigningKey::from_bytes(&[71; 32]); + let credential = SignedNodeCredential::issue( + &root, + CredentialClaims { + node_id: NodeId::new("node:requester").unwrap(), + public_key: node.verifying_key().to_bytes(), + role: NodeRole::Requester, + issued_at_unix_ms: NOW - 1, + expires_at_unix_ms: NOW + 1, + }, + ) + .unwrap(); + let envelope = WireEnvelope::seal("test.v1", &json!({}), &node, credential).unwrap(); + let client = PeerClient::with_timeouts( + root.verifying_key(), + NOW, + Duration::from_millis(100), + Duration::from_millis(100), + ) + .unwrap(); + + let non_success = client + .post_signed::(&endpoint, "/unavailable", &envelope, "response.v1") + .await + .unwrap_err(); + assert_eq!(non_success, TransportError::NonSuccessStatus(503)); + + let oversized = client + .post_signed::(&endpoint, "/oversized", &envelope, "response.v1") + .await + .unwrap_err(); + assert_eq!(oversized, TransportError::ResponseTooLarge); + + let timeout = client + .post_signed::(&endpoint, "/slow", &envelope, "response.v1") + .await + .unwrap_err(); + assert_eq!(timeout, TransportError::RequestFailed); + assert!(!format!("{timeout:?}").contains(&endpoint)); + server.abort(); +} diff --git a/tests/http_directory.rs b/tests/http_directory.rs new file mode 100644 index 0000000..e14e19c --- /dev/null +++ b/tests/http_directory.rs @@ -0,0 +1,159 @@ +use agenet::{ + protocol::{ + CandidateSet, CapabilityId, CapabilityManifest, CredentialClaims, NodeId, NodeRole, + RouteQuery, SideEffectProfile, SignedNodeCredential, WireEnvelope, + }, + runtime::{DirectoryRegistry, NodeIdentity}, + transport::{MAX_JSON_BODY_BYTES, directory_router}, +}; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use ed25519_dalek::SigningKey; +use http_body_util::BodyExt; +use tower::ServiceExt; + +const NOW: u64 = 1_800_000_000; + +fn signing_key(byte: u8) -> SigningKey { + SigningKey::from_bytes(&[byte; 32]) +} + +fn credential( + root: &SigningKey, + node: &SigningKey, + node_id: &str, + role: NodeRole, +) -> SignedNodeCredential { + SignedNodeCredential::issue( + root, + CredentialClaims { + node_id: NodeId::new(node_id).unwrap(), + public_key: node.verifying_key().to_bytes(), + role, + issued_at_unix_ms: NOW - 1, + expires_at_unix_ms: NOW + 60_000, + }, + ) + .unwrap() +} + +fn identity(root: &SigningKey, node: SigningKey, node_id: &str, role: NodeRole) -> NodeIdentity { + let credential = credential(root, &node, node_id, role); + NodeIdentity::new(node, credential, root.verifying_key(), NOW).unwrap() +} + +#[tokio::test] +async fn signed_manifest_registration_and_deterministic_query_round_trip() { + let root = signing_key(40); + let directory = identity( + &root, + signing_key(41), + "node:directory", + NodeRole::Directory, + ); + let executor = identity(&root, signing_key(42), "node:executor", NodeRole::Executor); + let requester = identity( + &root, + signing_key(43), + "node:requester", + NodeRole::Requester, + ); + let app = directory_router(DirectoryRegistry::new(), directory, NOW); + + let manifest = CapabilityManifest { + capability_id: CapabilityId::new("capability:source-metrics").unwrap(), + provider: executor.node_id().clone(), + kind: "source.metrics".to_owned(), + version: "v1".to_owned(), + description: "Compute source metrics".to_owned(), + input_profile: "artifact.source.utf8.v1".to_owned(), + output_profile: "source.metrics.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: "http://127.0.0.1:41414".to_owned(), + evidence_types: vec!["source.metrics.evidence.v1".to_owned()], + expires_at_unix_ms: NOW + 60_000, + }; + let register = executor.seal("capability.manifest.v1", &manifest).unwrap(); + let register_response = app + .clone() + .oneshot( + Request::post("/v0/capabilities/register") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(®ister).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(register_response.status(), StatusCode::OK); + + let query = requester + .seal( + "route.query.v1", + &RouteQuery { + required_capability: "source.metrics.v1".to_owned(), + }, + ) + .unwrap(); + let query_response = app + .oneshot( + Request::post("/v0/routes/query") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&query).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(query_response.status(), StatusCode::OK); + let bytes = query_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let envelope: WireEnvelope = serde_json::from_slice(&bytes).unwrap(); + let candidates: CandidateSet = envelope + .open("route.candidates.v1", &root.verifying_key(), NOW) + .unwrap(); + assert_eq!(candidates.candidates, vec![manifest]); +} + +#[tokio::test] +async fn unsigned_and_oversized_directory_requests_are_rejected() { + let root = signing_key(50); + let directory = identity( + &root, + signing_key(51), + "node:directory", + NodeRole::Directory, + ); + let app = directory_router(DirectoryRegistry::new(), directory, NOW); + + let unsigned = app + .clone() + .oneshot( + Request::post("/v0/routes/query") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unsigned.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + unsigned.headers().get("content-type").unwrap(), + "application/json" + ); + + let oversized = app + .oneshot( + Request::post("/v0/routes/query") + .header("content-type", "application/json") + .body(Body::from(vec![b'x'; MAX_JSON_BODY_BYTES + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); +} diff --git a/tests/protocol_kernel.rs b/tests/protocol_kernel.rs index 719b533..da002fa 100644 --- a/tests/protocol_kernel.rs +++ b/tests/protocol_kernel.rs @@ -1,8 +1,8 @@ use agenet::protocol::{ AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, CandidateSet, CapabilityId, - CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractProjection, - ContractState, CredentialClaims, ErrorEnvelope, EventKind, EvidenceClaim, Grant, IntentId, - IntentProjection, NodeId, NodeRole, ProtocolError, RouteQuery, SealedContract, + CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractOffer, + ContractProjection, ContractState, CredentialClaims, ErrorEnvelope, EventKind, EvidenceClaim, + Grant, IntentId, IntentProjection, NodeId, NodeRole, ProtocolError, RouteQuery, SealedContract, SideEffectProfile, SignedNodeCredential, SourceMetrics, WireEnvelope, apply_event, event_hash, }; use base64::{Engine, engine::general_purpose::STANDARD}; @@ -337,6 +337,28 @@ fn bilateral_contract_signatures_cover_identical_draft_bytes() { assert!(tampered.verify(&root.verifying_key(), NOW).is_err()); } +#[test] +fn requester_offer_is_countersigned_by_the_provider() { + let root = signing_key(23); + let requester = signing_key(24); + let executor = signing_key(25); + let offer = ContractOffer::create( + &draft(), + &requester, + credential(&root, &requester, "node:requester", NodeRole::Requester), + ) + .unwrap(); + assert_eq!(offer.verify(&root.verifying_key(), NOW).unwrap(), draft()); + + let sealed = offer + .countersign( + &executor, + credential(&root, &executor, "node:executor", NodeRole::Executor), + ) + .unwrap(); + assert_eq!(sealed.verify(&root.verifying_key(), NOW).unwrap(), draft()); +} + #[test] fn reducer_rejects_illegal_transitions_and_duplicate_effects() { let draft = draft(); diff --git a/tests/runtime_storage.rs b/tests/runtime_storage.rs new file mode 100644 index 0000000..10eeb66 --- /dev/null +++ b/tests/runtime_storage.rs @@ -0,0 +1,226 @@ +use std::{fs, os::unix::fs::PermissionsExt}; + +use agenet::{ + protocol::{ + AcceptanceProfile, ArtifactId, ArtifactRef, CapabilityId, ContractDraft, ContractEvent, + ContractId, ContractState, CredentialClaims, EventKind, Grant, IntentId, NodeId, NodeRole, + SealedContract, SignedNodeCredential, WireEnvelope, event_hash, + }, + runtime::{ArtifactStore, ContractRecorder, RuntimeError, read_signing_key, write_signing_key}, +}; +use ed25519_dalek::SigningKey; +use tempfile::TempDir; + +const NOW: u64 = 1_800_000_000; + +fn signing_key(byte: u8) -> SigningKey { + SigningKey::from_bytes(&[byte; 32]) +} + +fn credential( + root: &SigningKey, + node: &SigningKey, + node_id: &str, + role: NodeRole, +) -> SignedNodeCredential { + SignedNodeCredential::issue( + root, + CredentialClaims { + node_id: NodeId::new(node_id).unwrap(), + public_key: node.verifying_key().to_bytes(), + role, + issued_at_unix_ms: NOW - 1_000, + expires_at_unix_ms: NOW + 60_000, + }, + ) + .unwrap() +} + +#[test] +fn signing_keys_are_stored_with_owner_only_permissions() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("identity.key"); + let key = signing_key(7); + + write_signing_key(&path, &key).unwrap(); + + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(read_signing_key(&path).unwrap().to_bytes(), key.to_bytes()); +} + +#[test] +fn artifact_store_is_content_addressed_and_detects_tampering() { + let temp = TempDir::new().unwrap(); + let store = ArtifactStore::open(temp.path(), NodeId::new("node:requester").unwrap()).unwrap(); + let bytes = b"fn main() {}\n"; + + let artifact = store.import(bytes, "text/x-rust").unwrap(); + assert_eq!(artifact.byte_count, bytes.len() as u64); + assert_eq!(store.read(&artifact).unwrap(), bytes); + + let stored_file = fs::read_dir(temp.path().join("artifacts")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + fs::write(stored_file, b"tampered").unwrap(); + assert_eq!( + store.read(&artifact), + Err(RuntimeError::ArtifactIntegrityMismatch) + ); +} + +fn contract(root: &SigningKey, requester: &SigningKey, executor: &SigningKey) -> SealedContract { + let artifact_id = ArtifactId::new("sha256:abcd").unwrap(); + SealedContract::seal( + &ContractDraft { + contract_id: ContractId::new("contract:source").unwrap(), + parent_contract_id: None, + intent_id: IntentId::new("intent:metrics").unwrap(), + requester: NodeId::new("node:requester").unwrap(), + provider: NodeId::new("node:executor").unwrap(), + capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + grant: Grant { + issuer: NodeId::new("node:requester").unwrap(), + subject: NodeId::new("node:executor").unwrap(), + capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + artifact_id: artifact_id.clone(), + expires_at_unix_ms: NOW + 60_000, + delegation_depth: 0, + }, + artifact: ArtifactRef { + artifact_id, + byte_count: 4, + media_type: "text/x-rust".to_owned(), + owner: NodeId::new("node:requester").unwrap(), + }, + acceptance: AcceptanceProfile::ExactSourceMetricsV1, + expires_at_unix_ms: NOW + 60_000, + }, + requester, + credential(root, requester, "node:requester", NodeRole::Requester), + executor, + credential(root, executor, "node:executor", NodeRole::Executor), + ) + .unwrap() +} + +struct EventSpec<'a> { + role: NodeRole, + node_id: &'a str, + sequence: u64, + previous_hash: Option, + operation_id: &'a str, + kind: EventKind, +} + +fn event( + root: &SigningKey, + signer: &SigningKey, + spec: EventSpec<'_>, +) -> (ContractEvent, WireEnvelope) { + let event = ContractEvent { + contract_id: ContractId::new("contract:source").unwrap(), + event_id: format!("event:{}", spec.sequence), + sequence: spec.sequence, + previous_hash: spec.previous_hash, + operation_id: spec.operation_id.to_owned(), + issuer: NodeId::new(spec.node_id).unwrap(), + kind: spec.kind, + payload: serde_json::json!({}), + occurred_at_unix_ms: NOW + spec.sequence, + }; + let envelope = WireEnvelope::seal( + "contract.event.v1", + &event, + signer, + credential(root, signer, spec.node_id, spec.role), + ) + .unwrap(); + (event, envelope) +} + +#[tokio::test] +async fn journal_replays_projection_and_deduplicates_operations() { + let temp = TempDir::new().unwrap(); + let root = signing_key(10); + let requester = signing_key(11); + let executor = signing_key(12); + let recorder = ContractRecorder::open(temp.path(), root.verifying_key(), NOW) + .await + .unwrap(); + recorder + .register_contract(contract(&root, &requester, &executor)) + .await + .unwrap(); + + let (active, active_envelope) = event( + &root, + &executor, + EventSpec { + role: NodeRole::Executor, + node_id: "node:executor", + sequence: 1, + previous_hash: None, + operation_id: "op:active", + kind: EventKind::Activated, + }, + ); + recorder.append_event(active_envelope).await.unwrap(); + let (_, running_envelope) = event( + &root, + &executor, + EventSpec { + role: NodeRole::Executor, + node_id: "node:executor", + sequence: 2, + previous_hash: Some(event_hash(&active)), + operation_id: "op:running", + kind: EventKind::Started, + }, + ); + recorder.append_event(running_envelope).await.unwrap(); + + let line_count_before = fs::read_to_string(temp.path().join("journal.jsonl")) + .unwrap() + .lines() + .count(); + let (_, duplicate_operation) = event( + &root, + &executor, + EventSpec { + role: NodeRole::Executor, + node_id: "node:executor", + sequence: 99, + previous_hash: None, + operation_id: "op:running", + kind: EventKind::Started, + }, + ); + assert_eq!( + recorder.append_event(duplicate_operation).await.unwrap(), + ContractState::Running + ); + assert_eq!( + fs::read_to_string(temp.path().join("journal.jsonl")) + .unwrap() + .lines() + .count(), + line_count_before + ); + + drop(recorder); + let replayed = ContractRecorder::open(temp.path(), root.verifying_key(), NOW) + .await + .unwrap(); + let projection = replayed + .projection(&ContractId::new("contract:source").unwrap()) + .await + .unwrap(); + assert_eq!(projection.state, ContractState::Running); + assert_eq!(projection.events.len(), 2); +} From 2033c0fded2cfa7b6fd8de618003c05be528dd91 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Thu, 13 Aug 2026 23:53:33 +0800 Subject: [PATCH 03/67] [feat][Flow] Implement verified metrics flow Root cause: NA Solution: Add Contract-authorized Artifact reads, real source metrics, independent recomputation, dynamic provider routing, and requester-only Evidence-gated acceptance. Risks: The workload is read-only and does not prove arbitrary task execution or sandbox isolation. Dependency: 6029c1f Links: plan/00-v1-local-loopback-mvp.md --- ROADMAP.md | 7 + docs/design/agenet-v0.1.md | 6 + src/adapters/decision.rs | 206 ++++++++++++++++++ src/adapters/executor_metrics.rs | 25 +++ src/adapters/mod.rs | 8 + src/adapters/verifier_metrics.rs | 33 +++ src/lib.rs | 1 + src/runtime/mod.rs | 4 + src/runtime/provider.rs | 216 ++++++++++++++++++ src/runtime/requester.rs | 362 +++++++++++++++++++++++++++++++ src/transport/mod.rs | 2 + src/transport/node.rs | 243 +++++++++++++++++++++ tests/http_artifact.rs | 162 ++++++++++++++ tests/llm_adapter.rs | 100 +++++++++ tests/source_metrics.rs | 65 ++++++ 15 files changed, 1440 insertions(+) create mode 100644 src/adapters/decision.rs create mode 100644 src/adapters/executor_metrics.rs create mode 100644 src/adapters/mod.rs create mode 100644 src/adapters/verifier_metrics.rs create mode 100644 src/runtime/provider.rs create mode 100644 src/runtime/requester.rs create mode 100644 src/transport/node.rs create mode 100644 tests/http_artifact.rs create mode 100644 tests/llm_adapter.rs create mode 100644 tests/source_metrics.rs diff --git a/ROADMAP.md b/ROADMAP.md index c996af4..f02583e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,12 @@ # ROADMAP +## 2026-08-13 23:30 CST + +- **Change**: Implemented the real source-metrics workload, Contract-authorized Artifact reads, provider execution, independent verification, and evidence-gated acceptance. +- **Files**: `src/adapters/`, `src/runtime/provider.rs`, `src/runtime/requester.rs`, `src/transport/node.rs`, `tests/source_metrics.rs`, `tests/http_artifact.rs`, `tests/llm_adapter.rs`. +- **Decision**: Keep Executor and Verifier metric implementations separate and compare both Evidence claims again at the Requester before emitting `Accepted`. +- **Reason**: Prevent delivery, a single implementation, or a provider-controlled verifier from being sufficient for acceptance. + ## 2026-08-13 23:10 CST - **Change**: Implemented durable per-node storage and the loopback Directory/HTTP transport baseline. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 50ee06c..3873e4c 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -42,6 +42,12 @@ Each node state directory owns an append-only `journal.jsonl`, an Artifact direc The HTTP adapter rejects non-loopback Capability endpoints, caps JSON bodies at 256 KiB, maps failures to sanitized typed errors, and gives read-only requests a bounded retry path. Directory matching is deterministic equality on the public `kind.version`; it neither invokes a model nor selects a final provider. +## Verified source-metrics flow + +The Requester imports UTF-8 source bytes, asks the Directory separately for `source.metrics.v1` and `source.metrics.verify.v1`, and signs a scoped bilateral Contract for each provider. The Executor and Verifier retrieve bytes through signed Artifact requests whose caller, Contract, Capability, Artifact hash, length, and expiry are checked. The two providers use separate metric implementations. The verification Contract links to the source Contract through `parent_contract_id`. + +Only the Requester can append `Accepted`, and it does so only after matching the Artifact reference and every SourceMetrics field. The Accepted Event carries the verification Contract ID and a hash of the verifying Evidence. A verifier failure leaves the source Contract at `Delivered`. + ## Deliberately deferred - TLS and cross-machine peer sessions diff --git a/src/adapters/decision.rs b/src/adapters/decision.rs new file mode 100644 index 0000000..a594361 --- /dev/null +++ b/src/adapters/decision.rs @@ -0,0 +1,206 @@ +use std::{fmt::Debug, time::Duration}; + +use reqwest::{ + Client, Url, + header::{AUTHORIZATION, HeaderMap, HeaderValue}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +const MAX_LLM_RESPONSE_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AgentDecision { + pub required_capability: String, + pub acceptance_profile: String, + pub requires_independent_verifier: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecisionResult { + pub decision: AgentDecision, + pub llm_calls: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DecisionError { + InvalidConfiguration, + RequestFailed, + ResponseTooLarge, + AgentDecisionInvalid, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct DeterministicDecisionAdapter; + +impl DeterministicDecisionAdapter { + pub fn decide_for_protocol_tests(&self) -> DecisionResult { + DecisionResult { + decision: AgentDecision { + required_capability: "source.metrics.v1".to_owned(), + acceptance_profile: "exact-source-metrics.v1".to_owned(), + requires_independent_verifier: true, + }, + llm_calls: 0, + } + } +} + +pub struct OpenAiDecisionAdapter { + client: Client, + endpoint: Url, + model: String, +} + +impl Debug for OpenAiDecisionAdapter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OpenAiDecisionAdapter") + .field("endpoint", &self.endpoint) + .field("model", &self.model) + .finish_non_exhaustive() + } +} + +impl OpenAiDecisionAdapter { + pub fn new(base_url: &str, api_key: &str, model: &str) -> Result { + if api_key.is_empty() || model.is_empty() { + return Err(DecisionError::InvalidConfiguration); + } + let endpoint = completion_endpoint(base_url)?; + let mut authorization = HeaderValue::from_str(&format!("Bearer {api_key}")) + .map_err(|_| DecisionError::InvalidConfiguration)?; + authorization.set_sensitive(true); + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, authorization); + let client = Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(60)) + .default_headers(headers) + .build() + .map_err(|_| DecisionError::InvalidConfiguration)?; + Ok(Self { + client, + endpoint, + model: model.to_owned(), + }) + } + + pub async fn decide(&self, goal: &str) -> Result { + let first = self + .completion(vec![json!({ + "role": "user", + "content": decision_prompt(goal), + })]) + .await?; + if let Ok(decision) = parse_decision(&first) { + return Ok(DecisionResult { + decision, + llm_calls: 1, + }); + } + + let repaired = self + .completion(vec![ + json!({"role": "user", "content": decision_prompt(goal)}), + json!({"role": "assistant", "content": first}), + json!({ + "role": "user", + "content": "Return only one JSON object matching the requested schema. Do not use Markdown or add fields.", + }), + ]) + .await?; + let decision = + parse_decision(&repaired).map_err(|_| DecisionError::AgentDecisionInvalid)?; + Ok(DecisionResult { + decision, + llm_calls: 2, + }) + } + + async fn completion(&self, messages: Vec) -> Result { + let response = self + .client + .post(self.endpoint.clone()) + .json(&json!({ + "model": self.model, + "temperature": 0, + "messages": messages, + })) + .send() + .await + .map_err(|_| DecisionError::RequestFailed)?; + if !response.status().is_success() { + return Err(DecisionError::RequestFailed); + } + if response + .content_length() + .is_some_and(|length| length > MAX_LLM_RESPONSE_BYTES as u64) + { + return Err(DecisionError::ResponseTooLarge); + } + let bytes = response + .bytes() + .await + .map_err(|_| DecisionError::RequestFailed)?; + if bytes.len() > MAX_LLM_RESPONSE_BYTES { + return Err(DecisionError::ResponseTooLarge); + } + let response: CompletionResponse = + serde_json::from_slice(&bytes).map_err(|_| DecisionError::RequestFailed)?; + response + .choices + .into_iter() + .next() + .map(|choice| choice.message.content) + .ok_or(DecisionError::RequestFailed) + } +} + +#[derive(Deserialize)] +struct CompletionResponse { + choices: Vec, +} + +#[derive(Deserialize)] +struct CompletionChoice { + message: CompletionMessage, +} + +#[derive(Deserialize)] +struct CompletionMessage { + content: String, +} + +fn completion_endpoint(base_url: &str) -> Result { + let base_url = base_url.trim_end_matches('/'); + let endpoint = if base_url.ends_with("/chat/completions") { + base_url.to_owned() + } else { + format!("{base_url}/chat/completions") + }; + let url = Url::parse(&endpoint).map_err(|_| DecisionError::InvalidConfiguration)?; + if !matches!(url.scheme(), "http" | "https") { + return Err(DecisionError::InvalidConfiguration); + } + Ok(url) +} + +fn decision_prompt(goal: &str) -> String { + format!( + "Project this goal into an AgenNet Intent. Public capability kind: source.metrics.v1. Goal: {goal}\nReturn only strict JSON with exactly these fields: required_capability (must be source.metrics.v1), acceptance_profile (must be exact-source-metrics.v1), requires_independent_verifier (must be true)." + ) +} + +fn parse_decision(content: &str) -> Result { + let decision: AgentDecision = + serde_json::from_str(content).map_err(|_| DecisionError::AgentDecisionInvalid)?; + if decision.required_capability != "source.metrics.v1" + || decision.acceptance_profile != "exact-source-metrics.v1" + || !decision.requires_independent_verifier + { + return Err(DecisionError::AgentDecisionInvalid); + } + Ok(decision) +} diff --git a/src/adapters/executor_metrics.rs b/src/adapters/executor_metrics.rs new file mode 100644 index 0000000..e77519b --- /dev/null +++ b/src/adapters/executor_metrics.rs @@ -0,0 +1,25 @@ +use std::fmt::Write; + +use sha2::{Digest, Sha256}; + +use crate::{protocol::SourceMetrics, runtime::RuntimeError}; + +pub fn compute(bytes: &[u8]) -> Result { + let source = + std::str::from_utf8(bytes).map_err(|_| RuntimeError::UnsupportedArtifactEncoding)?; + let digest = Sha256::digest(bytes); + let mut sha256 = String::with_capacity(71); + sha256.push_str("sha256:"); + for byte in digest { + write!(&mut sha256, "{byte:02x}").map_err(|_| RuntimeError::Serialization)?; + } + Ok(SourceMetrics { + sha256, + byte_count: bytes.len() as u64, + line_count: source.lines().count() as u64, + non_empty_line_count: source + .lines() + .filter(|line| !line.trim().is_empty()) + .count() as u64, + }) +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs new file mode 100644 index 0000000..b095221 --- /dev/null +++ b/src/adapters/mod.rs @@ -0,0 +1,8 @@ +mod decision; + +pub use decision::{ + AgentDecision, DecisionError, DecisionResult, DeterministicDecisionAdapter, + OpenAiDecisionAdapter, +}; +pub mod executor_metrics; +pub mod verifier_metrics; diff --git a/src/adapters/verifier_metrics.rs b/src/adapters/verifier_metrics.rs new file mode 100644 index 0000000..f4fb088 --- /dev/null +++ b/src/adapters/verifier_metrics.rs @@ -0,0 +1,33 @@ +use std::fmt::Write; + +use sha2::{Digest, Sha256}; + +use crate::{protocol::SourceMetrics, runtime::RuntimeError}; + +pub fn recompute(bytes: &[u8]) -> Result { + let source = + std::str::from_utf8(bytes).map_err(|_| RuntimeError::UnsupportedArtifactEncoding)?; + let digest = Sha256::digest(bytes); + let mut sha256 = String::with_capacity(71); + sha256.push_str("sha256:"); + for byte in digest { + write!(&mut sha256, "{byte:02x}").map_err(|_| RuntimeError::Serialization)?; + } + Ok(SourceMetrics { + sha256, + byte_count: bytes.len() as u64, + line_count: source.lines().count() as u64, + non_empty_line_count: source + .lines() + .filter(|line| !line.trim().is_empty()) + .count() as u64, + }) +} + +pub fn verify(bytes: &[u8], delivered: &SourceMetrics) -> Result { + let recomputed = recompute(bytes)?; + if &recomputed != delivered { + return Err(RuntimeError::EvidenceMismatch); + } + Ok(recomputed) +} diff --git a/src/lib.rs b/src/lib.rs index 7e57202..b2b14d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod adapters; pub mod protocol; pub mod runtime; pub mod transport; diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index ac4bc1b..26626f4 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -4,7 +4,9 @@ mod directory; mod error; mod identity; mod key_store; +mod provider; mod recorder; +mod requester; pub use artifact::ArtifactStore; pub use artifact_access::ArtifactAccessService; @@ -12,6 +14,8 @@ pub use directory::DirectoryRegistry; pub use error::RuntimeError; pub use identity::NodeIdentity; pub use key_store::{read_signing_key, write_signing_key}; +pub use provider::ProviderService; pub use recorder::ContractRecorder; +pub use requester::{PursuitRequest, PursuitResult, RequesterService}; pub const MAX_ARTIFACT_BYTES: usize = 64 * 1024; diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs new file mode 100644 index 0000000..8973fd6 --- /dev/null +++ b/src/runtime/provider.rs @@ -0,0 +1,216 @@ +use std::{sync::Arc, time::Duration}; + +use base64::{Engine, engine::general_purpose::STANDARD}; + +use crate::{ + adapters::{executor_metrics, verifier_metrics}, + protocol::{ + ArtifactPayload, ArtifactReadRequest, ContractDraft, ContractEvent, ContractId, + ContractProjection, ContractProposeRequest, ContractProposeResponse, ContractQuery, + ContractState, EventKind, EvidenceClaim, NodeRole, WireEnvelope, event_hash, + }, + transport::{PeerClient, TransportError}, +}; + +use super::{ContractRecorder, NodeIdentity, RuntimeError}; + +#[derive(Clone)] +pub struct ProviderService { + identity: NodeIdentity, + recorder: Arc, + client: PeerClient, + role: NodeRole, + now_unix_ms: u64, +} + +impl ProviderService { + pub fn new( + identity: NodeIdentity, + recorder: Arc, + client: PeerClient, + role: NodeRole, + now_unix_ms: u64, + ) -> Result { + if !matches!(role, NodeRole::Executor | NodeRole::Verifier) + || identity.claims().role != role + { + return Err(RuntimeError::CredentialRoleMismatch); + } + Ok(Self { + identity, + recorder, + client, + role, + now_unix_ms, + }) + } + + pub fn identity(&self) -> &NodeIdentity { + &self.identity + } + + pub fn validation_time_unix_ms(&self) -> u64 { + self.now_unix_ms + } + + pub async fn propose( + &self, + issuer: &crate::protocol::NodeId, + request: ContractProposeRequest, + ) -> Result { + let draft = request + .offer + .verify(self.identity.root(), self.now_unix_ms)?; + if issuer != &draft.requester || &draft.provider != self.identity.node_id() { + return Err(RuntimeError::ArtifactAccessDenied); + } + let sealed = self.identity.countersign_contract(request.offer.clone())?; + self.recorder.register_contract(sealed.clone()).await?; + let service = self.clone(); + tokio::spawn(async move { + if let Err(error) = service.execute(draft, request).await { + tracing::warn!(code = ?error, "provider execution failed"); + } + }); + Ok(ContractProposeResponse { contract: sealed }) + } + + pub async fn append(&self, envelope: WireEnvelope) -> Result { + self.recorder.append_event(envelope).await + } + + pub async fn query(&self, query: &ContractQuery) -> Result { + self.recorder.projection(&query.contract_id).await + } + + async fn execute( + &self, + draft: ContractDraft, + request: ContractProposeRequest, + ) -> Result<(), RuntimeError> { + tokio::time::sleep(Duration::from_millis(100)).await; + self.append_provider_event( + &draft.contract_id, + EventKind::Activated, + serde_json::json!({}), + ) + .await?; + self.append_provider_event( + &draft.contract_id, + EventKind::Started, + serde_json::json!({}), + ) + .await?; + match self.execute_started(&draft, &request).await { + Ok(evidence) => { + self.append_provider_event( + &draft.contract_id, + EventKind::Delivered, + serde_json::to_value(evidence)?, + ) + .await?; + Ok(()) + } + Err(error) => self.fail(&draft.contract_id, error).await, + } + } + + async fn execute_started( + &self, + draft: &ContractDraft, + request: &ContractProposeRequest, + ) -> Result { + let read_request = ArtifactReadRequest { + contract_id: draft.contract_id.clone(), + artifact_id: draft.artifact.artifact_id.clone(), + }; + let read_envelope = self.identity.seal("artifact.read.v1", &read_request)?; + let artifact: ArtifactPayload = self + .client + .post_signed_read( + &request.artifact_endpoint, + "/v0/artifacts/read", + &read_envelope, + "artifact.payload.v1", + ) + .await + .map_err(map_transport)?; + let bytes = STANDARD + .decode(&artifact.bytes_base64) + .map_err(|_| RuntimeError::ArtifactIntegrityMismatch)?; + if artifact.artifact != draft.artifact { + return Err(RuntimeError::ArtifactIntegrityMismatch); + } + let metrics = match self.role { + NodeRole::Executor => executor_metrics::compute(&bytes)?, + NodeRole::Verifier => { + let expected = request + .expected_metrics + .as_ref() + .ok_or(RuntimeError::EvidenceMismatch)?; + verifier_metrics::verify(&bytes, expected)? + } + _ => return Err(RuntimeError::CredentialRoleMismatch), + }; + if metrics.sha256 != draft.artifact.artifact_id.as_str() + || metrics.byte_count != draft.artifact.byte_count + { + return Err(RuntimeError::ArtifactIntegrityMismatch); + } + Ok(EvidenceClaim { + artifact: draft.artifact.clone(), + capability_version: draft.capability_id.as_str().to_owned(), + metrics, + producer: self.identity.node_id().clone(), + executed_at_unix_ms: unix_ms(), + }) + } + + async fn fail( + &self, + contract_id: &ContractId, + error: RuntimeError, + ) -> Result<(), RuntimeError> { + let _ = self + .append_provider_event( + contract_id, + EventKind::Failed, + serde_json::json!({"code": format!("{error:?}")}), + ) + .await; + Err(error) + } + + async fn append_provider_event( + &self, + contract_id: &ContractId, + kind: EventKind, + payload: serde_json::Value, + ) -> Result { + let projection = self.recorder.projection(contract_id).await?; + let event = ContractEvent { + contract_id: contract_id.clone(), + event_id: format!("event:{}", uuid::Uuid::new_v4()), + sequence: projection.events.len() as u64 + 1, + previous_hash: projection.events.last().map(event_hash), + operation_id: format!("op:{}:{kind:?}", contract_id.as_str()), + issuer: self.identity.node_id().clone(), + kind, + payload, + occurred_at_unix_ms: unix_ms(), + }; + let envelope = self.identity.seal("contract.event.v1", &event)?; + self.recorder.append_event(envelope).await + } +} + +fn map_transport(_: TransportError) -> RuntimeError { + RuntimeError::ArtifactAccessDenied +} + +fn unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} diff --git a/src/runtime/requester.rs b/src/runtime/requester.rs new file mode 100644 index 0000000..ff98a9d --- /dev/null +++ b/src/runtime/requester.rs @@ -0,0 +1,362 @@ +use std::{collections::BTreeMap, sync::Arc, time::Instant}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; + +use crate::{ + adapters::OpenAiDecisionAdapter, + protocol::{ + AcceptanceProfile, CandidateSet, CapabilityManifest, ContractDraft, ContractEvent, + ContractId, ContractProjection, ContractProposeRequest, ContractProposeResponse, + ContractQuery, ContractState, EventKind, EvidenceClaim, Grant, IntentId, NodeRole, + RouteQuery, SourceMetrics, event_hash, + }, + transport::{HttpStats, PeerClient}, +}; + +use super::{ArtifactAccessService, ArtifactStore, NodeIdentity, RuntimeError}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PursuitRequest { + pub goal: String, + pub artifact_bytes_base64: String, + pub media_type: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PursuitResult { + pub intent_id: IntentId, + pub source_contract_id: ContractId, + pub verification_contract_id: ContractId, + pub state_path: Vec, + pub artifact_id: String, + pub executor_metrics: SourceMetrics, + pub verifier_metrics: SourceMetrics, + pub llm_calls: u8, + pub http: HttpStats, + pub phase_ms: BTreeMap, +} + +#[derive(Clone)] +pub struct RequesterService { + identity: NodeIdentity, + store: ArtifactStore, + access: ArtifactAccessService, + client: PeerClient, + decision: Arc, + directory_endpoint: String, + artifact_endpoint: String, +} + +impl RequesterService { + pub fn new( + identity: NodeIdentity, + store: ArtifactStore, + access: ArtifactAccessService, + client: PeerClient, + decision: OpenAiDecisionAdapter, + directory_endpoint: String, + artifact_endpoint: String, + ) -> Result { + if identity.claims().role != NodeRole::Requester { + return Err(RuntimeError::CredentialRoleMismatch); + } + Ok(Self { + identity, + store, + access, + client, + decision: Arc::new(decision), + directory_endpoint, + artifact_endpoint, + }) + } + + pub fn identity(&self) -> &NodeIdentity { + &self.identity + } + + pub fn access(&self) -> &ArtifactAccessService { + &self.access + } + + pub async fn pursue(&self, request: PursuitRequest) -> Result { + let started = Instant::now(); + let bytes = STANDARD + .decode(request.artifact_bytes_base64) + .map_err(|_| RuntimeError::ArtifactIntegrityMismatch)?; + let artifact = self.store.import(&bytes, &request.media_type)?; + let mut phase_ms = BTreeMap::new(); + phase_ms.insert("artifact_import".to_owned(), elapsed_ms(started)); + + let decision_started = Instant::now(); + let decision = self + .decision + .decide(&request.goal) + .await + .map_err(|_| RuntimeError::DecisionFailed)?; + phase_ms.insert("llm_decision".to_owned(), elapsed_ms(decision_started)); + let intent_id = IntentId::new(format!("intent:{}", uuid::Uuid::new_v4()))?; + + let route_started = Instant::now(); + let executor = self.route(&decision.decision.required_capability).await?; + let verifier = self.route("source.metrics.verify.v1").await?; + phase_ms.insert("routing".to_owned(), elapsed_ms(route_started)); + + let source_started = Instant::now(); + let source_draft = self.draft( + None, + &intent_id, + &executor, + artifact.clone(), + "contract:source", + )?; + let (source_contract, source_projection) = + self.execute_contract(&executor, source_draft, None).await?; + let executor_evidence = delivered_evidence(&source_projection)?; + phase_ms.insert("executor_contract".to_owned(), elapsed_ms(source_started)); + + let verifier_started = Instant::now(); + let verification_draft = self.draft( + Some(source_contract.clone()), + &intent_id, + &verifier, + artifact.clone(), + "contract:verification", + )?; + let (verification_contract, verification_projection) = self + .execute_contract( + &verifier, + verification_draft, + Some(executor_evidence.metrics.clone()), + ) + .await?; + let verifier_evidence = delivered_evidence(&verification_projection)?; + if executor_evidence.artifact != verifier_evidence.artifact + || executor_evidence.metrics != verifier_evidence.metrics + { + return Err(RuntimeError::VerificationFailed); + } + phase_ms.insert("verifier_contract".to_owned(), elapsed_ms(verifier_started)); + + let accept_started = Instant::now(); + self.accept( + &executor, + &source_projection, + &verification_contract, + &verifier_evidence, + ) + .await?; + phase_ms.insert("acceptance".to_owned(), elapsed_ms(accept_started)); + phase_ms.insert("total".to_owned(), elapsed_ms(started)); + + Ok(PursuitResult { + intent_id, + source_contract_id: source_contract, + verification_contract_id: verification_contract, + state_path: vec![ + ContractState::Proposed, + ContractState::Active, + ContractState::Running, + ContractState::Delivered, + ContractState::Accepted, + ], + artifact_id: artifact.artifact_id.as_str().to_owned(), + executor_metrics: executor_evidence.metrics, + verifier_metrics: verifier_evidence.metrics, + llm_calls: decision.llm_calls, + http: self.client.stats(), + phase_ms, + }) + } + + async fn route(&self, capability: &str) -> Result { + let envelope = self.identity.seal( + "route.query.v1", + &RouteQuery { + required_capability: capability.to_owned(), + }, + )?; + let candidates: CandidateSet = self + .client + .post_signed_read( + &self.directory_endpoint, + "/v0/routes/query", + &envelope, + "route.candidates.v1", + ) + .await + .map_err(|_| RuntimeError::TransportFailed)?; + candidates + .candidates + .into_iter() + .next() + .ok_or(RuntimeError::CapabilityUnavailable) + } + + fn draft( + &self, + parent_contract_id: Option, + intent_id: &IntentId, + manifest: &CapabilityManifest, + artifact: crate::protocol::ArtifactRef, + prefix: &str, + ) -> Result { + let expires_at_unix_ms = unix_ms() + 60_000; + Ok(ContractDraft { + contract_id: ContractId::new(format!("{prefix}:{}", uuid::Uuid::new_v4()))?, + parent_contract_id, + intent_id: intent_id.clone(), + requester: self.identity.node_id().clone(), + provider: manifest.provider.clone(), + capability_id: manifest.capability_id.clone(), + grant: Grant { + issuer: self.identity.node_id().clone(), + subject: manifest.provider.clone(), + capability_id: manifest.capability_id.clone(), + artifact_id: artifact.artifact_id.clone(), + expires_at_unix_ms, + delegation_depth: 0, + }, + artifact, + acceptance: AcceptanceProfile::ExactSourceMetricsV1, + expires_at_unix_ms, + }) + } + + async fn execute_contract( + &self, + manifest: &CapabilityManifest, + draft: ContractDraft, + expected_metrics: Option, + ) -> Result<(ContractId, ContractProjection), RuntimeError> { + let contract_id = draft.contract_id.clone(); + let request = ContractProposeRequest { + offer: self.identity.create_contract_offer(&draft)?, + artifact_endpoint: self.artifact_endpoint.clone(), + expected_metrics, + }; + let envelope = self.identity.seal("contract.propose.v1", &request)?; + let response: ContractProposeResponse = self + .client + .post_signed( + &manifest.endpoint, + "/v0/contracts/propose", + &envelope, + "contract.sealed.v1", + ) + .await + .map_err(|_| RuntimeError::TransportFailed)?; + response.contract.verify(self.identity.root(), unix_ms())?; + self.access.authorize(response.contract).await?; + let projection = self + .poll_delivered(&manifest.endpoint, &contract_id) + .await?; + Ok((contract_id, projection)) + } + + async fn poll_delivered( + &self, + endpoint: &str, + contract_id: &ContractId, + ) -> Result { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let envelope = self.identity.seal( + "contract.query.v1", + &ContractQuery { + contract_id: contract_id.clone(), + }, + )?; + let projection: ContractProjection = self + .client + .post_signed_read( + endpoint, + "/v0/contracts/events/query", + &envelope, + "contract.projection.v1", + ) + .await + .map_err(|_| RuntimeError::TransportFailed)?; + match projection.state { + ContractState::Delivered => return Ok(projection), + ContractState::Failed => return Err(RuntimeError::ContractExecutionFailed), + _ if tokio::time::Instant::now() >= deadline => { + return Err(RuntimeError::ContractExecutionFailed); + } + _ => tokio::time::sleep(std::time::Duration::from_millis(50)).await, + } + } + } + + async fn accept( + &self, + executor: &CapabilityManifest, + source: &ContractProjection, + verification_contract_id: &ContractId, + evidence: &EvidenceClaim, + ) -> Result { + let event = ContractEvent { + contract_id: source.draft.contract_id.clone(), + event_id: format!("event:{}", uuid::Uuid::new_v4()), + sequence: source.events.len() as u64 + 1, + previous_hash: source.events.last().map(event_hash), + operation_id: format!("op:{}:accepted", source.draft.contract_id.as_str()), + issuer: self.identity.node_id().clone(), + kind: EventKind::Accepted, + payload: serde_json::json!({ + "verification_contract_id": verification_contract_id, + "evidence_hash": evidence_hash(evidence)?, + }), + occurred_at_unix_ms: unix_ms(), + }; + let envelope = self.identity.seal("contract.event.v1", &event)?; + let response: serde_json::Value = self + .client + .post_signed( + &executor.endpoint, + "/v0/contracts/events/append", + &envelope, + "contract.event.appended.v1", + ) + .await + .map_err(|_| RuntimeError::TransportFailed)?; + if response.get("state") != Some(&serde_json::json!(ContractState::Accepted)) { + return Err(RuntimeError::VerificationFailed); + } + Ok(ContractState::Accepted) + } +} + +fn delivered_evidence(projection: &ContractProjection) -> Result { + let event = projection + .events + .iter() + .find(|event| event.kind == EventKind::Delivered) + .ok_or(RuntimeError::ContractExecutionFailed)?; + serde_json::from_value(event.payload.clone()).map_err(RuntimeError::from) +} + +fn evidence_hash(evidence: &EvidenceClaim) -> Result { + use sha2::{Digest, Sha256}; + let bytes = serde_json::to_vec(evidence)?; + let digest = Sha256::digest(bytes); + Ok(format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +fn elapsed_ms(start: Instant) -> u64 { + start.elapsed().as_millis() as u64 +} + +fn unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index a90ddcf..aa3ae57 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -1,7 +1,9 @@ mod client; mod directory; +mod node; pub use client::{HttpStats, PeerClient, TransportError}; pub use directory::directory_router; +pub use node::{artifact_router, provider_router, requester_router}; pub const MAX_JSON_BODY_BYTES: usize = 256 * 1024; diff --git a/src/transport/node.rs b/src/transport/node.rs new file mode 100644 index 0000000..a779e92 --- /dev/null +++ b/src/transport/node.rs @@ -0,0 +1,243 @@ +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, State, rejection::JsonRejection}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use serde_json::json; + +use crate::{ + protocol::{ + ArtifactReadRequest, ContractProposeRequest, ContractQuery, ErrorEnvelope, WireEnvelope, + }, + runtime::{ + ArtifactAccessService, NodeIdentity, ProviderService, PursuitRequest, RequesterService, + RuntimeError, + }, +}; + +use super::MAX_JSON_BODY_BYTES; + +pub fn provider_router(service: ProviderService) -> Router { + Router::new() + .route("/healthz", get(health)) + .route("/v0/contracts/propose", post(provider_propose)) + .route("/v0/contracts/events/append", post(provider_append)) + .route("/v0/contracts/events/query", post(provider_query)) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .with_state(Arc::new(service)) +} + +async fn provider_propose( + State(service): State>, + payload: Result, JsonRejection>, +) -> Response { + let envelope = match envelope(payload) { + Ok(envelope) => envelope, + Err(response) => return *response, + }; + let request: ContractProposeRequest = match envelope.open( + "contract.propose.v1", + service.identity().root(), + service.validation_time_unix_ms(), + ) { + Ok(request) => request, + Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; + match service.propose(&envelope.issuer_id, request).await { + Ok(response) => signed_response( + service.identity(), + "contract.sealed.v1", + &response, + StatusCode::OK, + ), + Err(_) => error(StatusCode::FORBIDDEN, "ContractRejected"), + } +} + +async fn provider_append( + State(service): State>, + payload: Result, JsonRejection>, +) -> Response { + let envelope = match envelope(payload) { + Ok(envelope) => envelope, + Err(response) => return *response, + }; + match service.append(envelope).await { + Ok(state) => signed_response( + service.identity(), + "contract.event.appended.v1", + &json!({"state": state}), + StatusCode::OK, + ), + Err(_) => error(StatusCode::FORBIDDEN, "EventRejected"), + } +} + +async fn provider_query( + State(service): State>, + payload: Result, JsonRejection>, +) -> Response { + let envelope = match envelope(payload) { + Ok(envelope) => envelope, + Err(response) => return *response, + }; + let query: ContractQuery = match envelope.open( + "contract.query.v1", + service.identity().root(), + service.validation_time_unix_ms(), + ) { + Ok(query) => query, + Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; + match service.query(&query).await { + Ok(projection) => signed_response( + service.identity(), + "contract.projection.v1", + &projection, + StatusCode::OK, + ), + Err(_) => error(StatusCode::NOT_FOUND, "UnknownContract"), + } +} + +#[derive(Clone)] +struct ArtifactHttpState { + identity: NodeIdentity, + access: ArtifactAccessService, +} + +pub fn artifact_router(identity: NodeIdentity, access: ArtifactAccessService) -> Router { + Router::new() + .route("/healthz", get(health)) + .route("/v0/artifacts/read", post(artifact_read)) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .with_state(Arc::new(ArtifactHttpState { identity, access })) +} + +#[derive(Clone)] +struct RequesterHttpState { + service: RequesterService, + control_token: Arc, +} + +pub fn requester_router(service: RequesterService, control_token: String) -> Router { + let artifact_routes = artifact_router(service.identity().clone(), service.access().clone()); + let local_routes = Router::new() + .route("/local/v0/pursuits", post(local_pursuit)) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .with_state(Arc::new(RequesterHttpState { + service, + control_token: Arc::from(control_token), + })); + artifact_routes.merge(local_routes) +} + +async fn local_pursuit( + State(state): State>, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Response { + if !valid_bearer(&headers, &state.control_token) { + return error(StatusCode::UNAUTHORIZED, "InvalidControlToken"); + } + let Json(request) = match payload { + Ok(request) => request, + Err(rejection) if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE => { + return error(StatusCode::PAYLOAD_TOO_LARGE, "RequestBodyTooLarge"); + } + Err(_) => return error(StatusCode::BAD_REQUEST, "InvalidPursuitRequest"), + }; + match state.service.pursue(request).await { + Ok(result) => (StatusCode::OK, Json(result)).into_response(), + Err(error_code) => { + tracing::warn!(code = ?error_code, "pursuit failed"); + error(StatusCode::UNPROCESSABLE_ENTITY, "PursuitFailed") + } + } +} + +fn valid_bearer(headers: &HeaderMap, expected: &str) -> bool { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + == Some(expected) +} + +async fn artifact_read( + State(state): State>, + payload: Result, JsonRejection>, +) -> Response { + let envelope = match envelope(payload) { + Ok(envelope) => envelope, + Err(response) => return *response, + }; + let request: ArtifactReadRequest = match envelope.open( + "artifact.read.v1", + state.identity.root(), + state.identity.validation_time_unix_ms(), + ) { + Ok(request) => request, + Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; + match state.access.read(&envelope.issuer_id, &request).await { + Ok(artifact) => signed_response( + &state.identity, + "artifact.payload.v1", + &artifact, + StatusCode::OK, + ), + Err(RuntimeError::ArtifactAccessDenied) => { + error(StatusCode::CONFLICT, "ContractNotAuthorized") + } + Err(_) => error(StatusCode::FORBIDDEN, "ArtifactReadRejected"), + } +} + +async fn health() -> Json { + Json(json!({"status": "ok"})) +} + +fn envelope( + payload: Result, JsonRejection>, +) -> Result> { + match payload { + Ok(Json(envelope)) => Ok(envelope), + Err(rejection) if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE => Err(Box::new( + error(StatusCode::PAYLOAD_TOO_LARGE, "RequestBodyTooLarge"), + )), + Err(_) => Err(Box::new(error( + StatusCode::UNAUTHORIZED, + "SignedEnvelopeRequired", + ))), + } +} + +fn signed_response( + identity: &NodeIdentity, + object_type: &str, + payload: &T, + status: StatusCode, +) -> Response { + match identity.seal(object_type, payload) { + Ok(envelope) => (status, Json(envelope)).into_response(), + Err(_) => error(StatusCode::INTERNAL_SERVER_ERROR, "InternalError"), + } +} + +fn error(status: StatusCode, code: &str) -> Response { + ( + status, + Json(ErrorEnvelope { + code: code.to_owned(), + message: "the request could not be completed".to_owned(), + retryable: status == StatusCode::CONFLICT, + operation_id: "http:unavailable".to_owned(), + }), + ) + .into_response() +} diff --git a/tests/http_artifact.rs b/tests/http_artifact.rs new file mode 100644 index 0000000..2827e54 --- /dev/null +++ b/tests/http_artifact.rs @@ -0,0 +1,162 @@ +use agenet::{ + protocol::{ + AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, CapabilityId, + ContractDraft, ContractId, CredentialClaims, Grant, IntentId, NodeId, NodeRole, + SealedContract, SignedNodeCredential, + }, + runtime::{ArtifactAccessService, ArtifactStore, NodeIdentity}, + transport::artifact_router, +}; +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use ed25519_dalek::SigningKey; +use tempfile::TempDir; +use tower::ServiceExt; + +const NOW: u64 = 1_800_000_000; + +fn key(byte: u8) -> SigningKey { + SigningKey::from_bytes(&[byte; 32]) +} + +fn credential( + root: &SigningKey, + key: &SigningKey, + id: &str, + role: NodeRole, +) -> SignedNodeCredential { + SignedNodeCredential::issue( + root, + CredentialClaims { + node_id: NodeId::new(id).unwrap(), + public_key: key.verifying_key().to_bytes(), + role, + issued_at_unix_ms: NOW - 1, + expires_at_unix_ms: NOW + 60_000, + }, + ) + .unwrap() +} + +fn identity(root: &SigningKey, key: SigningKey, id: &str, role: NodeRole) -> NodeIdentity { + let credential = credential(root, &key, id, role); + NodeIdentity::new(key, credential, root.verifying_key(), NOW).unwrap() +} + +#[tokio::test] +async fn artifact_endpoint_requires_the_exact_contract_caller_and_hash() { + let temp = TempDir::new().unwrap(); + let root = key(60); + let requester_key = key(61); + let executor_key = key(62); + let intruder_key = key(63); + let requester = identity( + &root, + requester_key.clone(), + "node:requester", + NodeRole::Requester, + ); + let executor = identity( + &root, + executor_key.clone(), + "node:executor", + NodeRole::Executor, + ); + let intruder = identity(&root, intruder_key, "node:intruder", NodeRole::Verifier); + let store = ArtifactStore::open(temp.path(), requester.node_id().clone()).unwrap(); + let artifact = store.import(b"fn main() {}\n", "text/x-rust").unwrap(); + let access = ArtifactAccessService::new(store, root.verifying_key(), NOW); + let contract_id = ContractId::new("contract:source").unwrap(); + let capability_id = CapabilityId::new("capability:source-metrics").unwrap(); + let contract = SealedContract::seal( + &draft( + contract_id.clone(), + capability_id, + artifact.clone(), + executor.node_id().clone(), + ), + &requester_key, + credential(&root, &requester_key, "node:requester", NodeRole::Requester), + &executor_key, + credential(&root, &executor_key, "node:executor", NodeRole::Executor), + ) + .unwrap(); + access.authorize(contract).await.unwrap(); + let app = artifact_router(requester, access); + + let valid = signed_request(&executor, &contract_id, &artifact.artifact_id); + assert_eq!(send(app.clone(), valid).await, StatusCode::OK); + + let wrong_caller = signed_request(&intruder, &contract_id, &artifact.artifact_id); + assert_eq!(send(app.clone(), wrong_caller).await, StatusCode::FORBIDDEN); + + let unknown_contract = signed_request( + &executor, + &ContractId::new("contract:unknown").unwrap(), + &artifact.artifact_id, + ); + assert_eq!( + send(app.clone(), unknown_contract).await, + StatusCode::CONFLICT + ); + + let wrong_hash = signed_request( + &executor, + &contract_id, + &ArtifactId::new("sha256:wrong").unwrap(), + ); + assert_eq!(send(app, wrong_hash).await, StatusCode::FORBIDDEN); +} + +fn draft( + contract_id: ContractId, + capability_id: CapabilityId, + artifact: ArtifactRef, + provider: NodeId, +) -> ContractDraft { + ContractDraft { + contract_id, + parent_contract_id: None, + intent_id: IntentId::new("intent:metrics").unwrap(), + requester: NodeId::new("node:requester").unwrap(), + provider: provider.clone(), + capability_id: capability_id.clone(), + grant: Grant { + issuer: NodeId::new("node:requester").unwrap(), + subject: provider, + capability_id, + artifact_id: artifact.artifact_id.clone(), + expires_at_unix_ms: NOW + 60_000, + delegation_depth: 0, + }, + artifact, + acceptance: AcceptanceProfile::ExactSourceMetricsV1, + expires_at_unix_ms: NOW + 60_000, + } +} + +fn signed_request( + caller: &NodeIdentity, + contract_id: &ContractId, + artifact_id: &ArtifactId, +) -> Request { + let envelope = caller + .seal( + "artifact.read.v1", + &ArtifactReadRequest { + contract_id: contract_id.clone(), + artifact_id: artifact_id.clone(), + }, + ) + .unwrap(); + Request::post("/v0/artifacts/read") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&envelope).unwrap())) + .unwrap() +} + +async fn send(app: axum::Router, request: Request) -> StatusCode { + app.oneshot(request).await.unwrap().status() +} diff --git a/tests/llm_adapter.rs b/tests/llm_adapter.rs new file mode 100644 index 0000000..020ad38 --- /dev/null +++ b/tests/llm_adapter.rs @@ -0,0 +1,100 @@ +use std::{collections::VecDeque, sync::Arc}; + +use agenet::adapters::{ + AgentDecision, DecisionError, DeterministicDecisionAdapter, OpenAiDecisionAdapter, +}; +use axum::{Json, Router, extract::State, routing::post}; +use serde_json::{Value, json}; +use tokio::{net::TcpListener, sync::Mutex}; + +#[derive(Clone)] +struct FakeState { + responses: Arc>>, + requests: Arc>>, +} + +async fn completion(State(state): State, Json(request): Json) -> Json { + state.requests.lock().await.push(request); + let content = state.responses.lock().await.pop_front().unwrap(); + Json(json!({ + "choices": [{"message": {"content": content}}] + })) +} + +async fn fake_server(responses: Vec<&str>) -> (String, FakeState, tokio::task::JoinHandle<()>) { + let state = FakeState { + responses: Arc::new(Mutex::new( + responses.into_iter().map(str::to_owned).collect(), + )), + requests: Arc::new(Mutex::new(Vec::new())), + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = Router::new() + .route("/chat/completions", post(completion)) + .with_state(state.clone()); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{address}"), state, handle) +} + +fn valid_decision() -> &'static str { + r#"{"required_capability":"source.metrics.v1","acceptance_profile":"exact-source-metrics.v1","requires_independent_verifier":true}"# +} + +#[test] +fn deterministic_adapter_is_explicit_and_never_claims_an_llm_call() { + let result = DeterministicDecisionAdapter.decide_for_protocol_tests(); + assert_eq!(result.llm_calls, 0); + assert_eq!(result.decision.required_capability, "source.metrics.v1"); +} + +#[tokio::test] +async fn valid_json_produces_a_typed_decision_without_source_content() { + let (base_url, state, server) = fake_server(vec![valid_decision()]).await; + let adapter = OpenAiDecisionAdapter::new(&base_url, "sentinel-secret", "test-model").unwrap(); + + let result = adapter + .decide("Compute independently verified source metrics") + .await + .unwrap(); + + assert_eq!(result.llm_calls, 1); + assert_eq!( + result.decision, + AgentDecision { + required_capability: "source.metrics.v1".to_owned(), + acceptance_profile: "exact-source-metrics.v1".to_owned(), + requires_independent_verifier: true, + } + ); + let captured = serde_json::to_string(&state.requests.lock().await.clone()).unwrap(); + assert!(!captured.contains("fn main")); + assert!(!format!("{adapter:?}").contains("sentinel-secret")); + server.abort(); +} + +#[tokio::test] +async fn malformed_json_gets_one_repair_request() { + let (base_url, state, server) = fake_server(vec!["not-json", valid_decision()]).await; + let adapter = OpenAiDecisionAdapter::new(&base_url, "sentinel-secret", "test-model").unwrap(); + + let result = adapter.decide("Compute metrics").await.unwrap(); + + assert_eq!(result.llm_calls, 2); + assert_eq!(state.requests.lock().await.len(), 2); + server.abort(); +} + +#[tokio::test] +async fn two_malformed_responses_fail_explicitly_without_secret_leakage() { + let (base_url, _state, server) = fake_server(vec!["bad-one", "bad-two"]).await; + let adapter = OpenAiDecisionAdapter::new(&base_url, "sentinel-secret", "test-model").unwrap(); + + let error = adapter.decide("Compute metrics").await.unwrap_err(); + + assert_eq!(error, DecisionError::AgentDecisionInvalid); + assert!(!format!("{error:?}").contains("sentinel-secret")); + server.abort(); +} diff --git a/tests/source_metrics.rs b/tests/source_metrics.rs new file mode 100644 index 0000000..121e741 --- /dev/null +++ b/tests/source_metrics.rs @@ -0,0 +1,65 @@ +use agenet::{ + adapters::{executor_metrics, verifier_metrics}, + protocol::SourceMetrics, + runtime::RuntimeError, +}; +use sha2::{Digest, Sha256}; + +fn oracle(bytes: &[u8]) -> SourceMetrics { + let text = std::str::from_utf8(bytes).unwrap(); + let digest = Sha256::digest(bytes); + let sha256 = format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + SourceMetrics { + sha256, + byte_count: bytes.len() as u64, + line_count: text.lines().count() as u64, + non_empty_line_count: text.lines().filter(|line| !line.trim().is_empty()).count() as u64, + } +} + +#[test] +fn executor_and_verifier_match_the_independent_oracle_for_edge_cases() { + for bytes in [ + b"".as_slice(), + b"one line".as_slice(), + b"one line\n".as_slice(), + b"one\r\n\r\n two \r\n".as_slice(), + "你好\n\t\nworld".as_bytes(), + b"\n\n".as_slice(), + ] { + let expected = oracle(bytes); + assert_eq!(executor_metrics::compute(bytes).unwrap(), expected); + assert_eq!(verifier_metrics::recompute(bytes).unwrap(), expected); + } +} + +#[test] +fn invalid_utf8_is_rejected_without_lossy_conversion() { + let bytes = [0xff, 0xfe, 0xfd]; + assert_eq!( + executor_metrics::compute(&bytes), + Err(RuntimeError::UnsupportedArtifactEncoding) + ); + assert_eq!( + verifier_metrics::recompute(&bytes), + Err(RuntimeError::UnsupportedArtifactEncoding) + ); +} + +#[test] +fn verifier_reports_a_metrics_mismatch() { + let bytes = b"fn main() {}\n"; + let mut delivered = executor_metrics::compute(bytes).unwrap(); + delivered.non_empty_line_count += 1; + + assert_eq!( + verifier_metrics::verify(bytes, &delivered), + Err(RuntimeError::EvidenceMismatch) + ); +} From e38043b5c945dc582268e662bc9972e48720d592 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Thu, 13 Aug 2026 23:55:58 +0800 Subject: [PATCH 04/67] [feat][Demo] Implement local AgenNet demo Root cause: NA Solution: Add the real model adapter wiring, node CLI, ephemeral Domain provisioning, four-process loopback harness, local pursuit API, retained audit state, timing/stat summaries, and graceful process cleanup. Risks: The automated process test uses a synthetic local completion server; real model behavior remains a separate manual gate. Dependency: 2033c0f Links: plan/00-v1-local-loopback-mvp.md --- README.md | 37 +++- ROADMAP.md | 7 + docs/design/agenet-v0.1.md | 20 ++ fixtures/sample.rs | 4 + src/demo.rs | 403 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/main.rs | 82 +++++++- src/node.rs | 261 ++++++++++++++++++++++++ src/runtime/mod.rs | 2 +- src/runtime/requester.rs | 27 ++- src/transport/node.rs | 23 ++- tests/multiprocess_demo.rs | 145 +++++++++++++ 12 files changed, 1004 insertions(+), 9 deletions(-) create mode 100644 fixtures/sample.rs create mode 100644 src/demo.rs create mode 100644 src/node.rs create mode 100644 tests/multiprocess_demo.rs diff --git a/README.md b/README.md index 94a5a2b..4823f1e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,37 @@ cargo run -- demo \ The environment file is read in place. It is never copied, logged, or committed. +Required names are `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `VLM_MODEL`. Only the Requester child receives them. The other three children are started with a cleared environment. The demo never falls back to `DeterministicDecisionAdapter`; an invalid model response fails explicitly after one format-repair request. + +The successful command prints one JSON summary containing: + +- four distinct PIDs, loopback addresses, Node IDs, and state directories; +- source and verification Contract IDs; +- `Proposed → Active → Running → Delivered → Accepted`; +- the immutable Artifact hash and both metric results; +- LLM call count, peer HTTP request/byte counters, and phase timings; +- the retained `.local/demo//` directory for inspection. + +Private keys and the local control token are stored with mode `0600`. Runtime state, logs, private keys, tokens, and JSONL journals are ignored by Git. Peer payloads never accept a filesystem path. + +## Architecture + +```text +demo harness + ├─ Directory 127.0.0.1:dynamic + ├─ Requester 127.0.0.1:dynamic ── real LLM decision + ├─ Executor 127.0.0.1:dynamic ── source.metrics.v1 + └─ Verifier 127.0.0.1:dynamic ── source.metrics.verify.v1 + +Requester → Directory → signed CandidateSet +Requester → Executor → bilateral source Contract → Delivered Evidence +Requester → Directory → signed CandidateSet +Requester → Verifier → parent-linked verification Contract → Delivered Evidence +Requester → Executor → signed Accepted Event +``` + +`agenet demo` is provisioning and test scaffolding, not a control plane. The Requester child receives only the Directory seed; Executor and Verifier endpoints are learned from signed Capability Manifests. + ## Development gates ```bash @@ -33,4 +64,8 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test --all-targets ``` -The crate currently exposes a pure `protocol` kernel. Runtime, transport, and adapter modules are introduced only after their corresponding behavior tests exist. +Tests include pure protocol/property checks, storage replay, Axum `oneshot` authorization and limits, Reqwest timeout/error handling, LLM JSON repair and secret sentinels, metric edge cases, and a real four-child-process loopback demo backed by a clearly synthetic local completion server. + +## Security boundary + +This milestone proves signed loopback coordination semantics. HTTP is plaintext and forcibly loopback-only. Separate OS processes and state directories are not claimed as a secure sandbox. The workload does not execute shell commands. A future `project.build_test.v1` adapter must use Docker, a VM, or a platform sandbox before accepting untrusted code. diff --git a/ROADMAP.md b/ROADMAP.md index f02583e..871058b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,12 @@ # ROADMAP +## 2026-08-13 23:50 CST + +- **Change**: Added the real OpenAI-compatible Decision Adapter, node CLI, four-process demo harness, retained audit state, graceful SIGTERM shutdown, and end-to-end process test. +- **Files**: `src/node.rs`, `src/demo.rs`, `src/main.rs`, `fixtures/sample.rs`, `tests/multiprocess_demo.rs`, `README.md`, `docs/design/agenet-v0.1.md`. +- **Decision**: Keep the demo outside the protocol control plane; pass only the Directory seed to Requester bootstrap and only the three allowlisted model variables to that child. +- **Reason**: Demonstrate actual process/network/identity boundaries without confusing harness convenience with an AgenNet architectural primitive. + ## 2026-08-13 23:30 CST - **Change**: Implemented the real source-metrics workload, Contract-authorized Artifact reads, provider execution, independent verification, and evidence-gated acceptance. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 3873e4c..4aeab3c 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -48,6 +48,26 @@ The Requester imports UTF-8 source bytes, asks the Directory separately for `sou Only the Requester can append `Accepted`, and it does so only after matching the Artifact reference and every SourceMetrics field. The Accepted Event carries the verification Contract ID and a hash of the verifying Evidence. A verifier failure leaves the source Contract at `Delivered`. +## LLM and demo adapter + +The OpenAI-compatible adapter receives only the natural-language goal, the strict Intent projection schema, and the public Capability kind. It uses `temperature: 0`, limits responses to 64 KiB, performs at most one real format-repair call, and has no manual-demo fallback. Authorization is installed as a sensitive header and adapter Debug output omits it. + +The demo provisions an ephemeral Domain Root and four Node Credentials, starts four copies of the `agenet node` binary on `127.0.0.1:0`, waits for signed Capability registration, submits one local pursuit with a bearer token read from a `0600` file, enforces a 90-second outer timeout, sends SIGTERM, and retains state for audit. + +## Validation matrix + +| Claim | Status | Evidence | +| --- | --- | --- | +| Exact-byte Credential, Envelope, Contract, and Event signatures | automated | unit/property tests | +| Grant and Artifact read scope | automated | protocol and Axum tests | +| Journal replay and operation idempotency | automated | restart test | +| Independent source metric reproduction | automated | separate implementations plus third oracle | +| Four PIDs and four dynamic ports | automated | real child-process test | +| Real model Intent projection | manual gate | Walkman env demo only | +| TLS or secure multi-machine sessions | not implemented | deferred | +| Sandbox for arbitrary code | not implemented | deferred | +| Quota, replication, failover, and federation | not implemented | deferred | + ## Deliberately deferred - TLS and cross-machine peer sessions diff --git a/fixtures/sample.rs b/fixtures/sample.rs new file mode 100644 index 0000000..d3b4e01 --- /dev/null +++ b/fixtures/sample.rs @@ -0,0 +1,4 @@ +fn main() { + let participants = ["directory", "requester", "executor", "verifier"]; + println!("AgenNet participants: {}", participants.len()); +} diff --git a/src/demo.rs b/src/demo.rs new file mode 100644 index 0000000..3e27fcb --- /dev/null +++ b/src/demo.rs @@ -0,0 +1,403 @@ +use std::{ + collections::{HashMap, HashSet}, + fs::{self, File}, + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; +use serde::{Deserialize, Serialize}; + +use crate::{ + node::{NodeProfile, ReadyState}, + protocol::{CredentialClaims, NodeId, NodeRole, SignedNodeCredential}, + runtime::{PursuitRequest, PursuitResult, write_signing_key}, +}; + +#[derive(Debug)] +pub struct DemoOptions { + pub env_file: PathBuf, + pub artifact: PathBuf, + pub state_dir: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DemoSummary { + pub run_id: String, + pub participants: Vec, + pub pursuit: PursuitResult, + pub retained_state_dir: PathBuf, +} + +struct ChildSet { + children: Vec, +} + +impl ChildSet { + fn new() -> Self { + Self { + children: Vec::new(), + } + } + + fn push(&mut self, child: Child) { + self.children.push(child); + } + + fn terminate(&mut self) { + for child in &mut self.children { + if child.try_wait().ok().flatten().is_none() { + let _ = Command::new("/bin/kill") + .arg("-TERM") + .arg(child.id().to_string()) + .status(); + } + } + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + if self + .children + .iter_mut() + .all(|child| child.try_wait().ok().flatten().is_some()) + { + return; + } + std::thread::sleep(Duration::from_millis(25)); + } + for child in &mut self.children { + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } +} + +impl Drop for ChildSet { + fn drop(&mut self) { + self.terminate(); + } +} + +pub async fn run(options: DemoOptions) -> Result { + tokio::time::timeout(Duration::from_secs(90), run_inner(options)) + .await + .map_err(|_| "DemoTimeout".to_owned())? +} + +async fn run_inner(options: DemoOptions) -> Result { + let env = read_llm_env(&options.env_file)?; + let artifact = fs::read(&options.artifact).map_err(sanitized)?; + if artifact.len() > crate::runtime::MAX_ARTIFACT_BYTES { + return Err("ArtifactTooLarge".to_owned()); + } + let run_id = uuid::Uuid::new_v4().to_string(); + let root_dir = match options.state_dir { + Some(directory) => directory.join(&run_id), + None => PathBuf::from(".local/demo").join(&run_id), + }; + fs::create_dir_all(&root_dir).map_err(sanitized)?; + let executable = std::env::current_exe().map_err(sanitized)?; + let now = unix_ms(); + let root_key = random_signing_key()?; + let root_key_file = root_dir.join("domain-root.key"); + let root_public_key_file = root_dir.join("domain-root.pub"); + write_signing_key(&root_key_file, &root_key).map_err(sanitized)?; + fs::write( + &root_public_key_file, + format!("{}\n", STANDARD.encode(root_key.verifying_key().to_bytes())), + ) + .map_err(sanitized)?; + + let credentials = provision_nodes(&root_dir, &root_key, now)?; + let control_token = uuid::Uuid::new_v4().to_string(); + let control_token_file = root_dir.join("requester-control.token"); + write_secret(&control_token_file, control_token.as_bytes())?; + let mut children = ChildSet::new(); + let mut participants = Vec::new(); + + let directory = spawn_node( + &executable, + &root_public_key_file, + &credentials[&NodeProfile::Directory], + None, + None, + None, + )?; + children.push(directory); + let directory_ready = wait_ready(&credentials[&NodeProfile::Directory].ready_file).await?; + wait_health(&directory_ready.address).await?; + participants.push(directory_ready.clone()); + + for profile in [NodeProfile::Executor, NodeProfile::Verifier] { + let child = spawn_node( + &executable, + &root_public_key_file, + &credentials[&profile], + Some(&directory_ready.address), + None, + None, + )?; + children.push(child); + let ready = wait_ready(&credentials[&profile].ready_file).await?; + wait_health(&ready.address).await?; + participants.push(ready); + } + + let requester = spawn_node( + &executable, + &root_public_key_file, + &credentials[&NodeProfile::Requester], + Some(&directory_ready.address), + Some(&control_token_file), + Some(&env), + )?; + children.push(requester); + let requester_ready = wait_ready(&credentials[&NodeProfile::Requester].ready_file).await?; + wait_health(&requester_ready.address).await?; + participants.push(requester_ready.clone()); + validate_participants(&participants)?; + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(90)) + .build() + .map_err(sanitized)?; + let response = client + .post(format!("{}/local/v0/pursuits", requester_ready.address)) + .bearer_auth(&control_token) + .json(&PursuitRequest { + goal: "Compute exact source metrics and accept only after independent verification" + .to_owned(), + artifact_bytes_base64: STANDARD.encode(artifact), + media_type: "text/x-rust".to_owned(), + }) + .send() + .await + .map_err(sanitized)?; + if !response.status().is_success() { + return Err(format!("PursuitFailed:HTTP{}", response.status().as_u16())); + } + let pursuit: PursuitResult = response.json().await.map_err(sanitized)?; + let summary = DemoSummary { + run_id, + participants, + pursuit, + retained_state_dir: root_dir, + }; + println!( + "{}", + serde_json::to_string_pretty(&summary).map_err(sanitized)? + ); + children.terminate(); + Ok(summary) +} + +#[derive(Debug)] +struct ProvisionedNode { + profile: NodeProfile, + state_dir: PathBuf, + key_file: PathBuf, + credential_file: PathBuf, + ready_file: PathBuf, +} + +fn provision_nodes( + root_dir: &Path, + root_key: &SigningKey, + now: u64, +) -> Result, String> { + let mut nodes = HashMap::new(); + for profile in [ + NodeProfile::Directory, + NodeProfile::Requester, + NodeProfile::Executor, + NodeProfile::Verifier, + ] { + let state_dir = root_dir.join(format!("{profile:?}").to_lowercase()); + fs::create_dir_all(&state_dir).map_err(sanitized)?; + let signing_key = random_signing_key()?; + let node_id = NodeId::new(format!( + "node:{}:{}", + format!("{profile:?}").to_lowercase(), + uuid::Uuid::new_v4() + )) + .map_err(sanitized)?; + let credential = SignedNodeCredential::issue( + root_key, + CredentialClaims { + node_id, + public_key: signing_key.verifying_key().to_bytes(), + role: role(profile), + issued_at_unix_ms: now.saturating_sub(1_000), + expires_at_unix_ms: now + 600_000, + }, + ) + .map_err(sanitized)?; + let key_file = state_dir.join("identity.key"); + let credential_file = state_dir.join("credential.json"); + let ready_file = state_dir.join("ready.json"); + write_signing_key(&key_file, &signing_key).map_err(sanitized)?; + fs::write( + &credential_file, + serde_json::to_vec_pretty(&credential).map_err(sanitized)?, + ) + .map_err(sanitized)?; + nodes.insert( + profile, + ProvisionedNode { + profile, + state_dir, + key_file, + credential_file, + ready_file, + }, + ); + } + Ok(nodes) +} + +fn spawn_node( + executable: &Path, + root_public_key_file: &Path, + node: &ProvisionedNode, + directory_seed: Option<&str>, + control_token_file: Option<&Path>, + llm_env: Option<&HashMap>, +) -> Result { + let stdout = File::create(node.state_dir.join("stdout.log")).map_err(sanitized)?; + let stderr = File::create(node.state_dir.join("stderr.log")).map_err(sanitized)?; + let mut command = Command::new(executable); + command + .env_clear() + .arg("node") + .arg("--profile") + .arg(format!("{:?}", node.profile).to_lowercase()) + .arg("--state-dir") + .arg(&node.state_dir) + .arg("--key-file") + .arg(&node.key_file) + .arg("--credential-file") + .arg(&node.credential_file) + .arg("--root-public-key-file") + .arg(root_public_key_file) + .arg("--ready-file") + .arg(&node.ready_file) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + if let Some(directory_seed) = directory_seed { + command.arg("--directory-seed").arg(directory_seed); + } + if let Some(control_token_file) = control_token_file { + command.arg("--control-token-file").arg(control_token_file); + } + if let Some(llm_env) = llm_env { + for name in ["OPENAI_BASE_URL", "OPENAI_API_KEY", "VLM_MODEL"] { + command.env(name, &llm_env[name]); + } + } + command.spawn().map_err(sanitized) +} + +async fn wait_ready(path: &Path) -> Result { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + if let Ok(bytes) = fs::read(path) + && let Ok(ready) = serde_json::from_slice(&bytes) + { + return Ok(ready); + } + if tokio::time::Instant::now() >= deadline { + return Err("NodeReadyTimeout".to_owned()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +async fn wait_health(endpoint: &str) -> Result<(), String> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let client = reqwest::Client::new(); + loop { + if let Ok(response) = client.get(format!("{endpoint}/healthz")).send().await + && response.status().is_success() + { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err("NodeHealthTimeout".to_owned()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +fn validate_participants(participants: &[ReadyState]) -> Result<(), String> { + let pids: HashSet<_> = participants.iter().map(|state| state.pid).collect(); + let addresses: HashSet<_> = participants.iter().map(|state| &state.address).collect(); + let node_ids: HashSet<_> = participants.iter().map(|state| &state.node_id).collect(); + let directories: HashSet<_> = participants.iter().map(|state| &state.state_dir).collect(); + if [ + pids.len(), + addresses.len(), + node_ids.len(), + directories.len(), + ] != [4, 4, 4, 4] + { + return Err("ParticipantsNotIsolated".to_owned()); + } + Ok(()) +} + +fn read_llm_env(path: &Path) -> Result, String> { + let parsed = dotenvy::from_path_iter(path).map_err(sanitized)?; + let values: HashMap<_, _> = parsed + .map(|entry| entry.map_err(sanitized)) + .collect::>()?; + for name in ["OPENAI_BASE_URL", "OPENAI_API_KEY", "VLM_MODEL"] { + if !values.get(name).is_some_and(|value| !value.is_empty()) { + return Err(format!("MissingEnvironmentVariable:{name}")); + } + } + Ok(values) +} + +fn write_secret(path: &Path, bytes: &[u8]) -> Result<(), String> { + use std::{io::Write, os::unix::fs::OpenOptionsExt}; + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(path) + .map_err(sanitized)?; + file.write_all(bytes).map_err(sanitized)?; + file.write_all(b"\n").map_err(sanitized)?; + file.flush().map_err(sanitized)?; + file.sync_data().map_err(sanitized) +} + +fn random_signing_key() -> Result { + let mut secret = [0_u8; 32]; + getrandom::fill(&mut secret).map_err(sanitized)?; + Ok(SigningKey::from_bytes(&secret)) +} + +fn role(profile: NodeProfile) -> NodeRole { + match profile { + NodeProfile::Directory => NodeRole::Directory, + NodeProfile::Requester => NodeRole::Requester, + NodeProfile::Executor => NodeRole::Executor, + NodeProfile::Verifier => NodeRole::Verifier, + } +} + +fn unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn sanitized(error: impl std::fmt::Debug) -> String { + format!("{error:?}") +} diff --git a/src/lib.rs b/src/lib.rs index b2b14d8..43c9169 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ pub mod adapters; +pub mod demo; +pub mod node; pub mod protocol; pub mod runtime; pub mod transport; diff --git a/src/main.rs b/src/main.rs index 88f1995..f8d133e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,81 @@ -fn main() { - eprintln!("AgenNet CLI runtime is under construction"); +use std::path::PathBuf; + +use agenet::{demo, node}; +use clap::{Args, Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command(name = "agenet", version, about)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Node(NodeArgs), + Demo(DemoArgs), +} + +#[derive(Debug, Args)] +struct NodeArgs { + #[arg(long)] + profile: node::NodeProfile, + #[arg(long)] + state_dir: PathBuf, + #[arg(long)] + key_file: PathBuf, + #[arg(long)] + credential_file: PathBuf, + #[arg(long)] + root_public_key_file: PathBuf, + #[arg(long)] + ready_file: PathBuf, + #[arg(long)] + directory_seed: Option, + #[arg(long)] + control_token_file: Option, +} + +#[derive(Debug, Args)] +struct DemoArgs { + #[arg(long)] + env_file: PathBuf, + #[arg(long)] + artifact: PathBuf, + #[arg(long)] + state_dir: Option, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter("agenet=info") + .with_target(false) + .without_time() + .init(); + let result = match Cli::parse().command { + Command::Node(args) => node::run(node::NodeOptions { + profile: args.profile, + state_dir: args.state_dir, + key_file: args.key_file, + credential_file: args.credential_file, + root_public_key_file: args.root_public_key_file, + ready_file: args.ready_file, + directory_seed: args.directory_seed, + control_token_file: args.control_token_file, + }) + .await + .map(|_| ()), + Command::Demo(args) => demo::run(demo::DemoOptions { + env_file: args.env_file, + artifact: args.artifact, + state_dir: args.state_dir, + }) + .await + .map(|_| ()), + }; + if let Err(error) = result { + eprintln!("agenet failed: {error}"); + std::process::exit(1); + } } diff --git a/src/node.rs b/src/node.rs new file mode 100644 index 0000000..b645b10 --- /dev/null +++ b/src/node.rs @@ -0,0 +1,261 @@ +use std::{ + fs, + net::{IpAddr, SocketAddr}, + path::PathBuf, + sync::Arc, +}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use clap::ValueEnum; +use ed25519_dalek::VerifyingKey; +use serde::{Deserialize, Serialize}; +use tokio::net::TcpListener; + +use crate::{ + adapters::OpenAiDecisionAdapter, + protocol::{ + CapabilityId, CapabilityManifest, NodeRole, SideEffectProfile, SignedNodeCredential, + }, + runtime::{ + ArtifactAccessService, ArtifactStore, ContractRecorder, DirectoryRegistry, NodeIdentity, + ProviderService, RequesterService, read_signing_key, + }, + transport::{PeerClient, directory_router, provider_router, requester_router}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] +pub enum NodeProfile { + Directory, + Requester, + Executor, + Verifier, +} + +impl NodeProfile { + fn role(self) -> NodeRole { + match self { + Self::Directory => NodeRole::Directory, + Self::Requester => NodeRole::Requester, + Self::Executor => NodeRole::Executor, + Self::Verifier => NodeRole::Verifier, + } + } +} + +#[derive(Debug)] +pub struct NodeOptions { + pub profile: NodeProfile, + pub state_dir: PathBuf, + pub key_file: PathBuf, + pub credential_file: PathBuf, + pub root_public_key_file: PathBuf, + pub ready_file: PathBuf, + pub directory_seed: Option, + pub control_token_file: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReadyState { + pub profile: String, + pub pid: u32, + pub address: String, + pub node_id: String, + pub state_dir: PathBuf, + pub directory_seed: Option, +} + +pub async fn run(options: NodeOptions) -> Result { + fs::create_dir_all(&options.state_dir).map_err(sanitized)?; + let now = unix_ms(); + let identity = load_identity(&options, now)?; + if identity.claims().role != options.profile.role() { + return Err("CredentialRoleMismatch".to_owned()); + } + let listener = TcpListener::bind("127.0.0.1:0").await.map_err(sanitized)?; + let address = listener.local_addr().map_err(sanitized)?; + ensure_loopback(address)?; + let endpoint = format!("http://{address}"); + let recorder = Arc::new( + ContractRecorder::open(&options.state_dir, *identity.root(), now) + .await + .map_err(sanitized)?, + ); + let app = match options.profile { + NodeProfile::Directory => directory_router(DirectoryRegistry::new(), identity.clone(), now), + NodeProfile::Executor | NodeProfile::Verifier => { + let directory = options + .directory_seed + .as_deref() + .ok_or_else(|| "DirectorySeedRequired".to_owned())?; + let client = PeerClient::new(*identity.root(), now).map_err(sanitized)?; + let service = ProviderService::new( + identity.clone(), + recorder, + client.clone(), + options.profile.role(), + now, + ) + .map_err(sanitized)?; + register_capability(directory, &endpoint, &identity, &client, options.profile).await?; + provider_router(service) + } + NodeProfile::Requester => { + let directory = options + .directory_seed + .clone() + .ok_or_else(|| "DirectorySeedRequired".to_owned())?; + let control_token_file = options + .control_token_file + .as_ref() + .ok_or_else(|| "ControlTokenRequired".to_owned())?; + let control_token = fs::read_to_string(control_token_file) + .map_err(sanitized)? + .trim() + .to_owned(); + let store = ArtifactStore::open(&options.state_dir, identity.node_id().clone()) + .map_err(sanitized)?; + let access = ArtifactAccessService::new(store.clone(), *identity.root(), now); + let client = PeerClient::new(*identity.root(), now).map_err(sanitized)?; + let decision = OpenAiDecisionAdapter::new( + &required_env("OPENAI_BASE_URL")?, + &required_env("OPENAI_API_KEY")?, + &required_env("VLM_MODEL")?, + ) + .map_err(sanitized)?; + let service = RequesterService::new( + identity.clone(), + store, + access, + client, + decision, + directory, + endpoint.clone(), + ) + .map_err(sanitized)?; + requester_router(service, control_token) + } + }; + let ready = ReadyState { + profile: format!("{:?}", options.profile).to_lowercase(), + pid: std::process::id(), + address: endpoint, + node_id: identity.node_id().as_str().to_owned(), + state_dir: options.state_dir, + directory_seed: options.directory_seed, + }; + write_ready(&options.ready_file, &ready)?; + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .map_err(sanitized)?; + Ok(ready) +} + +fn load_identity(options: &NodeOptions, now: u64) -> Result { + let signing_key = read_signing_key(&options.key_file).map_err(sanitized)?; + let credential: SignedNodeCredential = + serde_json::from_slice(&fs::read(&options.credential_file).map_err(sanitized)?) + .map_err(sanitized)?; + let root_bytes = STANDARD + .decode( + fs::read_to_string(&options.root_public_key_file) + .map_err(sanitized)? + .trim(), + ) + .map_err(sanitized)?; + let root_array: [u8; 32] = root_bytes + .try_into() + .map_err(|_| "InvalidRootPublicKey".to_owned())?; + let root = VerifyingKey::from_bytes(&root_array).map_err(sanitized)?; + NodeIdentity::new(signing_key, credential, root, now).map_err(sanitized) +} + +async fn register_capability( + directory: &str, + endpoint: &str, + identity: &NodeIdentity, + client: &PeerClient, + profile: NodeProfile, +) -> Result<(), String> { + let (capability_id, kind, description) = match profile { + NodeProfile::Executor => ( + "capability:source-metrics-executor", + "source.metrics", + "Compute source metrics from an authorized Artifact", + ), + NodeProfile::Verifier => ( + "capability:source-metrics-verifier", + "source.metrics.verify", + "Independently recompute and verify source metrics", + ), + _ => return Err("UnsupportedCapabilityProfile".to_owned()), + }; + let manifest = CapabilityManifest { + capability_id: CapabilityId::new(capability_id).map_err(sanitized)?, + provider: identity.node_id().clone(), + kind: kind.to_owned(), + version: "v1".to_owned(), + description: description.to_owned(), + input_profile: "artifact.source.utf8.v1".to_owned(), + output_profile: "source.metrics.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: endpoint.to_owned(), + evidence_types: vec!["source.metrics.evidence.v1".to_owned()], + expires_at_unix_ms: unix_ms() + 300_000, + }; + let envelope = identity + .seal("capability.manifest.v1", &manifest) + .map_err(sanitized)?; + let _: serde_json::Value = client + .post_signed( + directory, + "/v0/capabilities/register", + &envelope, + "capability.registration.v1", + ) + .await + .map_err(sanitized)?; + Ok(()) +} + +fn write_ready(path: &PathBuf, state: &ReadyState) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(state).map_err(sanitized)?; + fs::write(path, bytes).map_err(sanitized) +} + +fn ensure_loopback(address: SocketAddr) -> Result<(), String> { + if !matches!(address.ip(), IpAddr::V4(ip) if ip.is_loopback()) { + return Err("UnsupportedNonLoopbackTransport".to_owned()); + } + Ok(()) +} + +fn required_env(name: &str) -> Result { + std::env::var(name).map_err(|_| format!("MissingEnvironmentVariable:{name}")) +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("SIGTERM handler must install"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + } + #[cfg(not(unix))] + let _ = tokio::signal::ctrl_c().await; +} + +fn unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn sanitized(error: impl std::fmt::Debug) -> String { + format!("{error:?}") +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 26626f4..078ec07 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -16,6 +16,6 @@ pub use identity::NodeIdentity; pub use key_store::{read_signing_key, write_signing_key}; pub use provider::ProviderService; pub use recorder::ContractRecorder; -pub use requester::{PursuitRequest, PursuitResult, RequesterService}; +pub use requester::{PursuitQuery, PursuitRequest, PursuitResult, RequesterService}; pub const MAX_ARTIFACT_BYTES: usize = 64 * 1024; diff --git a/src/runtime/requester.rs b/src/runtime/requester.rs index ff98a9d..8d5db36 100644 --- a/src/runtime/requester.rs +++ b/src/runtime/requester.rs @@ -1,7 +1,12 @@ -use std::{collections::BTreeMap, sync::Arc, time::Instant}; +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, + time::Instant, +}; use base64::{Engine, engine::general_purpose::STANDARD}; use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; use crate::{ adapters::OpenAiDecisionAdapter, @@ -23,6 +28,11 @@ pub struct PursuitRequest { pub media_type: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PursuitQuery { + pub intent_id: IntentId, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PursuitResult { pub intent_id: IntentId, @@ -46,6 +56,7 @@ pub struct RequesterService { decision: Arc, directory_endpoint: String, artifact_endpoint: String, + pursuits: Arc>>, } impl RequesterService { @@ -69,6 +80,7 @@ impl RequesterService { decision: Arc::new(decision), directory_endpoint, artifact_endpoint, + pursuits: Arc::new(RwLock::new(HashMap::new())), }) } @@ -150,7 +162,7 @@ impl RequesterService { phase_ms.insert("acceptance".to_owned(), elapsed_ms(accept_started)); phase_ms.insert("total".to_owned(), elapsed_ms(started)); - Ok(PursuitResult { + let result = PursuitResult { intent_id, source_contract_id: source_contract, verification_contract_id: verification_contract, @@ -167,7 +179,16 @@ impl RequesterService { llm_calls: decision.llm_calls, http: self.client.stats(), phase_ms, - }) + }; + self.pursuits + .write() + .await + .insert(result.intent_id.clone(), result.clone()); + Ok(result) + } + + pub async fn query(&self, query: &PursuitQuery) -> Option { + self.pursuits.read().await.get(&query.intent_id).cloned() } async fn route(&self, capability: &str) -> Result { diff --git a/src/transport/node.rs b/src/transport/node.rs index a779e92..3948b26 100644 --- a/src/transport/node.rs +++ b/src/transport/node.rs @@ -14,8 +14,8 @@ use crate::{ ArtifactReadRequest, ContractProposeRequest, ContractQuery, ErrorEnvelope, WireEnvelope, }, runtime::{ - ArtifactAccessService, NodeIdentity, ProviderService, PursuitRequest, RequesterService, - RuntimeError, + ArtifactAccessService, NodeIdentity, ProviderService, PursuitQuery, PursuitRequest, + RequesterService, RuntimeError, }, }; @@ -128,6 +128,7 @@ pub fn requester_router(service: RequesterService, control_token: String) -> Rou let artifact_routes = artifact_router(service.identity().clone(), service.access().clone()); let local_routes = Router::new() .route("/local/v0/pursuits", post(local_pursuit)) + .route("/local/v0/pursuits/query", post(local_pursuit_query)) .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) .with_state(Arc::new(RequesterHttpState { service, @@ -136,6 +137,24 @@ pub fn requester_router(service: RequesterService, control_token: String) -> Rou artifact_routes.merge(local_routes) } +async fn local_pursuit_query( + State(state): State>, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Response { + if !valid_bearer(&headers, &state.control_token) { + return error(StatusCode::UNAUTHORIZED, "InvalidControlToken"); + } + let Json(query) = match payload { + Ok(query) => query, + Err(_) => return error(StatusCode::BAD_REQUEST, "InvalidPursuitQuery"), + }; + match state.service.query(&query).await { + Some(result) => (StatusCode::OK, Json(result)).into_response(), + None => error(StatusCode::NOT_FOUND, "UnknownPursuit"), + } +} + async fn local_pursuit( State(state): State>, headers: HeaderMap, diff --git a/tests/multiprocess_demo.rs b/tests/multiprocess_demo.rs new file mode 100644 index 0000000..6233e68 --- /dev/null +++ b/tests/multiprocess_demo.rs @@ -0,0 +1,145 @@ +use std::{collections::HashSet, fs, path::Path, process::Command}; + +use agenet::{demo::DemoSummary, protocol::ContractState}; +use axum::{Json, Router, routing::post}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use tokio::{net::TcpListener, process::Command as AsyncCommand}; + +async fn completion() -> Json { + Json(json!({ + "choices": [{ + "message": { + "content": "{\"required_capability\":\"source.metrics.v1\",\"acceptance_profile\":\"exact-source-metrics.v1\",\"requires_independent_verifier\":true}" + } + }] + })) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn demo_runs_four_real_processes_and_reaches_verified_acceptance() { + let temp = TempDir::new().unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let llm_address = listener.local_addr().unwrap(); + let llm = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route("/chat/completions", post(completion)), + ) + .await + .unwrap(); + }); + let env_file = temp.path().join("demo.env"); + let sentinel = "sentinel-e2e-key-never-log"; + fs::write( + &env_file, + format!( + "OPENAI_BASE_URL=http://{llm_address}\nOPENAI_API_KEY={sentinel}\nVLM_MODEL=test-model\n" + ), + ) + .unwrap(); + let artifact = temp.path().join("sample.rs"); + let source = b"fn main() {\n println!(\"AgenNet\");\n}\n"; + fs::write(&artifact, source).unwrap(); + + let output = AsyncCommand::new(env!("CARGO_BIN_EXE_agenet")) + .arg("demo") + .arg("--env-file") + .arg(&env_file) + .arg("--artifact") + .arg(&artifact) + .arg("--state-dir") + .arg(temp.path().join("runs")) + .output() + .await + .unwrap(); + llm.abort(); + + assert!( + output.status.success(), + "demo stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let summary: DemoSummary = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(summary.participants.len(), 4); + assert_eq!( + summary.pursuit.state_path, + vec![ + ContractState::Proposed, + ContractState::Active, + ContractState::Running, + ContractState::Delivered, + ContractState::Accepted, + ] + ); + assert_eq!(summary.pursuit.llm_calls, 1); + assert_eq!( + summary.pursuit.executor_metrics, + summary.pursuit.verifier_metrics + ); + assert_eq!( + summary.pursuit.executor_metrics.byte_count, + source.len() as u64 + ); + assert_eq!(summary.pursuit.executor_metrics.line_count, 3); + assert_eq!(summary.pursuit.executor_metrics.non_empty_line_count, 3); + let digest = Sha256::digest(source); + let expected_hash = format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + assert_eq!(summary.pursuit.artifact_id, expected_hash); + + assert_unique(summary.participants.iter().map(|node| node.pid)); + assert_unique(summary.participants.iter().map(|node| node.address.clone())); + assert_unique(summary.participants.iter().map(|node| node.node_id.clone())); + assert_unique( + summary + .participants + .iter() + .map(|node| node.state_dir.clone()), + ); + let requester = summary + .participants + .iter() + .find(|node| node.profile == "requester") + .unwrap(); + let ready: serde_json::Value = + serde_json::from_slice(&fs::read(requester.state_dir.join("ready.json")).unwrap()).unwrap(); + let ready_text = serde_json::to_string(&ready).unwrap(); + assert!(ready.get("directory_seed").unwrap().is_string()); + assert!(!ready_text.contains("executor")); + assert!(!ready_text.contains("verifier")); + + for node in &summary.participants { + assert!(node.state_dir.join("journal.jsonl").exists()); + let stdout = fs::read_to_string(node.state_dir.join("stdout.log")).unwrap(); + let stderr = fs::read_to_string(node.state_dir.join("stderr.log")).unwrap(); + assert!(!stdout.contains(sentinel)); + assert!(!stderr.contains(sentinel)); + assert!(!process_exists(node.pid), "PID {} was not reaped", node.pid); + } + assert!(!String::from_utf8_lossy(&output.stdout).contains(sentinel)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(sentinel)); +} + +fn assert_unique(values: impl IntoIterator) { + let values: Vec = values.into_iter().collect(); + let unique: HashSet<_> = values.iter().collect(); + assert_eq!(unique.len(), values.len()); +} + +fn process_exists(pid: u32) -> bool { + Command::new("/bin/kill") + .arg("-0") + .arg(pid.to_string()) + .status() + .is_ok_and(|status| status.success()) +} + +#[allow(dead_code)] +fn _assert_local_path(_: &Path) {} From fda7a6b574101b34567c721b9a5fe46ac653d6d9 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 00:09:07 +0800 Subject: [PATCH 05/67] [chore] Verify AgenNet loopback MVP Root cause: The Walkman OPENAI_BASE_URL alias identifies a complete ModelHub endpoint, but the first adapter treated it as an OpenAI base path. Solution: Separate the tested OpenAI and ModelHub wire styles, preserve safe error categories, pass all automated gates, and record the real-model run. Post-mortem: Provider semantics must be verified from the owning API style and reference implementation before endpoint construction. Risks: Validation remains loopback-only and does not establish TLS, multi-machine failover, quota, or arbitrary-code sandbox guarantees. Dependency: e38043b Links: plan/00-v2-modelhub-adapter-correction.md --- Cargo.lock | 2 + Cargo.toml | 3 +- README.md | 2 +- ROADMAP.md | 17 +++ docs/design/agenet-v0.1.md | 4 +- plan/00-v2-modelhub-adapter-correction.md | 31 +++++ src/adapters/decision.rs | 159 +++++++++++++++++++--- src/adapters/mod.rs | 3 +- src/node.rs | 4 +- src/runtime/error.rs | 5 + src/runtime/requester.rs | 19 ++- tests/llm_adapter.rs | 105 +++++++++++++- tests/multiprocess_demo.rs | 9 +- 13 files changed, 318 insertions(+), 45 deletions(-) create mode 100644 plan/00-v2-modelhub-adapter-correction.md diff --git a/Cargo.lock b/Cargo.lock index 5eb266c..30253de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "zeroize", ] [[package]] @@ -1361,6 +1362,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index b76fb63..4ef17f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ clap = { version = "=4.6.6", features = ["derive"] } dotenvy = "=0.15.7" ed25519-dalek = { version = "=3.0.0", features = ["rand_core"] } getrandom = "=0.4.3" -reqwest = { version = "=0.13.4", features = ["json"] } +reqwest = { version = "=0.13.4", features = ["json", "query"] } serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" sha2 = "=0.11.0" @@ -29,6 +29,7 @@ tokio = { version = "=1.53.1", features = ["full"] } tracing = "=0.1.44" tracing-subscriber = { version = "=0.3.20", features = ["env-filter", "fmt"] } uuid = { version = "=1.24.0", features = ["serde", "v4"] } +zeroize = "=1.9.0" [dev-dependencies] http-body-util = "=0.1.5" diff --git a/README.md b/README.md index 4823f1e..b8a9d86 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ cargo run -- demo \ The environment file is read in place. It is never copied, logged, or committed. -Required names are `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `VLM_MODEL`. Only the Requester child receives them. The other three children are started with a cleared environment. The demo never falls back to `DeterministicDecisionAdapter`; an invalid model response fails explicitly after one format-repair request. +Required Walkman alias names are `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `VLM_MODEL`. For this command, `OPENAI_BASE_URL` is the complete ModelHub `gemini_multimodal_inline_v1` endpoint: the adapter does not append a route, places the credential only in the `ak` query parameter, and sends inline text content. Only the Requester child receives these three variables. The other three children are started with a cleared environment. The demo never falls back to `DeterministicDecisionAdapter`; an invalid model response fails explicitly after one format-repair request. The successful command prints one JSON summary containing: diff --git a/ROADMAP.md b/ROADMAP.md index 871058b..6a30c6a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,22 @@ # ROADMAP +## 2026-08-14 00:35 CST + +- **Change**: Passed the real Walkman-backed four-process demo after the v2 provider correction. +- **Files**: Runtime evidence retained under ignored `.local/demo/3cf7bccc-932b-4b77-9ae5-514f8a52f961/`; validation status updated in `ROADMAP.md` and `docs/design/agenet-v0.1.md`. +- **Decision**: Mark the loopback MVP's real-model gate verified while keeping all multi-machine, TLS, sandbox, quota, and failover claims deferred. +- **Reason**: The real model produced a valid Intent in one call; Executor and Verifier independently matched Artifact `sha256:ab50610cf384a1553be8b36341366601efbf03c02b2568719ca150f07b64cd50` at 151 bytes, 4 lines, and 4 non-empty lines; the source Contract reached `Accepted` in 2047 ms total. +- **Evidence**: Four distinct PIDs and loopback ports were observed and reaped; peer HTTP counters were 13 requests, 17,699 bytes sent, and 36,701 bytes received; log scanning found no Authorization header, bearer value, API-key variable, or `ak` query string. + +## 2026-08-14 00:20 CST + +- **Change**: Corrected the Walkman Decision Adapter from an assumed OpenAI route to the verified `gemini_multimodal_inline_v1` ModelHub contract; added safe upstream status classification and provider-style contract tests. +- **Files**: `src/adapters/decision.rs`, `src/runtime/requester.rs`, `src/node.rs`, `tests/llm_adapter.rs`, `tests/multiprocess_demo.rs`, `plan/00-v2-modelhub-adapter-correction.md`, `README.md`, `docs/design/agenet-v0.1.md`. +- **Decision**: Keep OpenAI and ModelHub styles explicit; the Walkman demo posts to the complete endpoint with `ak` query authentication and inline text. +- **Reason**: The real demo consistently returned HTTP 404 before any protocol work. Inspection of Walkman and QueryAgent showed that `OPENAI_BASE_URL` is only a compatibility alias for a complete ModelHub endpoint. +- **Error record**: Technical blind spot — the initial implementation inferred provider semantics from an environment-variable name and collapsed all adapter failures to `DecisionFailed`. +- **Prevention**: Before integrating an inherited endpoint, inspect the owning provider's API-style setting and reference implementation; add a contract test for exact path, authentication placement, body schema, response schema, and sanitized error category. + ## 2026-08-13 23:50 CST - **Change**: Added the real OpenAI-compatible Decision Adapter, node CLI, four-process demo harness, retained audit state, graceful SIGTERM shutdown, and end-to-end process test. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 4aeab3c..53ed77c 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -50,7 +50,7 @@ Only the Requester can append `Accepted`, and it does so only after matching the ## LLM and demo adapter -The OpenAI-compatible adapter receives only the natural-language goal, the strict Intent projection schema, and the public Capability kind. It uses `temperature: 0`, limits responses to 64 KiB, performs at most one real format-repair call, and has no manual-demo fallback. Authorization is installed as a sensitive header and adapter Debug output omits it. +The Decision layer receives only the natural-language goal, the strict Intent projection schema, and the public Capability kind. Two explicitly tested wire styles exist: `openai_chat_completions_v1` appends `/chat/completions` when required and uses a sensitive Authorization header; the Walkman-backed manual demo uses `gemini_multimodal_inline_v1`, treats the configured URL as a complete endpoint, places the credential in the `ak` query parameter, and sends inline text content. Neither style puts credentials in Debug output or logs. Both use `temperature: 0`, limit responses to 64 KiB, perform at most one real format-repair call, reject redirects, and have no manual-demo fallback. The demo provisions an ephemeral Domain Root and four Node Credentials, starts four copies of the `agenet node` binary on `127.0.0.1:0`, waits for signed Capability registration, submits one local pursuit with a bearer token read from a `0600` file, enforces a 90-second outer timeout, sends SIGTERM, and retains state for audit. @@ -63,7 +63,7 @@ The demo provisions an ephemeral Domain Root and four Node Credentials, starts f | Journal replay and operation idempotency | automated | restart test | | Independent source metric reproduction | automated | separate implementations plus third oracle | | Four PIDs and four dynamic ports | automated | real child-process test | -| Real model Intent projection | manual gate | Walkman env demo only | +| Real ModelHub Intent projection | verified locally | Walkman env run `3cf7bccc-932b-4b77-9ae5-514f8a52f961` | | TLS or secure multi-machine sessions | not implemented | deferred | | Sandbox for arbitrary code | not implemented | deferred | | Quota, replication, failover, and federation | not implemented | deferred | diff --git a/plan/00-v2-modelhub-adapter-correction.md b/plan/00-v2-modelhub-adapter-correction.md new file mode 100644 index 0000000..d29cf78 --- /dev/null +++ b/plan/00-v2-modelhub-adapter-correction.md @@ -0,0 +1,31 @@ +# AgenNet ModelHub Adapter Correction Plan + +## Goal + +Correct the manual Walkman-backed model gate without changing AgenNet protocol semantics or hiding the first failed experiment. + +## Preconditions + +- Walkman's ignored `.env` remains the only credential source. +- Walkman and QueryAgent define the alias as a complete `gemini_multimodal_inline_v1` endpoint. +- No prompt, response body, endpoint, API key, or query-bearing URL may be logged. + +## Steps + +1. Preserve non-success HTTP status as a sanitized error category and reproduce the failure. +2. Inspect the owning Walkman/QueryAgent provider implementation rather than infer behavior from `OPENAI_BASE_URL`. +3. Add a failing contract test for exact endpoint use, `ak` query authentication, inline text messages, and response parsing. +4. Implement the separate ModelHub style while retaining the tested OpenAI style. +5. Re-run all automated gates and the real-model four-process demo. + +## Acceptance + +- The ModelHub test observes no appended `/chat/completions` route. +- Credentials remain absent from Debug output, errors, logs, repository content, and demo output. +- The real demo reports one or two genuine model calls and reaches `Accepted` without deterministic fallback. +- All prior protocol, runtime, HTTP, workload, and process tests remain green. + +## Risks + +- Query-parameter authentication can leak if raw Reqwest errors or final URLs are logged; the adapter maps errors to closed enums and never exposes the request URL. +- The endpoint contract belongs to an internal provider and may evolve; the style remains explicit and covered by a wire-level test. diff --git a/src/adapters/decision.rs b/src/adapters/decision.rs index a594361..28776c4 100644 --- a/src/adapters/decision.rs +++ b/src/adapters/decision.rs @@ -6,6 +6,7 @@ use reqwest::{ }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use zeroize::Zeroizing; const MAX_LLM_RESPONSE_BYTES: usize = 64 * 1024; @@ -27,6 +28,8 @@ pub struct DecisionResult { pub enum DecisionError { InvalidConfiguration, RequestFailed, + NonSuccessStatus(u16), + InvalidResponse, ResponseTooLarge, AgentDecisionInvalid, } @@ -47,36 +50,73 @@ impl DeterministicDecisionAdapter { } } -pub struct OpenAiDecisionAdapter { +pub struct LlmDecisionAdapter { client: Client, endpoint: Url, model: String, + style: DecisionApiStyle, + query_credential: Option>, } -impl Debug for OpenAiDecisionAdapter { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DecisionApiStyle { + OpenAiChatCompletionsV1, + GeminiMultimodalInlineV1, +} + +impl Debug for LlmDecisionAdapter { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter - .debug_struct("OpenAiDecisionAdapter") - .field("endpoint", &self.endpoint) + .debug_struct("LlmDecisionAdapter") .field("model", &self.model) + .field("style", &self.style) .finish_non_exhaustive() } } -impl OpenAiDecisionAdapter { +impl LlmDecisionAdapter { pub fn new(base_url: &str, api_key: &str, model: &str) -> Result { + Self::build( + completion_endpoint(base_url)?, + api_key, + model, + DecisionApiStyle::OpenAiChatCompletionsV1, + ) + } + + pub fn new_modelhub(endpoint: &str, api_key: &str, model: &str) -> Result { + Self::build( + exact_endpoint(endpoint)?, + api_key, + model, + DecisionApiStyle::GeminiMultimodalInlineV1, + ) + } + + fn build( + endpoint: Url, + api_key: &str, + model: &str, + style: DecisionApiStyle, + ) -> Result { if api_key.is_empty() || model.is_empty() { return Err(DecisionError::InvalidConfiguration); } - let endpoint = completion_endpoint(base_url)?; - let mut authorization = HeaderValue::from_str(&format!("Bearer {api_key}")) - .map_err(|_| DecisionError::InvalidConfiguration)?; - authorization.set_sensitive(true); let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, authorization); + let query_credential = match style { + DecisionApiStyle::OpenAiChatCompletionsV1 => { + let mut authorization = HeaderValue::from_str(&format!("Bearer {api_key}")) + .map_err(|_| DecisionError::InvalidConfiguration)?; + authorization.set_sensitive(true); + headers.insert(AUTHORIZATION, authorization); + None + } + DecisionApiStyle::GeminiMultimodalInlineV1 => Some(Zeroizing::new(api_key.to_owned())), + }; let client = Client::builder() .connect_timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(60)) + .redirect(reqwest::redirect::Policy::none()) .default_headers(headers) .build() .map_err(|_| DecisionError::InvalidConfiguration)?; @@ -84,6 +124,8 @@ impl OpenAiDecisionAdapter { client, endpoint, model: model.to_owned(), + style, + query_credential, }) } @@ -120,19 +162,14 @@ impl OpenAiDecisionAdapter { } async fn completion(&self, messages: Vec) -> Result { - let response = self - .client - .post(self.endpoint.clone()) - .json(&json!({ - "model": self.model, - "temperature": 0, - "messages": messages, - })) + let (request, body) = self.request(messages)?; + let response = request + .json(&body) .send() .await .map_err(|_| DecisionError::RequestFailed)?; if !response.status().is_success() { - return Err(DecisionError::RequestFailed); + return Err(DecisionError::NonSuccessStatus(response.status().as_u16())); } if response .content_length() @@ -148,13 +185,46 @@ impl OpenAiDecisionAdapter { return Err(DecisionError::ResponseTooLarge); } let response: CompletionResponse = - serde_json::from_slice(&bytes).map_err(|_| DecisionError::RequestFailed)?; + serde_json::from_slice(&bytes).map_err(|_| DecisionError::InvalidResponse)?; response .choices .into_iter() .next() .map(|choice| choice.message.content) - .ok_or(DecisionError::RequestFailed) + .ok_or(DecisionError::InvalidResponse) + } + + fn request( + &self, + messages: Vec, + ) -> Result<(reqwest::RequestBuilder, Value), DecisionError> { + let request = self.client.post(self.endpoint.clone()); + match self.style { + DecisionApiStyle::OpenAiChatCompletionsV1 => Ok(( + request, + json!({ + "model": self.model, + "temperature": 0, + "messages": messages, + }), + )), + DecisionApiStyle::GeminiMultimodalInlineV1 => { + let credential = self + .query_credential + .as_ref() + .ok_or(DecisionError::InvalidConfiguration)?; + let messages = inline_messages(messages)?; + Ok(( + request.query(&[("ak", credential.as_str())]), + json!({ + "max_tokens": 4096, + "model": self.model, + "temperature": 0, + "messages": messages, + }), + )) + } + } } } @@ -181,12 +251,57 @@ fn completion_endpoint(base_url: &str) -> Result { format!("{base_url}/chat/completions") }; let url = Url::parse(&endpoint).map_err(|_| DecisionError::InvalidConfiguration)?; - if !matches!(url.scheme(), "http" | "https") { + if !secure_or_loopback(&url) { return Err(DecisionError::InvalidConfiguration); } Ok(url) } +fn exact_endpoint(endpoint: &str) -> Result { + let url = Url::parse(endpoint).map_err(|_| DecisionError::InvalidConfiguration)?; + if !secure_or_loopback(&url) + || url.username() != "" + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(DecisionError::InvalidConfiguration); + } + Ok(url) +} + +fn secure_or_loopback(url: &Url) -> bool { + if url.scheme() == "https" { + return true; + } + if url.scheme() != "http" { + return false; + } + url.host_str() + .and_then(|host| host.parse::().ok()) + .is_some_and(|address| address.is_loopback()) +} + +fn inline_messages(messages: Vec) -> Result, DecisionError> { + messages + .into_iter() + .map(|message| { + let role = message + .get("role") + .and_then(Value::as_str) + .ok_or(DecisionError::InvalidConfiguration)?; + let content = message + .get("content") + .and_then(Value::as_str) + .ok_or(DecisionError::InvalidConfiguration)?; + Ok(json!({ + "role": role, + "content": [{"type": "text", "text": content}], + })) + }) + .collect() +} + fn decision_prompt(goal: &str) -> String { format!( "Project this goal into an AgenNet Intent. Public capability kind: source.metrics.v1. Goal: {goal}\nReturn only strict JSON with exactly these fields: required_capability (must be source.metrics.v1), acceptance_profile (must be exact-source-metrics.v1), requires_independent_verifier (must be true)." diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index b095221..f0ed7be 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -1,8 +1,7 @@ mod decision; pub use decision::{ - AgentDecision, DecisionError, DecisionResult, DeterministicDecisionAdapter, - OpenAiDecisionAdapter, + AgentDecision, DecisionError, DecisionResult, DeterministicDecisionAdapter, LlmDecisionAdapter, }; pub mod executor_metrics; pub mod verifier_metrics; diff --git a/src/node.rs b/src/node.rs index b645b10..2847853 100644 --- a/src/node.rs +++ b/src/node.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; use crate::{ - adapters::OpenAiDecisionAdapter, + adapters::LlmDecisionAdapter, protocol::{ CapabilityId, CapabilityManifest, NodeRole, SideEffectProfile, SignedNodeCredential, }, @@ -116,7 +116,7 @@ pub async fn run(options: NodeOptions) -> Result { .map_err(sanitized)?; let access = ArtifactAccessService::new(store.clone(), *identity.root(), now); let client = PeerClient::new(*identity.root(), now).map_err(sanitized)?; - let decision = OpenAiDecisionAdapter::new( + let decision = LlmDecisionAdapter::new_modelhub( &required_env("OPENAI_BASE_URL")?, &required_env("OPENAI_API_KEY")?, &required_env("VLM_MODEL")?, diff --git a/src/runtime/error.rs b/src/runtime/error.rs index b9216ac..e944417 100644 --- a/src/runtime/error.rs +++ b/src/runtime/error.rs @@ -19,6 +19,11 @@ pub enum RuntimeError { UnsupportedArtifactEncoding, EvidenceMismatch, DecisionFailed, + LlmRequestFailed, + LlmNonSuccessStatus(u16), + LlmInvalidResponse, + LlmResponseTooLarge, + AgentDecisionInvalid, TransportFailed, CapabilityUnavailable, ContractExecutionFailed, diff --git a/src/runtime/requester.rs b/src/runtime/requester.rs index 8d5db36..5a928c8 100644 --- a/src/runtime/requester.rs +++ b/src/runtime/requester.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use crate::{ - adapters::OpenAiDecisionAdapter, + adapters::{DecisionError, LlmDecisionAdapter}, protocol::{ AcceptanceProfile, CandidateSet, CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractProjection, ContractProposeRequest, ContractProposeResponse, @@ -53,7 +53,7 @@ pub struct RequesterService { store: ArtifactStore, access: ArtifactAccessService, client: PeerClient, - decision: Arc, + decision: Arc, directory_endpoint: String, artifact_endpoint: String, pursuits: Arc>>, @@ -65,7 +65,7 @@ impl RequesterService { store: ArtifactStore, access: ArtifactAccessService, client: PeerClient, - decision: OpenAiDecisionAdapter, + decision: LlmDecisionAdapter, directory_endpoint: String, artifact_endpoint: String, ) -> Result { @@ -106,7 +106,7 @@ impl RequesterService { .decision .decide(&request.goal) .await - .map_err(|_| RuntimeError::DecisionFailed)?; + .map_err(map_decision_error)?; phase_ms.insert("llm_decision".to_owned(), elapsed_ms(decision_started)); let intent_id = IntentId::new(format!("intent:{}", uuid::Uuid::new_v4()))?; @@ -381,3 +381,14 @@ fn unix_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } + +fn map_decision_error(error: DecisionError) -> RuntimeError { + match error { + DecisionError::InvalidConfiguration => RuntimeError::DecisionFailed, + DecisionError::RequestFailed => RuntimeError::LlmRequestFailed, + DecisionError::NonSuccessStatus(status) => RuntimeError::LlmNonSuccessStatus(status), + DecisionError::InvalidResponse => RuntimeError::LlmInvalidResponse, + DecisionError::ResponseTooLarge => RuntimeError::LlmResponseTooLarge, + DecisionError::AgentDecisionInvalid => RuntimeError::AgentDecisionInvalid, + } +} diff --git a/tests/llm_adapter.rs b/tests/llm_adapter.rs index 020ad38..3523472 100644 --- a/tests/llm_adapter.rs +++ b/tests/llm_adapter.rs @@ -1,9 +1,13 @@ use std::{collections::VecDeque, sync::Arc}; use agenet::adapters::{ - AgentDecision, DecisionError, DeterministicDecisionAdapter, OpenAiDecisionAdapter, + AgentDecision, DecisionError, DeterministicDecisionAdapter, LlmDecisionAdapter, +}; +use axum::{ + Json, Router, + extract::{OriginalUri, State}, + routing::post, }; -use axum::{Json, Router, extract::State, routing::post}; use serde_json::{Value, json}; use tokio::{net::TcpListener, sync::Mutex}; @@ -21,6 +25,25 @@ async fn completion(State(state): State, Json(request): Json) })) } +async fn rejected_completion() -> axum::http::StatusCode { + axum::http::StatusCode::BAD_REQUEST +} + +async fn modelhub_completion( + State(state): State, + OriginalUri(uri): OriginalUri, + Json(request): Json, +) -> Json { + state + .requests + .lock() + .await + .push(json!({"request_uri": uri.to_string(), "body": request})); + Json(json!({ + "choices": [{"message": {"content": valid_decision()}}] + })) +} + async fn fake_server(responses: Vec<&str>) -> (String, FakeState, tokio::task::JoinHandle<()>) { let state = FakeState { responses: Arc::new(Mutex::new( @@ -53,7 +76,7 @@ fn deterministic_adapter_is_explicit_and_never_claims_an_llm_call() { #[tokio::test] async fn valid_json_produces_a_typed_decision_without_source_content() { let (base_url, state, server) = fake_server(vec![valid_decision()]).await; - let adapter = OpenAiDecisionAdapter::new(&base_url, "sentinel-secret", "test-model").unwrap(); + let adapter = LlmDecisionAdapter::new(&base_url, "sentinel-secret", "test-model").unwrap(); let result = adapter .decide("Compute independently verified source metrics") @@ -78,7 +101,7 @@ async fn valid_json_produces_a_typed_decision_without_source_content() { #[tokio::test] async fn malformed_json_gets_one_repair_request() { let (base_url, state, server) = fake_server(vec!["not-json", valid_decision()]).await; - let adapter = OpenAiDecisionAdapter::new(&base_url, "sentinel-secret", "test-model").unwrap(); + let adapter = LlmDecisionAdapter::new(&base_url, "sentinel-secret", "test-model").unwrap(); let result = adapter.decide("Compute metrics").await.unwrap(); @@ -90,7 +113,7 @@ async fn malformed_json_gets_one_repair_request() { #[tokio::test] async fn two_malformed_responses_fail_explicitly_without_secret_leakage() { let (base_url, _state, server) = fake_server(vec!["bad-one", "bad-two"]).await; - let adapter = OpenAiDecisionAdapter::new(&base_url, "sentinel-secret", "test-model").unwrap(); + let adapter = LlmDecisionAdapter::new(&base_url, "sentinel-secret", "test-model").unwrap(); let error = adapter.decide("Compute metrics").await.unwrap_err(); @@ -98,3 +121,75 @@ async fn two_malformed_responses_fail_explicitly_without_secret_leakage() { assert!(!format!("{error:?}").contains("sentinel-secret")); server.abort(); } + +#[tokio::test] +async fn non_success_status_is_preserved_without_response_body() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route("/chat/completions", post(rejected_completion)), + ) + .await + .unwrap(); + }); + let adapter = LlmDecisionAdapter::new( + &format!("http://{address}"), + "sentinel-secret", + "test-model", + ) + .unwrap(); + + let error = adapter.decide("Compute metrics").await.unwrap_err(); + + assert_eq!(error, DecisionError::NonSuccessStatus(400)); + assert!(!format!("{error:?}").contains("sentinel-secret")); + server.abort(); +} + +#[tokio::test] +async fn modelhub_style_uses_the_exact_endpoint_query_credential_and_inline_text() { + let state = FakeState { + responses: Arc::new(Mutex::new(VecDeque::new())), + requests: Arc::new(Mutex::new(Vec::new())), + }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = Router::new() + .route("/llm_router", post(modelhub_completion)) + .with_state(state.clone()); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let adapter = LlmDecisionAdapter::new_modelhub( + &format!("http://{address}/llm_router"), + "sentinel-secret", + "test-model", + ) + .unwrap(); + + let result = adapter.decide("Compute metrics").await.unwrap(); + + assert_eq!(result.llm_calls, 1); + let requests = state.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["request_uri"], "/llm_router?ak=sentinel-secret"); + assert!(requests[0]["body"]["messages"][0]["content"].is_array()); + assert_eq!(requests[0]["body"]["max_tokens"], 4096); + assert!(!format!("{adapter:?}").contains("sentinel-secret")); + assert!(!format!("{adapter:?}").contains("llm_router")); + server.abort(); +} + +#[test] +fn remote_plaintext_modelhub_endpoint_is_rejected() { + assert!(matches!( + LlmDecisionAdapter::new_modelhub( + "http://modelhub.example/llm_router", + "sentinel-secret", + "test-model", + ), + Err(DecisionError::InvalidConfiguration) + )); +} diff --git a/tests/multiprocess_demo.rs b/tests/multiprocess_demo.rs index 6233e68..120dcb1 100644 --- a/tests/multiprocess_demo.rs +++ b/tests/multiprocess_demo.rs @@ -23,12 +23,9 @@ async fn demo_runs_four_real_processes_and_reaches_verified_acceptance() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let llm_address = listener.local_addr().unwrap(); let llm = tokio::spawn(async move { - axum::serve( - listener, - Router::new().route("/chat/completions", post(completion)), - ) - .await - .unwrap(); + axum::serve(listener, Router::new().route("/", post(completion))) + .await + .unwrap(); }); let env_file = temp.path().join("demo.env"); let sentinel = "sentinel-e2e-key-never-log"; From b0eb409082cec9b57f80d4246e794fd323368707 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 12:58:17 +0800 Subject: [PATCH 06/67] [doc] Design AgenNet node bootstrap Root cause: NA Solution: Document the approved secure node bootstrap, Agent onboarding, and bilingual GitHub Pages architecture with explicit acceptance gates. Risks: The v0.2 design is not implemented or multi-machine verified yet. Dependency: AgenNet loopback MVP commit fda7a6b. Links: docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md --- ROADMAP.md | 8 + ...6-08-14-node-bootstrap-and-pages-design.md | 600 ++++++++++++++++++ 2 files changed, 608 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md diff --git a/ROADMAP.md b/ROADMAP.md index 6a30c6a..58db49e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,13 @@ # ROADMAP +## 2026-08-14 12:54 CST + +- **Change**: Approved the v0.2 design for secure private-overlay Node Bootstrap, layered human/Agent installation surfaces, and a bilingual AgenNet GitHub Pages site. +- **Files**: `docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md`, `ROADMAP.md`; ignored visual studies under `.superpowers/brainstorm/`. +- **Decision**: Use a protocol-aware `agenet` CLI as the single state-changing path; add an Authority credential chain, TLS-pinned one-time enrollment, user-level macOS/Linux services, a versioned generic Agent guide and Codex Skill, Astro/Starlight documentation, and the approved A3 Field Study visual direction. +- **Reason**: A shortcut or polished site is useful only if it represents a real, recoverable, revocable multi-machine onboarding path rather than wrapping the loopback demo or duplicating security logic across scripts and Skills. +- **Boundary**: The design remains a Developer Preview and defers public-Internet transport, Windows, root daemons, automatic updates, hosted control plane, automatic Agent exposure, arbitrary remote shell, and live node state on GitHub Pages. + ## 2026-08-14 00:35 CST - **Change**: Passed the real Walkman-backed four-process demo after the v2 provider correction. diff --git a/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md new file mode 100644 index 0000000..69a59d3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md @@ -0,0 +1,600 @@ +# AgenNet Node Bootstrap and Public Site Design + +**Status:** Approved for implementation planning +**Date:** 2026-08-14 +**Depends on:** AgenNet v0.1 loopback MVP at `fda7a6b` +**Target milestone:** AgenNet v0.2 Developer Preview + +## 1. Purpose + +This design extends the verified loopback MVP into the smallest honest +multi-machine AgenNet preview. A user must be able to create a private AgenNet +Domain on one machine, authorize a second macOS or Linux machine with a +short-lived invitation, keep that node running as a user service, discover it, +and revoke it. The same workflow must be accessible through a stable CLI, a +versioned guide that general coding Agents can follow, an optional Codex Skill, +and a bilingual GitHub Pages site. + +All defaults in this document remain revisable after real two-device testing. +Revisions must be versioned, justified in `ROADMAP.md`, and preserve or migrate +public protocol and persistent-state consumers. + +## 2. Naming + +- The project, network, protocol, website, and documentation name is + **AgenNet**. +- The GitHub repository is `Nexa-Language/AgenNet`. +- Only the executable, Rust package, commands, and filesystem identifiers use + the lowercase form `agenet`. +- Display text must not use `AgenNET`, `AgentNet`, or `agennet`. +- CI will scan public copy for the rejected display variants. + +## 3. Goals and Non-goals + +### 3.1 Goals + +1. Establish multi-machine communication over an existing private overlay + network with application TLS and signed AgenNet protocol objects. +2. Enroll a node through a short-lived, single-use invitation without moving + the Domain Root or the new node's private key between machines. +3. Run the node after login without root privileges on macOS and Linux. +4. Provide one protocol-aware CLI path used by humans, installation scripts, + coding Agents, and Skills. +5. Publish fixed-version release artifacts, bootstrap metadata, installation + guidance, project status, and bilingual documentation through GitHub. +6. Validate the complete path on two physical devices, with additional + development machines used for failure scenarios. + +### 3.2 Non-goals + +- Public-Internet discovery, arbitrary public binds, NAT traversal, or a + managed AgenNet control plane. +- Windows, system-wide root daemons, or unattended automatic updates. +- Automatic exposure of an installed Agent, its API keys, its tools, or its + workspaces. +- An unauthenticated join path or an invitation form hosted on GitHub Pages. +- Remote arbitrary shell execution or an unsandboxed build/test Capability. +- Live node status, telemetry collection, or user accounts on the static site. +- A claim of Internet-scale reliability, secure sandboxing, quota enforcement, + replication, or failover. + +## 4. Considered Approaches + +### 4.1 Layered bootstrap — selected + +The `agenet` CLI owns installation state, enrollment, service management, +diagnostics, adapters, and lifecycle rules. Shell installers, public guides, +and Skills invoke the CLI instead of reimplementing these rules. + +This approach has a larger initial protocol and release surface, but it keeps +Agent-assisted and non-Agent devices behaviorally identical and testable. + +### 4.2 Shell-first installer — rejected + +Putting enrollment and lifecycle logic in `install.sh` would be fast initially, +but platform detection, secret handling, recovery, and service state would +diverge between macOS and Linux and would be difficult to test as protocol +behavior. + +### 4.3 Container-first nodes — rejected + +Containers provide a consistent Linux process environment, but make macOS +local-resource access, local Agent adapters, and user-level persistence depend +on Docker Desktop. Containers may be added for isolated workload adapters, but +they are not the base node distribution. + +## 5. System Decomposition + +The milestone is delivered as three ordered subsystems: + +1. **Multi-host Node Bootstrap:** private-overlay transport, TLS, credential + chain, enrollment, user service, renewal, diagnostics, and revocation. +2. **Installation surfaces:** release artifacts, verified installer, versioned + Agent guide, Codex Skill, and direct CLI workflow. +3. **Public static site:** custom AgenNet landing page, bilingual documentation, + machine-readable bootstrap metadata, CI, and GitHub Pages deployment. + +The public site cannot declare node onboarding complete until subsystem 1 has +passed the physical two-device acceptance scenario. + +## 6. Trust and Network Architecture + +```text +Domain Root (administrative, not loaded by the daemon) + signs +Online Enrollment Authority Credential + binds Authority public key, TLS CA fingerprint, scope, and expiry + signs +Node Credential + node TLS certificate + authenticates +Signed AgenNet envelopes, manifests, contracts, events, and evidence +``` + +### 6.1 Private network boundary + +The first release supports an existing Tailscale or WireGuard overlay. AgenNet +does not install or configure the overlay. + +- `tailscale` mode accepts an explicit listener in the overlay address range. +- `wireguard` mode requires an explicit listener and allowed CIDR. +- The daemon binds the exact configured address, never `0.0.0.0` or `::`. +- Loopback remains available for tests and single-machine development. +- A non-loopback address outside the configured boundary returns + `UnsupportedNetworkBoundary`. + +Signed envelopes remain mandatory over TLS. TLS provides confidentiality and +transport integrity; the signed AgenNet objects preserve protocol-level +authorization and auditability after transport termination. + +### 6.2 Credential hierarchy + +The v0.1 `SignedNodeCredential` was signed directly by the ephemeral demo Root. +v0.2 introduces a versioned `SignedAuthorityCredential` and validates a chain: + +```text +trusted Domain Root → Authority Credential → Node Credential +``` + +The Authority Credential restricts: + +- issuer and Domain IDs; +- Authority signing public key; +- internal TLS CA fingerprint; +- allowed node profiles; +- maximum Node Credential lifetime; +- issuance and expiration time; +- revocation epoch. + +The Domain Root key is created by the interactive CLI in a separate, +passphrase-protected administrative keystore and is never placed in daemon +state or loaded by the long-running service. Root passphrases are read through +a hidden terminal and are not accepted as command-line arguments. The online +service holds only the Authority signing key and internal TLS CA key in +owner-only storage. This reduces, but does not eliminate, the impact of a +same-user host compromise. The CLI reports the administrative keystore path and +instructs the owner to move an offline recovery copy; v0.2 does not silently +move or delete Root material. + +### 6.3 TLS bootstrap + +The Authority exposes a narrowly scoped HTTPS enrollment endpoint. Its CA +fingerprint is included in the invitation and pinned before any secret is +transmitted. After enrollment, each node receives a TLS identity bound to its +AgenNet Node Credential. Peer requests require both an acceptable TLS chain and +the existing signed business envelope. + +## 7. Domain and Node Lifecycle + +### 7.1 CLI surface + +```text +agenet domain init +agenet invite create --profile --ttl +agenet node join +agenet node status [--json] +agenet node doctor [--json] +agenet node start|stop +agenet credential renew +agenet node revoke +agenet node leave +agenet uninstall [--purge] +agenet adapter list +agenet adapter enable|disable +agenet agent install-skill --target codex +``` + +`node join` reads invitation material without echo directly from the controlling +terminal. Invitation material is not accepted as a command-line value, +environment variable, or ordinary piped standard input because process state, +shell history, and Agent-managed streams are observable. + +### 7.2 Founding Domain + +`agenet domain init`: + +1. validates the selected private-overlay listener; +2. generates a Domain Root, online Enrollment Authority, and internal TLS CA; +3. signs the restricted Authority Credential; +4. creates the founding Directory/Authority node; +5. installs and starts its user service only after explicit confirmation; +6. runs `doctor` and reports the Root fingerprint and founding Node ID. + +No model API key is required or requested. + +### 7.3 Invitation + +An invitation has a public payload and a secret component. The public payload +contains: + +- enrollment protocol version; +- Domain ID; +- Authority and Directory private-overlay addresses; +- Domain Root and Authority TLS CA fingerprints; +- allowed profile and capability ceiling; +- invite ID, expiry, and maximum attempts. + +The secret contains at least 256 bits of randomness. The Authority stores only +an HMAC-SHA-256 digest made with a separate owner-only server pepper. Defaults +are a ten-minute lifetime, one successful redemption, and five failed attempts. +Logs may contain the invite ID but never the secret or complete serialized +invitation. + +### 7.4 Enrollment flow + +```text +Joining node Enrollment Authority +------------ -------------------- +generate signing key + TLS CSR +pin TLS CA from invitation ──────▶ validate TLS bootstrap +sign exact enrollment request ─────▶ check secret hash, expiry, profile, + attempts, operation ID, and key proof + ◀────── Node Credential, TLS certificate chain, + Directory seed, credential expiry +persist owner-only state +start user service ─────▶ signed health and Manifest registration +run doctor ◀───── signed Directory acknowledgement +``` + +The node private key never leaves the joining machine. Enrollment uses an +operation ID and durable result record. If the response is lost after issuance, +the same operation recovers the existing credential instead of issuing a second +identity or consuming a second invitation. + +### 7.5 Local state machine + +```text +Absent +→ BinaryInstalled +→ ServicePrepared +→ ReadyForEnrollment +→ CredentialIssued +→ Registered +→ Healthy +``` + +Each transition has a durable local marker and a compensating action. A failed +service registration removes the incomplete service definition but retains the +verified binary and diagnostic state. Enrollment failure never triggers an +automatic credential purge. + +### 7.6 Service defaults + +The binary is installed into `~/.local/bin/agenet` unless the user selects an +existing writable user binary directory. + +macOS defaults: + +- config and state under `~/Library/Application Support/AgenNet/`; +- user service at `~/Library/LaunchAgents/org.nexa-language.agenet.plist`. + +Linux defaults: + +- config under `${XDG_CONFIG_HOME:-~/.config}/agenet/`; +- state under `${XDG_STATE_HOME:-~/.local/state}/agenet/`; +- user unit at `~/.config/systemd/user/agenet.service`. + +The v0.2 user-service guarantee is "starts after the user logs in," not "starts +at machine boot without a login session." Enabling Linux linger or installing a +system service is outside the no-root default and requires a later, explicit +deployment profile. + +Private keys and local control tokens remain owner-only. The installer never +uses `chmod 777`, disables host security controls, or installs a root service. + +### 7.7 Renewal, revocation, leave, and uninstall + +- Renewal proves possession of the existing node key and requires a valid, + unrevoked credential. +- Revocation creates a signed revocation event. Directory routing excludes the + node. The Authority publishes a signed revocation snapshot and monotonically + increasing revocation epoch. Peers refresh it at least every five minutes and + fail closed for effectful requests when their snapshot is expired; read-only + health diagnostics return a typed stale-revocation error instead. +- `node leave` unregisters and stops the service but retains identity and + journals for audit. +- `uninstall` removes the service and binary while retaining state by default. +- `uninstall --purge` requires an interactive confirmation and reports exactly + which identity and journal files will be removed. +- v0.2 never enables automatic updates. + +## 8. Node Profiles and Agent Adapters + +Every enrolled machine starts as a base Runtime with health, identity, journal, +and explicitly enabled deterministic capabilities. A profile is an enrollment +ceiling, not automatic permission to expose everything available on the host. + +Detecting Codex or another Agent produces an `agent-candidate` report only. An +Adapter is enabled in a second, explicit authorization step that presents: + +- capability IDs and versions to be advertised; +- model or Agent process it invokes; +- allowed workspaces and tools; +- input and output types; +- network, filesystem, and model-data exposure; +- concurrency, time, and resource ceilings; +- sandbox boundary and evidence type. + +No Adapter inherits the user's full shell or all installed Agent permissions. +Arbitrary code execution remains deferred until a Docker, VM, or platform +sandbox Adapter exists and is separately reviewed. + +## 9. Human, Agent, and Non-Agent Onboarding + +### 9.1 One source of truth + +The CLI owns all state-changing logic. The website, `install.sh`, Agent guide, +and Codex Skill only inspect, explain, invoke the CLI, and verify its output. + +### 9.2 Versioned generic Agent prompt + +The public site provides this versioned prompt in Chinese and English. The +Chinese canonical form is: + +> 请严格按照 AgenNet Node Bootstrap v0.2 指南,把这台机器配置为 +> AgenNet 节点。先完成环境检查并停在 `ReadyForEnrollment`;不要让我把 +> invitation secret 发到聊天中,也不要把它放进日志或命令参数。安装常驻 +> 服务和启用任何 Agent Adapter 前,分别向我确认。指南: +> `https://nexa-language.github.io/AgenNet/bootstrap/v0.2/agent-bootstrap.md` + +The Agent proceeds through: + +```text +Inspect → ReadyForInstall → ReadyForEnrollment → Joined → Healthy +``` + +It reports OS and architecture, selected version, source, hash or attestation, +files, service type, and expected network bind. It must stop on unsupported +conditions and must not bypass verification. The user enters invitation +material through the CLI's hidden prompt, outside model context. + +### 9.3 Codex Skill + +The release includes an `agenet-node-bootstrap` Skill. After the CLI exists, +the user can install it with: + +```text +agenet agent install-skill --target codex +``` + +The Skill does not contain a separate installer or enrollment implementation. +It runs inspection, requests confirmation at the installation and Adapter +boundaries, invokes the CLI, interprets stable error codes, and ends by running +`doctor`. + +### 9.4 Direct non-Agent path + +The landing page provides: + +```text +curl -fsSL https://nexa-language.github.io/AgenNet/install.sh | sh +agenet node join +``` + +The invitation is never an installer argument. The page also provides a manual +high-assurance path with a fixed release version, hashes, provenance +attestation, and explicit commands. + +## 10. Release and Supply-chain Design + +Each release publishes fixed-version artifacts for: + +- `aarch64-apple-darwin`; +- `x86_64-apple-darwin`; +- `aarch64-unknown-linux-gnu`; +- `x86_64-unknown-linux-gnu`. + +The workflow produces an asset manifest, SHA-256 checksums, and GitHub artifact +attestations tied to `Nexa-Language/AgenNet`. The convenience installer relies +on GitHub Pages/Release HTTPS and validates the selected artifact checksum. The +high-assurance path additionally verifies the GitHub artifact attestation. + +When `gh` attestation verification is available, Agent-assisted installation +must use it. If it is unavailable, the Agent must state that provenance +verification is degraded and must not claim that the artifact was signed or +attested. The `latest` label is for human discovery only; Agent guides and +bootstrap manifests always identify a concrete release. + +## 11. Stable Errors and Diagnostics + +The bootstrap layer adds stable error codes including: + +- `InviteExpired`; +- `InviteAlreadyUsed`; +- `InviteAttemptLimitExceeded`; +- `RootFingerprintMismatch`; +- `UnsupportedNetworkBoundary`; +- `ClockSkewTooLarge`; +- `AuthorityUnavailable`; +- `CredentialRevoked`; +- `EnrollmentOperationConflict`; +- `ServiceInstallFailed`; +- `ReleaseVerificationFailed`. + +Errors contain a sanitized message, retryability, operation ID where relevant, +and a documentation URL containing the error code but no secret. `node doctor` +checks credential chain and expiry, TLS pinning, Authority/Directory reachability, +Manifest registration, journal writability, service restart recovery, and clock +skew. Human and JSON output are both redacted. + +## 12. Public Website + +### 12.1 Technology + +The static site lives in `site/` and uses Astro, Starlight, TypeScript, and +pnpm. Astro owns a fully custom landing page; Starlight owns the documentation +layout, navigation, search, and locale routing. React, Next.js, and Tailwind are +not required for v0.2. + +Astro is configured with: + +```text +site = https://nexa-language.github.io +base = /AgenNet +``` + +Chinese is the unprefixed default locale and English uses `/en/`. A language +switch preserves the semantic page. Pagefind provides static local search; +there is no hosted search or analytics service. + +### 12.2 Landing-page narrative + +```text +Hero + What AgenNet is + [Let an Agent configure it] [Install directly] +Coordination model + Capability → Intent → Contract → Evidence → Accepted +Node types + Base Runtime / deterministic provider / optional Agent Adapter +Join in minutes + Install → enroll → doctor → enable capabilities +Validation boundary + What is verified / what is explicitly not implemented +Security boundary + Private overlay + TLS + one-time invitation +GitHub / Docs / Roadmap +``` + +GitHub Pages is not a control plane. It does not receive invitation material, +credentials, node status, user input, or telemetry. + +### 12.3 Visual direction: A3 Field Study + +The approved visual direction takes DSH's level of finish as a quality bar but +does not copy DeepSeek branding, text, code, logos, images, or proprietary +assets. AgenNet uses its own identity and coordination content. + +The hero rendering has three independent depths: + +1. slow, large-scale fluid light ribbons with strong blur and low-frequency + motion; +2. an ordered point field that bends continuously under a cursor force field; +3. restrained grid, grain, light veil, and black depth fade. + +It must not use random star particles, nearest-neighbor connection lines, +literal topology diagrams, or dashboard statistics in the hero. Motion is +slow, locally revealed, and subordinate to content. The right-side panel shows +the real Agent prompt/direct installation surfaces instead of fake network +metrics. + +The production effect is implemented as an isolated rendering component, not +by copying the brainstorm prototype. Static semantic HTML renders before the +visual module. The module supports: + +- desktop adaptive Canvas/WebGL rendering; +- reduced particle/detail density on constrained devices; +- a simplified mobile composition; +- a static frame for `prefers-reduced-motion` or renderer failure; +- no loss of navigation, content, or install actions when disabled. + +### 12.4 Documentation structure + +```text +/guide/quickstart +/guide/create-a-domain +/guide/join-a-node +/guide/agent-bootstrap/v0.2 +/guide/non-agent-node +/guide/enable-an-adapter +/concepts/identity-and-trust +/concepts/capabilities +/concepts/contracts-and-evidence +/reference/cli +/reference/configuration +/reference/bootstrap-manifest +/security/threat-model +/security/revocation-and-recovery +/status/validation-matrix +/roadmap +/changelog +``` + +The site additionally publishes: + +- `/bootstrap/v0.2/agent-bootstrap.md`, a decoration-free Agent-readable guide; +- `/bootstrap/v0.2/manifest.json`, containing the CLI version, platform assets, + release URLs, hashes, attestations, minimum protocol version, and guide hash. + +Both derive from the same checked source as their rendered documentation pages. + +### 12.5 Deployment + +GitHub Pages deploys only a successful build of `master`. Feature branches and +pull requests publish CI artifacts but cannot replace the public site. The +workflow uses minimum permissions: `contents: read`, `pages: write`, and +`id-token: write`. Direct dependencies and Actions are pinned during +implementation after checking their current official releases. + +## 13. Testing and Acceptance + +### 13.1 Protocol and Runtime tests + +- Authority Credential chain validation, expiry, scope, wrong Root, and + revocation epoch. +- TLS CA pinning and rejection of the wrong Authority. +- Invitation expiry, wrong secret, attempt exhaustion, replay, and concurrent + redemption. +- Enrollment key-possession proof and exact operation recovery after a lost + response. +- Credential renewal and revoked-node rejection. +- Overlay boundary validation and rejection of wildcard/public binds. +- Journal replay, service restart, manifest re-registration, and redaction. +- Tampering with credential, certificate, manifest, Contract, Artifact, or + Evidence cannot produce acceptance. + +### 13.2 Installation matrix + +Clean user environments cover the four released OS/architecture targets where +hardware is available. Tests verify no-root installation, atomic replacement, +LaunchAgent/systemd-user lifecycle, failed-install recovery, explicit upgrade, +state-preserving uninstall, and confirmed purge. + +### 13.3 Physical multi-machine acceptance + +Two user devices on a private overlay must demonstrate: + +1. Domain creation on the founding node; +2. a ten-minute invitation and hidden-input redemption on the second device; +3. restart-persistent health and signed Manifest registration; +4. dynamic discovery without hardcoding the provider endpoint; +5. one real `source.metrics.v1` Contract, independent verification, and + Evidence-gated `Accepted`; +6. loss and restoration of Authority connectivity without duplicate identity; +7. revocation of the second node and subsequent route/request rejection. + +Development machines add clock-skew, concurrent enrollment, Authority restart, +and multi-node churn scenarios. A local or synthetic test cannot satisfy the +physical multi-machine gate. + +### 13.4 Website gates + +- Astro type and build checks, lint, and committed pnpm lockfile. +- Bilingual route, sidebar, heading, code-example, and semantic-link parity. +- Broken-link and bootstrap-manifest consistency checks. +- Playwright desktop and mobile coverage for navigation, language switching, + tabs, copy controls, and fallback rendering. +- Keyboard navigation, focus visibility, contrast, semantic headings, and + reduced-motion checks. +- The page remains readable and installable with scripts or Canvas disabled. +- Visual regression captures for the approved A3 direction. + +## 14. Completion Criteria + +The v0.2 Node Bootstrap and Pages milestone is complete only when: + +- a new supported machine can follow the site or versioned Agent prompt to + install AgenNet without root; +- the user supplies invitation material outside model context and the machine + joins through the TLS-pinned, single-use enrollment flow; +- the node survives service restart, is dynamically discoverable, and can be + revoked; +- two physical devices complete the existing verified source-metrics Contract; +- all secrets remain absent from repository, logs, process arguments, Agent + transcripts, public pages, and CI artifacts; +- automated protocol, installation, website, formatting, lint, and test gates + pass; +- the public bilingual site accurately distinguishes verified behavior from + deferred capabilities. + +Publishing a polished landing page or starting two unsigned processes is not +sufficient evidence of completion. From 5a15749be86b13eb70122ded193d7ce3dd61eaa5 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 13:26:06 +0800 Subject: [PATCH 07/67] [doc] Plan AgenNet v0.2 implementation Root cause: NA Solution: Split the approved design into three dependency-ordered, test-first implementation plans and clarify private transport rules. Risks: The plans remain unimplemented and need physical-device validation. Dependency: Design commit b0eb409. Links: plan/01-v1-multi-host-node-bootstrap.md --- ROADMAP.md | 9 + ...6-08-14-node-bootstrap-and-pages-design.md | 6 + plan/01-v1-multi-host-node-bootstrap.md | 944 ++++++++++++++++++ plan/02-v1-installation-surfaces.md | 437 ++++++++ plan/03-v1-public-site.md | 591 +++++++++++ 5 files changed, 1987 insertions(+) create mode 100644 plan/01-v1-multi-host-node-bootstrap.md create mode 100644 plan/02-v1-installation-surfaces.md create mode 100644 plan/03-v1-public-site.md diff --git a/ROADMAP.md b/ROADMAP.md index 58db49e..4742a34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-14 13:16 CST + +- **Change**: Converted the approved v0.2 design into three dependency-ordered, test-first implementation plans for multi-host bootstrap, installation surfaces, and the bilingual public site. +- **Files**: `plan/01-v1-multi-host-node-bootstrap.md`, `plan/02-v1-installation-surfaces.md`, `plan/03-v1-public-site.md`, `docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md`, `ROADMAP.md`. +- **Decision**: Implement secure node semantics before distribution, then publish one canonical install/Agent workflow, and only then deploy the A3 Field Study site. Every task has a failing-test boundary, stable interfaces, verification commands, documentation updates, and an independent five-section commit. +- **Reason**: This order prevents a polished installer or website from getting ahead of a real credential, TLS, revocation, recovery, and two-device acceptance path. +- **Security clarification**: Tailscale listeners must be assigned to the local interface, not merely fall inside CGNAT space; enrollment and peer clients bypass ambient system proxies, while the external LLM client retains proxy support. +- **Boundary**: These files are executable plans, not evidence that v0.2 is implemented. Public multi-host claims remain blocked on the physical two-device gate. + ## 2026-08-14 12:54 CST - **Change**: Approved the v0.2 design for secure private-overlay Node Bootstrap, layered human/Agent installation surfaces, and a bilingual AgenNet GitHub Pages site. diff --git a/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md index 69a59d3..9f86011 100644 --- a/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md +++ b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md @@ -116,11 +116,17 @@ The first release supports an existing Tailscale or WireGuard overlay. AgenNet does not install or configure the overlay. - `tailscale` mode accepts an explicit listener in the overlay address range. +- `tailscale` mode verifies that the exact listener is currently assigned to the + local Tailscale interface; membership in `100.64.0.0/10` alone is not treated + as address ownership. - `wireguard` mode requires an explicit listener and allowed CIDR. - The daemon binds the exact configured address, never `0.0.0.0` or `::`. - Loopback remains available for tests and single-machine development. - A non-loopback address outside the configured boundary returns `UnsupportedNetworkBoundary`. +- Enrollment and peer clients bypass system proxy configuration so private- + overlay traffic cannot be redirected through an ambient proxy. The external + LLM Decision Adapter retains its current proxy support as a separate client. Signed envelopes remain mandatory over TLS. TLS provides confidentiality and transport integrity; the signed AgenNet objects preserve protocol-level diff --git a/plan/01-v1-multi-host-node-bootstrap.md b/plan/01-v1-multi-host-node-bootstrap.md new file mode 100644 index 0000000..5a3188a --- /dev/null +++ b/plan/01-v1-multi-host-node-bootstrap.md @@ -0,0 +1,944 @@ +# AgenNet Multi-host Node Bootstrap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the verified loopback runtime into a secure, revocable +Developer Preview that can enroll and run AgenNet nodes on two physical macOS +or Linux devices connected by an existing private overlay. + +**Architecture:** Keep the signed protocol kernel independent from transport. +Introduce a Domain Root → online Enrollment Authority → Node Credential chain, +server-authenticated one-time enrollment, mutually authenticated peer TLS, +explicit private-network boundaries, signed revocation snapshots, durable local +state, and user-level service managers. Preserve loopback as a development +adapter, but move all v0.2 peers to the same authority-chain envelope model. + +**Tech Stack:** Rust 1.97.1, Axum 0.8.9, Tokio 1.53.1, Reqwest 0.13.4, +rustls 0.23.43, axum-server 0.8.0, rcgen 0.14.9, Ed25519-dalek 3.0.0, +age 0.12.1, HMAC-SHA-256, Serde, Clap, macOS LaunchAgent, Linux systemd user +service. + +## Global Constraints + +- Treat every protocol, persistence, and command default as a v0.2 Developer + Preview decision that may be revised through a versioned migration. +- Never accept `0.0.0.0`, `::`, public IP addresses, or an implicit network + interface. Bind only the exact configured loopback or private-overlay IP. +- Never transmit or persist the Domain Root passphrase, invitation secret, + invitation pepper, node signing key, or TLS private key in logs, command-line + arguments, environment variables, journal payloads, or ordinary stdin. +- Peer and enrollment HTTP clients must disable system proxies. The LLM Decision + Adapter retains its existing proxy behavior because it is not private-overlay + traffic. +- Keep all protocol effect endpoints explicitly idempotent through + `operation_id`; do not add implicit HTTP retries to invitation consumption, + enrollment, Contract creation, Event append, renewal, or revocation. +- Preserve current v0.1 tests while migrating them to the v0.2 credential chain. + No unversioned on-disk or wire-format rewrite is allowed. +- Use test-first development. A task is not complete until its focused tests, + `cargo fmt --check`, and relevant Clippy targets pass. +- Use the repository's five-section Commit Message format. Do not merge. +- Do not mark this plan complete until the two-physical-device acceptance run + passes. Local and CI simulations are necessary but not sufficient. + +--- + +## Task 1: Pin security dependencies and establish the v0.2 boundary + +**Files:** + +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Modify: `src/lib.rs` +- Create: `src/bootstrap/mod.rs` +- Create: `src/service/mod.rs` +- Create: `tests/version_boundary.rs` +- Modify: `README.md` +- Create: `docs/security/dependency-review-v0.2.md` + +**Interfaces:** + +```rust +pub const KERNEL_VERSION_V1: &str = "agenet.kernel.v0.1"; +pub const KERNEL_VERSION_V2: &str = "agenet.kernel.v0.2"; + +pub enum SupportedKernelVersion { + V1, + V2, +} +``` + +- [ ] Add a failing `tests/version_boundary.rs` test proving that v0.2 is the + emitted version, v0.1 can be identified for migration diagnostics, and an + unknown version returns `UnsupportedKernelVersion` rather than falling + through to Serde errors. +- [ ] Run `cargo test --test version_boundary`; confirm the missing constants + or error variant causes the expected failure. +- [ ] Pin these direct dependencies exactly: `axum-server = 0.8.0` with + `tls-rustls`, `rustls = 0.23.43`, `rcgen = 0.14.9` with `aws_lc_rs`, `pem`, + and `zeroize`, `rustls-pemfile = 2.2.0`, `age = 0.12.1`, + `rpassword = 7.5.4`, `hmac = 0.13.0` with `zeroize`, `ipnet = 2.12.1` + with `serde`, `directories = 6.0.0`, `time = 0.3.55`, and `plist = 1.10.0`. +- [ ] Bump the package to `0.2.0` without changing the Rust 1.97.1 floor. Put + `plist` behind a macOS target dependency and record each new crate's license, + maintenance status, purpose, transitive footprint, and removal boundary in + `docs/security/dependency-review-v0.2.md`. +- [ ] Add the version constants and stable typed error, export empty + `bootstrap` and `service` module boundaries, then regenerate `Cargo.lock`. +- [ ] Document the dependency purpose, v0.2 wire boundary, and the absence of + external v0.1 consumers in `README.md`. +- [ ] Run `cargo test --test version_boundary`, `cargo check --all-targets`, + and `cargo tree -d`; investigate unexpected duplicate TLS/crypto major + versions before continuing. +- [ ] Commit: + +```text +[feat][Bootstrap][1/14] Establish v0.2 boundary + +Root cause: NA +Solution: Pin the security stack and introduce an explicit v0.2 +kernel boundary with typed migration diagnostics. +Risks: The age crate remains pinned below 1.0. +Dependency: AgenNet v0.1 at fda7a6b. +Links: plan/01-v1-multi-host-node-bootstrap.md +``` + +## Task 2: Implement the Authority credential chain + +**Files:** + +- Create: `src/protocol/authority.rs` +- Modify: `src/protocol/identity.rs` +- Modify: `src/protocol/envelope.rs` +- Modify: `src/protocol/error.rs` +- Modify: `src/protocol/mod.rs` +- Modify: `src/protocol/types.rs` +- Create: `tests/authority_protocol.rs` +- Modify: `tests/protocol_kernel.rs` + +**Interfaces:** + +```rust +pub struct AuthorityClaims { + pub domain_id: DomainId, + pub authority_id: NodeId, + pub signing_public_key_base64: String, + pub tls_ca_sha256: String, + pub scopes: BTreeSet, + pub allowed_profiles: BTreeSet, + pub maximum_node_lifetime_ms: u64, + pub issued_at_ms: i64, + pub expires_at_ms: i64, +} + +pub struct SignedAuthorityCredential { + pub claims: AuthorityClaims, + pub root_signature_base64: String, +} + +pub struct CredentialChain { + pub authority: SignedAuthorityCredential, + pub node: SignedNodeCredential, +} + +pub enum BootstrapProfile { + Base, + Provider, + AgentCandidate, +} + +pub fn verify_credential_chain( + root_public_key: &VerifyingKey, + chain: &CredentialChain, + expected_domain: &DomainId, + expected_role: NodeRole, + now_ms: i64, +) -> Result; +``` + +- [ ] Write failing tests for valid chain verification, wrong root, Domain + mismatch, expired Authority, expired Node, missing issuance scope, Node issuer + mismatch, role mismatch, TLS CA fingerprint tampering, and every signature bit + mutation. +- [ ] Add a property test that mutates one serialized Authority-claim byte and + proves `verify_strict` rejects it. +- [ ] Change `SignedNodeCredential` claims to include `domain_id`, + `authority_id`, `bootstrap_profile`, and `allowed_roles`. `Base` permits the + Requester role, `Provider` adds Executor and Verifier, and `AgentCandidate` + remains Base-equivalent until a separately authorized Adapter is enabled. + Directory issuance is reserved for `domain init`, not an invitation. +- [ ] Make issuance require an Authority signing key, `IssueNodeCredential` + scope, an allowed Bootstrap Profile, and a lifetime within the Authority + ceiling. +- [ ] Replace `WireEnvelope.credential` with + `WireEnvelope.credential_chain`; make v0.2 verification validate the chain + before deserializing the exact signed payload bytes. +- [ ] Keep a read-only v0.1 parser solely to return a stable + `MigrationRequiredV1Credential` error. Do not accept v0.1 objects on v0.2 + effect endpoints. +- [ ] Run `cargo test --test authority_protocol --test protocol_kernel` and + `cargo clippy --test authority_protocol -- -D warnings`. +- [ ] Commit: + +```text +[feat][Bootstrap][2/14] Add authority credentials + +Root cause: NA +Solution: Replace direct Root-to-node trust with a versioned +Root-to-Authority-to-node chain and strict validation. +Risks: Retained v0.1 demo state requires a new run rather than +in-place credential reuse. +Dependency: Bootstrap step 1. +Links: docs/superpowers/specs/ +2026-08-14-node-bootstrap-and-pages-design.md +``` + +## Task 3: Enforce explicit private-network boundaries + +**Files:** + +- Create: `src/bootstrap/network.rs` +- Modify: `src/protocol/error.rs` +- Modify: `src/transport/client.rs` +- Modify: `src/node.rs` +- Create: `tests/network_boundary.rs` + +**Interfaces:** + +```rust +pub enum OverlayKind { + Loopback, + Tailscale, + WireGuard, +} + +pub struct NetworkBoundary { + pub kind: OverlayKind, + pub bind_ip: IpAddr, + pub allowed_cidrs: Vec, +} + +impl NetworkBoundary { + pub fn validate_bind(&self) -> Result<(), RuntimeError>; + pub fn allows_peer(&self, peer: IpAddr) -> bool; +} +``` + +- [ ] Write failing table tests for IPv4/IPv6 loopback, the Tailscale CGNAT + range `100.64.0.0/10`, explicit Tailscale IPv6 addresses, an explicit + WireGuard CIDR, public addresses, wildcard addresses, multicast, unspecified, + link-local, and a bind address outside the declared CIDR. +- [ ] Require an explicit `bind_ip`. For Tailscale, accept only an address + reported by the local Tailscale interface and contained in its assigned + address set; do not treat the entire CGNAT range as proof that the local + interface owns the address. For WireGuard, require at least one operator- + supplied CIDR. +- [ ] Add peer URL validation that rejects scheme downgrade, DNS hostnames, + userinfo, fragments, and IPs outside `allowed_cidrs` before opening a socket. +- [ ] Retain `127.0.0.1`/`::1` for tests; require `https` for all non-loopback + peer and enrollment endpoints. +- [ ] Run `cargo test --test network_boundary --test http_client` and verify + no existing loopback regression. +- [ ] Commit: + +```text +[feat][Bootstrap][3/14] Enforce network boundary + +Root cause: NA +Solution: Validate exact private-overlay listeners and peers before +creating sockets or HTTP requests. +Risks: Overlay interface discovery can differ across OS releases. +Dependency: Bootstrap step 2. +Links: plan/01-v1-multi-host-node-bootstrap.md +``` + +## Task 4: Add the encrypted administrative keystore and internal TLS PKI + +**Files:** + +- Create: `src/bootstrap/keystore.rs` +- Create: `src/bootstrap/pki.rs` +- Modify: `src/runtime/key_store.rs` +- Create: `tests/admin_keystore.rs` +- Create: `tests/pki.rs` + +**Interfaces:** + +```rust +pub struct DomainRootMaterial { + pub domain_id: DomainId, + pub signing_key: SigningKey, + pub created_at_ms: i64, +} + +pub trait RootKeystore { + fn create( + path: &Path, + material: &DomainRootMaterial, + passphrase: SecretString, + ) -> Result<(), BootstrapError>; + fn unlock( + path: &Path, + passphrase: SecretString, + ) -> Result, BootstrapError>; +} + +pub struct AuthorityPki { + pub ca_cert_pem: Zeroizing, + pub ca_key_pem: Zeroizing, + pub fingerprint_sha256: String, +} +``` + +- [ ] Write failing keystore tests for a correct passphrase, wrong passphrase, + truncated ciphertext, non-regular files, symlinks, `0600` permissions, atomic + creation, and zeroization at the API boundary. +- [ ] Implement a versioned keystore header and age scrypt passphrase + encryption. Obtain passphrases with `rpassword` from a controlling TTY; reject + args, env vars, pipes, and ordinary stdin. +- [ ] Write failing PKI tests for CA constraints, server SAN exact-IP matching, + client certificate identity binding, validity not exceeding Authority expiry, + CSR key ownership, unknown critical extensions, and fingerprint stability. +- [ ] Use `rcgen` to create one internal Authority CA, a server certificate for + the exact overlay IP, and CSR-signed node client certificates. Persist all + private material with owner-only permissions and atomic rename. +- [ ] Keep the Domain Root outside daemon state. The daemon receives only the + Root public key, signed Authority Credential, Authority signing key, TLS CA, + and Authority server key. +- [ ] Run `cargo test --test admin_keystore --test pki` and use a sentinel + passphrase to assert logs and `Debug` errors never contain it. +- [ ] Commit: + +```text +[feat][Bootstrap][4/14] Add encrypted PKI + +Root cause: NA +Solution: Store the Domain Root in a passphrase-encrypted administrative +keystore and generate a scoped internal TLS Authority. +Risks: Losing the Root passphrase prevents administrative recovery. +Dependency: Bootstrap step 3. +Links: docs/superpowers/specs/ +2026-08-14-node-bootstrap-and-pages-design.md +``` + +## Task 5: Implement durable one-time invitations + +**Files:** + +- Create: `src/bootstrap/invitation.rs` +- Create: `src/bootstrap/journal.rs` +- Modify: `src/protocol/error.rs` +- Create: `tests/invitation_store.rs` + +**Interfaces:** + +```rust +pub struct InvitationRecord { + pub invitation_id: Uuid, + pub secret_hmac_sha256: [u8; 32], + pub allowed_profile: BootstrapProfile, + pub capability_ceiling: BTreeSet, + pub expires_at_ms: i64, + pub failed_attempts: u8, + pub state: InvitationState, +} + +pub struct InvitationPublicClaims { + pub protocol_version: String, + pub domain_id: DomainId, + pub authority_endpoint: Url, + pub directory_seeds: Vec, + pub root_sha256: String, + pub tls_ca_sha256: String, + pub allowed_profile: BootstrapProfile, + pub capability_ceiling: BTreeSet, + pub invitation_id: Uuid, + pub expires_at_ms: i64, + pub maximum_attempts: u8, +} + +pub enum InvitationState { + Available, + Reserved { operation_id: Uuid, reserved_at_ms: i64 }, + Consumed { node_id: NodeId, consumed_at_ms: i64 }, + Locked, + Expired, +} +``` + +- [ ] Write failing tests proving invitation secrets contain 256 random bits, + only the HMAC is persisted, the pepper is stored separately with `0600`, the + default expiry is ten minutes, the fifth failure locks the invitation, + concurrent valid claims yield exactly one winner, and repeating the winning + `operation_id` returns the same result. +- [ ] Define an append-only invitation journal with serialized writes, + `flush`, `sync_data`, replay, a length-delimited record, and a checksum. +- [ ] Implement a two-phase `reserve` then `consume` transition. A failed CSR or + certificate issuance releases only the same operation's reservation; a + successful issuance consumes permanently. +- [ ] Encode the operator handoff as public claims plus a 256-bit + `SecretString`. Its `Debug` output is always redacted; only explicit hidden- + TTY display/input functions may materialize the complete value. +- [ ] Ensure lookup and HMAC comparison are constant-time and errors do not + distinguish unknown IDs from wrong secrets. +- [ ] Add property tests for crash/replay at every transition and invariants + `consumed <= 1`, `failed_attempts <= 5`, and `locked => !available`. +- [ ] Run `cargo test --test invitation_store` including a multi-threaded race + loop of at least 1,000 attempts under the test process. +- [ ] Commit: + +```text +[feat][Bootstrap][5/14] Add one-time invitations + +Root cause: NA +Solution: Add durable HMAC invitations with expiration, lockout, +reservation, consumption, replay, and idempotency. +Risks: Durability still inherits the host volume's guarantees. +Dependency: Bootstrap step 4. +Links: plan/01-v1-multi-host-node-bootstrap.md +``` + +## Task 6: Implement pinned enrollment and local-key CSR issuance + +**Files:** + +- Create: `src/protocol/enrollment.rs` +- Create: `src/bootstrap/enrollment.rs` +- Create: `src/transport/enrollment.rs` +- Modify: `src/transport/mod.rs` +- Modify: `src/protocol/mod.rs` +- Create: `tests/enrollment_protocol.rs` +- Create: `tests/http_enrollment.rs` + +**Interfaces:** + +```rust +pub struct EnrollmentRequest { + pub operation_id: Uuid, + pub invitation_id: Uuid, + pub invitation_secret: SecretString, + pub node_id: NodeId, + pub requested_profile: BootstrapProfile, + pub signing_public_key_base64: String, + pub tls_csr_pem: String, +} + +pub struct EnrollmentBundle { + pub domain_id: DomainId, + pub root_public_key_base64: String, + pub credential_chain: CredentialChain, + pub tls_client_certificate_pem: String, + pub tls_ca_certificate_pem: String, + pub authority_endpoint: Url, + pub revocation_endpoint: Url, +} +``` + +- [ ] Write pure protocol tests for payload size, role/profile scope, CSR + identity, operation ID, invitation expiration, and sanitized stable errors. +- [ ] Write HTTP tests with a generated Authority server certificate proving + success only when the caller pins the expected CA fingerprint and exact IP + SAN. Reject redirects, DNS fallback, system proxy interception, mismatched + fingerprint, mismatched CSR key, reused secret, and oversized bodies. +- [ ] Build a separate enrollment listener using server-auth TLS. It must not + share the mTLS peer port because an unenrolled node has no client certificate. +- [ ] Generate Ed25519 protocol and TLS keys locally on the joining node, + submit only public material and the CSR, then validate every returned + credential and certificate before committing local state. +- [ ] Configure the enrollment Reqwest client with `.no_proxy()`, redirect + policy `none`, fixed connect/request timeouts, the invitation-pinned TLS CA + certificate, and no ambient credential store. +- [ ] Zeroize the invitation secret immediately after the enrollment response + is validated or rejected. +- [ ] Run `cargo test --test enrollment_protocol --test http_enrollment` and + scan captured logs for invitation and private-key sentinels. +- [ ] Commit: + +```text +[feat][Bootstrap][6/14] Add pinned enrollment + +Root cause: NA +Solution: Add fingerprint-pinned enrollment that signs a local CSR and +consumes a one-time invitation. +Risks: Enrollment requires clock skew within the documented tolerance. +Dependency: Bootstrap step 5. +Links: docs/superpowers/specs/ +2026-08-14-node-bootstrap-and-pages-design.md +``` + +## Task 7: Add signed revocation snapshots and stale-policy behavior + +**Files:** + +- Create: `src/protocol/revocation.rs` +- Create: `src/runtime/revocation.rs` +- Modify: `src/protocol/authority.rs` +- Modify: `src/transport/directory.rs` +- Modify: `src/transport/node.rs` +- Create: `tests/revocation.rs` +- Create: `tests/http_revocation.rs` + +**Interfaces:** + +```rust +pub struct RevocationSnapshot { + pub domain_id: DomainId, + pub epoch: u64, + pub generated_at_ms: i64, + pub next_update_ms: i64, + pub revoked_authorities: BTreeSet, + pub revoked_nodes: BTreeSet, + pub signature_base64: String, +} + +pub enum RevocationDecision { + CurrentAndAllowed, + Revoked, + Stale, +} +``` + +- [ ] Write failing tests for signature tampering, Domain mismatch, epoch + rollback, stale snapshots, revoked Authority, revoked Node, replay of the same + epoch, atomic cache replacement, and restart recovery. +- [ ] Add an Authority-signed snapshot endpoint with a monotonic persisted + epoch. Default `next_update_ms` to no more than five minutes after generation. +- [ ] Add each node's atomic snapshot cache and refresh task. Read-only health + and snapshot refresh remain available while stale; Contract creation, Event + append, Artifact read, registration, and all other effectful protocol requests + fail closed with `RevocationStateStale`. +- [ ] Reject revoked peers both during TLS identity mapping and during envelope + credential validation; retain the stable peer/operation identifiers needed + for audit without logging full credentials. +- [ ] Run `cargo test --test revocation --test http_revocation` with a paused + Tokio clock for deterministic freshness tests. +- [ ] Commit: + +```text +[feat][Bootstrap][7/14] Enforce signed revocation + +Root cause: NA +Solution: Distribute monotonic signed revocation snapshots and fail +closed on stale policy for effectful peer operations. +Risks: Authority downtime pauses work after snapshot freshness ends. +Dependency: Bootstrap step 6. +Links: plan/01-v1-multi-host-node-bootstrap.md +``` + +## Task 8: Replace non-loopback HTTP with mutual TLS + +**Files:** + +- Create: `src/transport/tls.rs` +- Modify: `src/transport/client.rs` +- Modify: `src/transport/directory.rs` +- Modify: `src/transport/node.rs` +- Create: `src/runtime/node.rs` +- Create: `tests/http_mtls.rs` + +**Interfaces:** + +```rust +pub struct PeerTlsIdentity { + pub node_id: NodeId, + pub certificate_chain_pem: Zeroizing, + pub private_key_pem: Zeroizing, + pub authority_ca_pem: String, +} + +pub fn build_peer_server_config( + identity: &PeerTlsIdentity, + revocations: Arc, +) -> Result; + +pub fn build_peer_client( + identity: &PeerTlsIdentity, + boundary: &NetworkBoundary, +) -> Result; +``` + +- [ ] Write tests proving mTLS success, missing client certificate rejection, + wrong CA rejection, exact-IP SAN enforcement, expired certificate rejection, + revoked certificate rejection, envelope/certificate Node-ID mismatch + rejection, redirect rejection, system proxy bypass, and private-boundary + validation before connect. +- [ ] Build the rustls server verifier with the Authority CA and require client + authentication. Map the verified certificate identity into request + extensions; every signed-envelope handler must compare it with `issuer_id`. +- [ ] Serve with `axum_server::bind_rustls` using a `RustlsConfig` created from + the explicit `rustls::ServerConfig`; retain a handle for graceful shutdown. +- [ ] Build the Reqwest peer client with explicit CA, PKCS#8 identity, + `.no_proxy()`, redirect policy `none`, connect timeout two seconds, request + timeout five seconds, and current body limits. +- [ ] Keep plain HTTP only for loopback tests. Return + `UnsupportedInsecureTransport` for any non-loopback `http` endpoint. +- [ ] Run `cargo test --test http_mtls --test http_directory --test http_client` + and `cargo clippy --all-targets --all-features -- -D warnings`. +- [ ] Commit: + +```text +[feat][Bootstrap][8/14] Add mutual TLS transport + +Root cause: NA +Solution: Require Authority-issued mTLS and bind the TLS identity to +every signed envelope issuer. +Risks: TLS rotation must complete before certificate expiration. +Dependency: Bootstrap step 7. +Links: docs/superpowers/specs/ +2026-08-14-node-bootstrap-and-pages-design.md +``` + +## Task 9: Define versioned local configuration and durable node state + +**Files:** + +- Create: `src/bootstrap/config.rs` +- Create: `src/bootstrap/paths.rs` +- Create: `src/bootstrap/state.rs` +- Modify: `src/runtime/key_store.rs` +- Create: `tests/bootstrap_config.rs` +- Create: `tests/bootstrap_recovery.rs` + +**Interfaces:** + +```rust +pub struct NodeConfigV1 { + pub schema_version: u32, + pub domain_id: DomainId, + pub profile: BootstrapProfile, + pub network: NetworkBoundary, + pub directory_seeds: Vec, + pub authority_endpoint: Url, + pub revocation_endpoint: Url, +} + +pub enum BootstrapPhase { + Absent, + BinaryInstalled, + ServicePrepared, + ReadyForEnrollment, + CredentialIssued, + Registered, + Healthy, + Left, +} +``` + +- [ ] Write tests for macOS and Linux user paths, schema rejection, unknown + fields, symlinks, owner mismatch, wrong permissions, partial writes, interrupted + enrollment, interrupted service installation, and replay to the last committed + phase. +- [ ] Use `directories` only to resolve user-scoped roots. Store configuration, + credentials, service metadata, revocation cache, and journals in separate + named files with explicit version fields. +- [ ] Implement an atomic bootstrap state journal so `join`, service + preparation, `leave`, and `uninstall` can resume or roll back without guessing + from partial filesystem state. +- [ ] Make config parsing deny unknown fields and validate all paths, URLs, + network boundaries, credential chains, and permissions before runtime startup. +- [ ] Run `cargo test --test bootstrap_config --test bootstrap_recovery`. +- [ ] Commit: + +```text +[feat][Bootstrap][9/14] Persist bootstrap state + +Root cause: NA +Solution: Add versioned user configuration and a crash-recoverable +bootstrap phase journal. +Risks: Cross-device atomic rename is rejected. +Dependency: Bootstrap step 8. +Links: plan/01-v1-multi-host-node-bootstrap.md +``` + +## Task 10: Expose Domain, invitation, and join CLI commands + +**Files:** + +- Modify: `src/main.rs` +- Create: `src/cli/mod.rs` +- Create: `src/cli/domain.rs` +- Create: `src/cli/invite.rs` +- Create: `src/cli/join.rs` +- Create: `src/cli/output.rs` +- Create: `tests/cli_bootstrap.rs` + +**Command contract:** + +```text +agenet domain init --network --bind-ip + [--allowed-cidr ] +agenet invite create --profile [--ttl 10m] +agenet node join +``` + +- [ ] Write CLI tests for help, exact required arguments, invalid network + combinations, non-TTY rejection, pre-existing Domain state, JSON output, and + secret redaction. Capture process lists to prove passphrases and invitation + secrets never appear in argv. +- [ ] Make `domain init` request and confirm a passphrase on the controlling + TTY, create the Root and Authority, write the encrypted Root keystore, and + output only Domain ID, endpoints, Root fingerprint, and next commands. +- [ ] Make `invite create` unlock the Root keystore, authorize the online + Authority scope, and print the invitation secret exactly once to the + controlling TTY. Structured JSON output contains only invitation metadata and + must never contain the secret. +- [ ] Make `node join` read the complete invitation through hidden TTY input, + validate its public Domain, Authority, Directory, profile, capability ceiling, + expiry, attempt count, and fingerprint fields, generate keys locally, perform + pinned enrollment, persist validated state atomically, and return a stable + `JoinResult`. +- [ ] Add `--output human|json` for automation. Keep stable `code`, `message`, + `retryable`, and `operation_id` fields in JSON errors. +- [ ] Run `cargo test --test cli_bootstrap` and manually inspect `agenet + --help`, `agenet domain --help`, `agenet invite --help`, and `agenet node + join --help`. +- [ ] Commit: + +```text +[feat][Bootstrap][10/14] Add bootstrap commands + +Root cause: NA +Solution: Expose Domain, invitation, and pinned enrollment through one +typed CLI with TTY-only secret entry. +Risks: Headless enrollment requires an interactive operator. +Dependency: Bootstrap step 9. +Links: docs/superpowers/specs/ +2026-08-14-node-bootstrap-and-pages-design.md +``` + +## Task 11: Install and manage user-level services + +**Files:** + +- Modify: `src/service/mod.rs` +- Create: `src/service/macos.rs` +- Create: `src/service/linux.rs` +- Create: `src/cli/node.rs` +- Modify: `src/cli/mod.rs` +- Create: `tests/service_macos.rs` +- Create: `tests/service_linux.rs` + +**Interfaces:** + +```rust +pub trait UserServiceManager { + fn render(&self, spec: &ServiceSpec) -> Result, ServiceError>; + fn install(&self, spec: &ServiceSpec) -> Result; + fn start(&self) -> Result; + fn stop(&self) -> Result; + fn uninstall(&self) -> Result<(), ServiceError>; + fn status(&self) -> Result; +} +``` + +- [ ] Write pure rendering tests for a macOS LaunchAgent plist and Linux + `systemd --user` unit. Assert absolute executable/config paths, argument + escaping, restart limits, no secrets, no shell, no root paths, and no + environment-file injection. +- [ ] Write command-runner contract tests for idempotent install/start/stop, + rollback after activation failure, status mapping, unavailable systemd user + session, and a binary path containing spaces. +- [ ] Implement + `~/Library/LaunchAgents/org.nexa-language.agenet.plist` with + `RunAtLoad`, bounded `KeepAlive`, stdout/stderr files under AgenNet's state + root, and `launchctl bootstrap/bootout` in the user's GUI domain. +- [ ] Implement `~/.config/systemd/user/agenet.service` with + `Restart=on-failure`, bounded restart delay, hardening options valid for a + user unit, `daemon-reload`, and `enable --now`. +- [ ] Explicitly report that persistence begins after user login. Do not invoke + sudo, create system units, or enable Linux lingering. +- [ ] Integrate service preparation into `domain init` and `node join` only + after a separate explicit confirmation. Add `agenet node start|stop|status` + and make every command reconcile durable bootstrap phase state. Service + removal remains owned by the lifecycle commands in Task 13. +- [ ] Run `cargo test --test service_macos --test service_linux`; run the native + platform smoke test in a temporary user-scoped service label and clean it up + through the tested uninstall path. +- [ ] Commit: + +```text +[feat][Bootstrap][11/14] Manage user node service + +Root cause: NA +Solution: Add recoverable LaunchAgent and systemd user services without +root privileges or embedded secrets. +Risks: Nodes start only after the owning user logs in. +Dependency: Bootstrap step 10. +Links: plan/01-v1-multi-host-node-bootstrap.md +``` + +## Task 12: Integrate the v0.2 runtime and migrate the loopback demo + +**Files:** + +- Modify: `src/node.rs` +- Modify: `src/demo.rs` +- Modify: `src/runtime/node.rs` +- Modify: `src/runtime/directory.rs` +- Modify: `src/runtime/requester.rs` +- Modify: `src/runtime/provider.rs` +- Modify: `tests/multiprocess_demo.rs` +- Create: `tests/multihost_simulated.rs` + +- [ ] Update the four-process test first so every node has a v0.2 Authority + chain and certificate, Requester still knows only Directory seeds, and all + endpoints derive from signed manifests. +- [ ] Add a simulated multi-host test binding distinct loopback aliases and + enforcing HTTPS/mTLS, revocation freshness, two bilateral Contracts, + independent metrics verification, and final `Accepted`. +- [ ] Refactor `NodeRuntime` to load validated `NodeConfigV1`, credential chain, + TLS identity, network boundary, and revocation cache before binding. Remove + the static validation timestamp from `NodeIdentity`; use an injected clock at + request validation time. +- [ ] Register manifests only after peer TLS is ready and the revocation + snapshot is current. Reject manifest endpoints outside the provider's + declared boundary or with a TLS identity different from the signed provider. +- [ ] Preserve `agenet demo` as an explicit loopback harness. It may provision + an ephemeral Authority automatically but must not read the administrative + keystore or claim multi-host security. +- [ ] Run `cargo test --test multiprocess_demo --test multihost_simulated` and + verify all spawned processes and listener ports are reaped. +- [ ] Commit: + +```text +[feat][Bootstrap][12/14] Integrate host runtime + +Root cause: NA +Solution: Run all four roles through the v0.2 +credential, TLS, network-boundary, and revocation paths. +Risks: The simulated test cannot replace physical overlay acceptance. +Dependency: Bootstrap step 11. +Links: docs/design/agenet-v0.1.md +``` + +## Task 13: Complete renewal, revoke, leave, uninstall, and doctor + +**Files:** + +- Create: `src/cli/lifecycle.rs` +- Create: `src/cli/doctor.rs` +- Modify: `src/cli/mod.rs` +- Modify: `src/cli/node.rs` +- Modify: `src/bootstrap/enrollment.rs` +- Modify: `src/runtime/revocation.rs` +- Create: `tests/lifecycle_cli.rs` +- Create: `tests/doctor_cli.rs` +- Modify: `README.md` +- Modify: `docs/design/agenet-v0.1.md` + +**Command contract:** + +```text +agenet credential renew +agenet node revoke +agenet node leave +agenet uninstall [--purge] +agenet node doctor [--output json] +``` + +- [ ] Write failure-first tests for renewal before expiry, expired credentials, + revoked nodes, Authority unavailability, repeated leave/uninstall, state + preservation, noninteractive destructive confirmation rejection, and + sanitized doctor output. +- [ ] Implement renewal as a new local CSR authenticated by current mTLS and + signed envelope; never reuse a TLS private key. Validate and atomically swap + the new bundle, then reconnect before deleting the retired key. +- [ ] Implement revoke as an administrative Root-authorized Authority action + that increments the snapshot epoch. Require typed confirmation containing the + target Node ID. +- [ ] Implement leave as unregister plus service stop and a best-effort signed + departure Event; preserve identity and audit state. Implement uninstall as + service and binary removal while retaining state by default. `--purge` + requires interactive confirmation and lists every identity/journal path; the + Root keystore is never deleted by node uninstall. +- [ ] Implement doctor checks for file permissions, config schema, clock skew, + exact bind ownership, TLS validity, Authority reachability, revocation age, + Directory reachability, service state, and binary/config version compatibility. +- [ ] Update the README and design verification matrix with exact implemented + and deferred claims. +- [ ] Run `cargo test --test lifecycle_cli --test doctor_cli`, then + `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D + warnings`, and `cargo test --all-targets`. +- [ ] Commit: + +```text +[feat][Bootstrap][13/14] Complete node lifecycle + +Root cause: NA +Solution: Add renewal, revocation, recoverable leave and uninstall, and +machine-readable diagnostics. +Risks: Uninstall cannot remove overlay software outside AgenNet. +Dependency: Bootstrap step 12. +Links: plan/01-v1-multi-host-node-bootstrap.md +``` + +## Task 14: Pass the physical two-device acceptance gate + +**Files:** + +- Create: `docs/testing/two-device-acceptance.md` +- Create: `scripts/verify-two-device-evidence.sh` +- Create: `tests/fixtures/two-device-evidence.schema.json` +- Modify: `ROADMAP.md` +- Modify: `README.md` +- Modify: `docs/design/agenet-v0.1.md` + +- [ ] Write the evidence JSON Schema first. Require redacted device labels, + OS/architecture, AgenNet version, Domain/Node IDs, private endpoint classes, + certificate fingerprints, revocation epochs, Contract/Event IDs, Artifact + hash and metrics, timestamps, and test result; forbid invitation material, + IP addresses, usernames, paths, prompts, keys, and credentials. +- [ ] Write `scripts/verify-two-device-evidence.sh` to validate the schema, + distinct nodes, distinct device labels, v0.2 credentials, full Contract path, + matching independent metrics, final `Accepted`, post-revocation rejection, + and bounded clock skew. +- [ ] On device A, create the Domain and invitation through TTY, start Directory + and Requester services, and record sanitized doctor output. +- [ ] On device B, join through the pinned Authority endpoint, start Executor + and Verifier profiles, and prove Requester learns their endpoints only from + signed manifests. +- [ ] Execute the real `source.metrics.v1` pursuit. Record both bilateral + Contracts, independent Evidence, final `Accepted`, process identities, and + stage timings in the schema without secrets or full network addresses. +- [ ] Revoke device B from device A, refresh snapshots, then prove a new + effectful request from B is rejected while health and audit inspection remain + available. +- [ ] Use development machines to run clock-skew, restart-during-enrollment, + invitation race, stale revocation, Directory restart, and capability-churn + scenarios. Record failures and design changes in `ROADMAP.md`. +- [ ] Run the evidence verifier, all Rust quality gates, and a repository secret + scan. Update README/design claims only after evidence passes. +- [ ] Commit: + +```text +[milestone][Bootstrap][14/14] Verify host preview + +Root cause: NA +Solution: Validate enrollment, discovery, execution, recovery, and +revocation across two physical private-overlay devices. +Risks: Results establish a Developer Preview, not Internet-scale or +public-network security. +Dependency: Bootstrap step 13 and two operator-controlled devices. +Links: docs/testing/two-device-acceptance.md +``` + +## Final Acceptance Commands + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets +cargo run -- demo \ + --env-file /Users/bytedance/proj/bandai/Walkman/.env \ + --artifact fixtures/sample.rs +scripts/verify-two-device-evidence.sh \ + .local/evidence/two-device-acceptance.json +``` + +The milestone remains incomplete if only the simulated test passes, if either +physical device uses a public or wildcard listener, if revocation is not +observed by the remote node, or if any secret appears in evidence, logs, argv, +environment, repository history, or public documentation. diff --git a/plan/02-v1-installation-surfaces.md b/plan/02-v1-installation-surfaces.md new file mode 100644 index 0000000..bb05301 --- /dev/null +++ b/plan/02-v1-installation-surfaces.md @@ -0,0 +1,437 @@ +# AgenNet Installation Surfaces Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. Use `skill-creator` for the Codex Skill +> task and validate the generated package with its bundled validator. + +**Goal:** Let a human, a general coding Agent, or a non-Agent machine install a +fixed AgenNet release and enter the same secure CLI-controlled enrollment flow, +with reproducible release metadata, checksums, GitHub artifact attestations, and +no secret exposure through documentation or automation. + +**Architecture:** Publish four native archives and one signed release manifest. +Keep installation separate from enrollment: the convenience installer only +selects and verifies a binary, while `agenet node join` owns all state changes +and TTY-only secret input. Generate the human guide, raw Agent guide, Codex Skill +reference, and website bootstrap metadata from versioned repository sources so +their commands and release identifiers cannot drift. + +**Tech Stack:** GitHub Actions, GitHub Releases, GitHub artifact attestations, +POSIX shell, Rust release-manifest generator, Codex Skill package, macOS/Linux +arm64/x86_64. + +## Global Constraints + +- Start only after Tasks 1–13 of + `plan/01-v1-multi-host-node-bootstrap.md` pass locally. Release candidates may + precede the physical gate, but public copy must remain Developer Preview until + Task 14 passes. +- The installer never accepts an invitation, passphrase, API key, Agent token, + or private-network credential. It installs only a verified executable. +- Never use a moving branch archive, `latest` URL in Agent instructions, + unpinned Action, or manifest whose version differs from the request. +- The repository owns the canonical guides, schema, and Skill. The website + consumes generated copies and must not fork them. +- Keep the Skill concise, imperative, under 500 lines, and delegate every state + mutation to `agenet`. +- A coding Agent may inspect diagnostics, but installs an Agent adapter only + after a second explicit authorization and never sees the invitation in chat. +- Use the five-section Commit Message format for each reviewable task. + +--- + +## Task 1: Define and generate the release manifest + +**Files:** + +- Create: `src/release/mod.rs` +- Create: `src/release/manifest.rs` +- Modify: `src/lib.rs` +- Create: `src/bin/agenet-release-manifest.rs` +- Create: `schemas/release-manifest-v1.schema.json` +- Create: `tests/release_manifest.rs` +- Modify: `Cargo.toml` + +**Interfaces:** + +```rust +pub struct ReleaseManifestV1 { + pub schema_version: u32, + pub project: String, + pub version: String, + pub git_commit: String, + pub published_at: String, + pub minimum_rust_version: String, + pub artifacts: BTreeMap, +} + +pub struct ReleaseArtifact { + pub file_name: String, + pub download_url: String, + pub sha256: String, + pub size_bytes: u64, + pub attestation_subject: String, +} + +pub enum ReleaseTarget { + MacosArm64, + MacosX86_64, + LinuxArm64, + LinuxX86_64, +} +``` + +- [ ] Write failing tests for a complete four-target manifest, missing target, + duplicate filename, invalid SHA-256, wrong display name, non-SemVer version, + non-HTTPS URL, URL/version mismatch, unexpected host, path traversal, + unstable JSON ordering, and JSON Schema parity. +- [ ] Implement strict Serde types with `deny_unknown_fields` and deterministic + `BTreeMap` serialization. Accept release URLs only under + `https://github.com/Nexa-Language/AgenNet/releases/download/v/`. +- [ ] Implement `agenet-release-manifest` to read explicit archive paths, + calculate checksums and sizes, require explicit version and full commit SHA, + and write atomically without network access. +- [ ] Generate and validate the checked-in JSON Schema; make the test fail when + the Rust model and schema differ. +- [ ] Run `cargo test --test release_manifest` and + `cargo run --bin agenet-release-manifest -- --help`. +- [ ] Commit: + +```text +[feat][Release][1/7] Define release manifest + +Root cause: NA +Solution: Add a deterministic four-platform manifest shared by +installers and the public site. +Risks: A future target requires a schema-versioned manifest change. +Dependency: Bootstrap plan tasks 1-13. +Links: plan/02-v1-installation-surfaces.md +``` + +## Task 2: Build reproducible four-target release archives + +**Files:** + +- Create: `.github/workflows/release.yml` +- Create: `scripts/package-release.sh` +- Create: `scripts/check-release-archive.sh` +- Create: `tests/scripts/test-release-archive.sh` +- Create: `packaging/README.release.md` +- Create: `LICENSE` + +**Archive contract:** + +```text +agenet-v-/ +├── agenet +├── LICENSE +├── README.md +└── RELEASE-METADATA.json +``` + +- [ ] Write the archive checker and failing fixtures first. Reject absolute or + parent-relative entries, symlinks, unexpected files, bad executable mode, + mismatched binary version/commit, failed smoke test, and unstable ordering. +- [ ] Implement `scripts/package-release.sh` with explicit `--binary`, + `--target`, `--version`, `--commit`, and `--output-dir`. Normalize timestamps, + uid/gid, ordering, and permissions without mutating the built binary. +- [ ] Add the repository's declared MIT license text as `LICENSE` and include it + byte-for-byte in every archive. +- [ ] Create a tag-triggered native matrix using `macos-15` for arm64, + `macos-15-intel` for x86_64, `ubuntu-24.04-arm` for arm64, and + `ubuntu-24.04` for x86_64. Do not hide emulation behind one job. +- [ ] Pin every third-party Action to a full commit SHA with its upstream tag in + a comment. Keep permissions read-only until the publish job. +- [ ] In every job run format, Clippy, target-appropriate tests, release build, + `agenet --version`, archive validation, and exact artifact upload. +- [ ] Run the packager/checker locally, lint the workflow with `actionlint`, and + inspect the archive using `tar -tvf`. +- [ ] Commit: + +```text +[feat][Release][2/7] Build native archives + +Root cause: NA +Solution: Build deterministic native archives on four stable GitHub +runners and validate their contents. +Risks: Runner image updates can alter linked system-library behavior. +Dependency: Release step 1. +Links: packaging/README.release.md +``` + +## Task 3: Publish checksums and GitHub artifact attestations + +**Files:** + +- Modify: `.github/workflows/release.yml` +- Create: `scripts/verify-release.sh` +- Create: `tests/scripts/test-verify-release.sh` +- Modify: `packaging/README.release.md` + +**Verification contract:** + +```text +scripts/verify-release.sh \ + --version \ + --archive \ + --manifest \ + [--require-gh-attestation] +``` + +- [ ] Write tests first for valid input, bad checksum, wrong target/version, + alternate origin, absent attestation tool, and failed mocked attestation. +- [ ] Implement checksum verification with `shasum -a 256` or `sha256sum`, exact + size comparison, archive validation, and binary version verification. +- [ ] Make high-assurance mode require + `gh attestation verify --repo Nexa-Language/AgenNet`; missing `gh`, + offline verification, and failed attestation are hard errors. +- [ ] Generate `SHA256SUMS` and the manifest, attest each archive and manifest, + then publish one immutable release for the exact annotated tag. +- [ ] Grant `id-token: write`, `attestations: write`, and `contents: write` only + to the attestation/publish job. Everything else keeps `contents: read`. +- [ ] Reject tag/Cargo version mismatch, an existing release, a dirty generated + manifest, or a tag commit not reachable from `master`. +- [ ] Run shell tests and workflow validation. After publishing a candidate, + download it into a clean temporary directory and verify both modes. +- [ ] Commit: + +```text +[feat][Release][3/7] Attest release artifacts + +Root cause: NA +Solution: Publish fixed-version checksums, a strict manifest, and GitHub +artifact attestations with least-privilege permissions. +Risks: High assurance depends on GitHub's service and gh CLI. +Dependency: Release step 2. +Links: plan/02-v1-installation-surfaces.md +``` + +## Task 4: Implement the convenience installer without enrollment logic + +**Files:** + +- Create: `scripts/install.sh` +- Create: `tests/scripts/test-install.sh` +- Create: `tests/fixtures/install/manifest.json` +- Modify: `README.md` + +**Installer contract:** + +```text +sh install.sh --version [--prefix ] + [--manifest-url ] +``` + +- [ ] Write a hermetic test harness with a local fixture server and fake + `uname`. Cover all four mappings, unsupported targets, TLS failure, redirect, + oversized/invalid manifest, bad checksum, partial download, existing binary, + unwritable prefix, interrupted atomic install, and cleanup. +- [ ] Implement strict parsing with `set -eu`, explicit SemVer, absolute prefix, + bounded HTTPS downloads, `mktemp -d`, and cleanup traps. +- [ ] Select only from the validated manifest, verify checksum and archive before + extraction, run `agenet --version`, and atomically replace only + `/bin/agenet`. +- [ ] Default to a user-owned prefix such as `~/.local`; never run `sudo`. Print + a safe explicit alternative if the prefix is not writable. +- [ ] Print `agenet node doctor` and `agenet node join` as next steps. Do not + prompt + for an invitation or install a service. +- [ ] Document inspected convenience installation and the downloaded + high-assurance attestation path separately. +- [ ] Run `shellcheck`, the shell suite, and install into a temporary prefix with + no writes outside that prefix. +- [ ] Commit: + +```text +[feat][Install][4/7] Add verified installer + +Root cause: NA +Solution: Install one fixed, checksummed binary atomically while keeping +enrollment and service state inside the CLI. +Risks: Convenience mode omits attestations without high assurance. +Dependency: Release step 3. +Links: README.md +``` + +## Task 5: Create versioned human and generic Agent guides + +**Files:** + +- Create: `docs/bootstrap/node-setup.md` +- Create: `docs/bootstrap/agent-node-setup.md` +- Create: `docs/bootstrap/agent-node-setup.zh-CN.md` +- Create: `docs/bootstrap/agent-contract-v1.schema.json` +- Create: `src/bin/render-bootstrap-docs.rs` +- Create: `tests/bootstrap_docs.rs` +- Modify: `README.md` + +**Agent execution contract:** + +```json +{ + "schema_version": 1, + "phase": "preflight|installed|awaiting_secret|joined|service_ready|complete", + "requires_user_action": true, + "safe_next_command": "agenet node join ...", + "diagnostics": [], + "secret_received": false +} +``` + +- [ ] Write tests that parse every fenced command, compare CLI flags with Clap's + command model, validate JSON examples, and reject invitation-shaped strings, + example private keys, shell interpolation, moving URLs, unpinned versions, or + unsupported commands. +- [ ] Write the human guide from preflight through verified install, overlay + check, join, service install, doctor, adapter authorization, leave, and + uninstall. Keep destructive and secret steps visibly operator-owned. +- [ ] Write concise imperative Agent guides. Require the Agent to inspect the + platform and overlay, install a fixed verified version, stop at + `awaiting_secret`, direct the user to AgenNet's hidden TTY prompt, resume from + sanitized JSON, and request another authorization before adapter installation. +- [ ] State that invitations never enter chat, prompts, args, env, Agent-created + files, ordinary stdin, or logs. The Agent stops instead of inventing a bypass. +- [ ] Implement a deterministic renderer that checks command blocks against + stored `agenet --help` snapshots and writes website-consumable copies under + `site/generated/` only when explicitly invoked. +- [ ] Add sentinel invitation and API-key tests across every documented error + path; neither sentinel may appear in generated output. +- [ ] Run `cargo test --test bootstrap_docs`, then exercise both guides in a + clean Agent context and verify they stop before secret input. +- [ ] Commit: + +```text +[doc][Install][5/7] Add bootstrap guides + +Root cause: NA +Solution: Define human and machine-checkable Agent workflows that stop +at the TTY-only invitation boundary. +Risks: Untested Agent clients may format progress differently. +Dependency: Install step 4. +Links: docs/bootstrap/agent-node-setup.md +``` + +## Task 6: Package and install the Codex Skill + +**Files:** + +- Create: `skills/agenet-node-bootstrap/SKILL.md` +- Create: `skills/agenet-node-bootstrap/agents/openai.yaml` +- Create: `skills/agenet-node-bootstrap/references/node-setup.md` +- Create: `src/cli/agent.rs` +- Modify: `src/cli/mod.rs` +- Create: `tests/skill_package.rs` +- Create: `tests/agent_cli.rs` + +**Command contract:** + +```text +agenet agent install-skill --target codex +agenet agent inspect-skill --target codex +agenet agent uninstall-skill --target codex +``` + +- [ ] Read the Skill Creator `references/openai_yaml.md`, then initialize + `skills/agenet-node-bootstrap` with `init_skill.py`; remove all generated + placeholders and do not create auxiliary README or changelog files. +- [ ] Write package tests first: require lowercase hyphenated naming, exactly + `name` and `description` in frontmatter, imperative body below 500 lines, + valid UI metadata, one-level references, no duplicated guide, no enrollment + scripts, no secrets, and only supported AgenNet commands. +- [ ] Trigger the Skill for install, configure, join, diagnose, leave, or remove + requests. Its flow is inspect → verify binary → safe preflight → user TTY + action → sanitized status → optional adapter authorization. +- [ ] Generate `references/node-setup.md` from the canonical Agent guide. The + Skill loads it only for setup or recovery and contains no copied long-form + explanation. +- [ ] Generate `agents/openai.yaml` deterministically with `display_name`, + `short_description`, and `default_prompt`; do not add unapproved icons or + brand colors. +- [ ] Implement Skill installation to resolve the Codex skills directory, + compare package hash/version, show exact target files, require confirmation, + and copy atomically. Never overwrite a modified Skill without backup and + explicit confirmation; never enroll a node or install an adapter. +- [ ] Implement inspect/uninstall using an AgenNet-owned receipt. Remove only + files whose hashes match the receipt; preserve modifications and return + `SkillModifiedByUser`. +- [ ] Run Skill Creator `quick_validate.py`, both Rust tests, installation into + an isolated temporary Codex home, and forward-test fresh install, + already-enrolled doctor, and safe leave without a live invitation. +- [ ] Commit: + +```text +[feat][Agent][6/7] Package bootstrap skill + +Root cause: NA +Solution: Package a validated Codex Skill using the canonical guide and +delegates every mutation to the AgenNet CLI. +Risks: Codex discovery paths may require a new target adapter. +Dependency: Install step 5. +Links: skills/agenet-node-bootstrap/SKILL.md +``` + +## Task 7: Seal the release and installation matrix + +**Files:** + +- Create: `docs/testing/release-install-matrix.md` +- Create: `docs/testing/release-install-matrix.json` +- Create: `scripts/verify-install-matrix.sh` +- Modify: `.github/workflows/release.yml` +- Modify: `README.md` +- Modify: `ROADMAP.md` +- Modify: `docs/design/agenet-v0.1.md` + +- [ ] Define a machine-readable matrix for macOS arm64/x86_64 and Linux + arm64/x86_64. Each row requires artifact verification, smoke test, installer, + doctor, join dry-run to the TTY boundary, service rendering, Skill lifecycle, + and cleanup. +- [ ] Implement the matrix verifier to reject missing rows, release mismatch, + absent attestations, skipped checks, writes outside temporary/user roots, or + secret-shaped output. +- [ ] Extend release CI to run non-secret matrix checks natively on all four + runners before publish. Keep real enrollment in the physical-device gate. +- [ ] Publish one release candidate from a clean commit reachable from `master`, + then test both verification paths in clean macOS and Linux environments. +- [ ] Prove the Agent guide and Skill resolve the same pinned version, emit the + same CLI sequence, stop at the same secret boundary, and report the same + sanitized phases. +- [ ] Run all Rust gates, shellcheck, shell tests, Skill validation, + manifest/schema tests, workflow lint, and repository secret scanning. +- [ ] Record the exact tag, commit, matrix result, known failures, and deferred + targets in ROADMAP. Do not describe the candidate as stable. +- [ ] Commit: + +```text +[milestone][Install][7/7] Verify install surfaces + +Root cause: NA +Solution: Validate artifacts, provenance, installer, Agent guide, and +Codex Skill across the supported platform matrix. +Risks: Real enrollment remains subject to the physical-device gate. +Dependency: Install step 6 and a published release candidate. +Links: docs/testing/release-install-matrix.md +``` + +## Final Acceptance Commands + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets +shellcheck scripts/install.sh scripts/package-release.sh \ + scripts/check-release-archive.sh scripts/verify-release.sh \ + scripts/verify-install-matrix.sh +tests/scripts/test-release-archive.sh +tests/scripts/test-verify-release.sh +tests/scripts/test-install.sh +scripts/verify-install-matrix.sh \ + docs/testing/release-install-matrix.json +``` + +Installation surfaces are incomplete if any path selects a moving version, +duplicates enrollment logic, accepts the invitation outside a hidden TTY, +overwrites a modified Skill, bypasses verification, or gives humans and Agents +different CLI semantics. diff --git a/plan/03-v1-public-site.md b/plan/03-v1-public-site.md new file mode 100644 index 0000000..0d74866 --- /dev/null +++ b/plan/03-v1-public-site.md @@ -0,0 +1,591 @@ +# AgenNet Public Site Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. Use `frontend-design` for visual-system +> and hero implementation tasks. + +**Goal:** Publish a fast, bilingual AgenNet landing page and documentation site +at GitHub Pages that presents the real Developer Preview, exposes verified +installation metadata, and delivers the approved A3 Field Study atmosphere with +high-quality interactive light ribbons and an ordered responsive point field. + +**Architecture:** Keep the website completely static. Use Astro for the custom +landing pages and Starlight for documentation, with Chinese at the repository +root and English under `/en/`. Render the hero through an isolated WebGL2 canvas +with deterministic shaders, a CSS fallback, reduced-motion behavior, and no +product telemetry. Generate install commands, manifest data, and Agent-guide +downloads from the canonical release/bootstrap sources rather than duplicating +them in page components. + +**Tech Stack:** Node.js 24, pnpm 11.21.0, Astro 7.2.2, Starlight 0.41.7, +TypeScript 7.0.2, native WebGL2/CSS, Playwright 1.62.1, Pagefind through +Starlight, GitHub Pages Actions. + +## Global Constraints + +- Preserve the approved A3 Field Study direction: near-black mineral field, + sparse technical typography, large slow fluid light ribbons, a disciplined + ordered point lattice that bends near the pointer, subtle grid/grain, and a + controlled black fade into content. +- Do not add random star particles, nearest-neighbor connecting lines, generic + glass-card piles, neon rainbow gradients, fake terminals, fake live node + counts, fake throughput, fake customers, or unverifiable scale claims. +- The site has no backend, secrets, invitation form, account system, telemetry, + cookies, live node status, or enrollment state. Every interactive control is + local navigation, language selection, copy, or visual response. +- Installation commands and downloads must name a fixed published version. + Generated content fails the build when the canonical manifest or guide is + absent, invalid, or inconsistent. +- Canvas is progressive enhancement. Semantic content, navigation, installation, + and docs must remain complete when WebGL is absent, scripting fails, motion is + reduced, or the device is low-power. +- Use design tokens for color, typography, spacing, line, surface, and motion. + Do not scatter magic visual values across components. +- Respect the GitHub Pages base path `/AgenNet/` in navigation, assets, canonical + URLs, language alternates, RSS/sitemap output, and test fixtures. +- Use the five-section Commit Message format; deploy only from reviewed `master`. + +--- + +## Task 1: Scaffold the static bilingual Astro/Starlight application + +**Files:** + +- Create: `site/package.json` +- Create: `site/pnpm-lock.yaml` +- Create: `site/astro.config.mjs` +- Create: `site/tsconfig.json` +- Create: `site/src/content.config.ts` +- Create: `site/src/styles/global.css` +- Create: `site/src/pages/index.astro` +- Create: `site/src/pages/en/index.astro` +- Create: `site/src/content/docs/guide/index.mdx` +- Create: `site/src/content/docs/en/guide/index.mdx` +- Create: `site/public/.nojekyll` +- Modify: `.gitignore` + +**Configuration contract:** + +```js +export default defineConfig({ + site: 'https://nexa-language.github.io', + base: '/AgenNet', + integrations: [ + starlight({ + defaultLocale: 'root', + locales: { + root: { label: '简体中文', lang: 'zh-CN' }, + en: { label: 'English', lang: 'en' }, + }, + }), + ], +}); +``` + +- [ ] Create a failing `site/tests/config.test.ts` that asserts exact site/base, + root Chinese locale, English `/en/`, strict TypeScript, no SSR adapter, and no + dependency versions outside the approved pins. +- [ ] Initialize `site/` with pnpm, pin the stated versions exactly, set + `engines.node` to `>=24 <25`, and commit the lockfile. Do not add React, + Tailwind, Three.js, or an animation library. +- [ ] Configure Starlight i18n and content collections so both docs trees build, + while custom Astro landing pages own `/` and `/en/` under the base path. +- [ ] Add semantic minimal pages, skip link, language switch, and a shared global + stylesheet before introducing visual complexity. +- [ ] Run `pnpm --dir site test`, `pnpm --dir site astro check`, and + `pnpm --dir site build`. Inspect `site/dist/index.html` and + `site/dist/en/index.html`. +- [ ] Commit: + +```text +[feat][Site][1/10] Scaffold bilingual site + +Root cause: NA +Solution: Add a pinned Astro and Starlight foundation with Chinese root, +English locale, and the GitHub Pages base path. +Risks: Astro and Starlight upgrades require compatibility review. +Dependency: Installation plan release manifest schema. +Links: plan/03-v1-public-site.md +``` + +## Task 2: Generate site data from canonical release and guide sources + +**Files:** + +- Create: `site/scripts/sync-canonical-content.mjs` +- Create: `site/src/data/release.ts` +- Create: `site/src/data/copy.ts` +- Create: `site/src/content/docs/guide/installation.mdx` +- Create: `site/src/content/docs/en/guide/installation.mdx` +- Create: `site/public/bootstrap/v0.2/agent-bootstrap.md` +- Create: `site/public/bootstrap/v0.2/agent-bootstrap.en.md` +- Create: `site/public/bootstrap/v0.2/manifest.json` +- Create: `site/tests/canonical-content.test.ts` +- Modify: `site/package.json` + +**Generated-data boundary:** + +```ts +export type PublicRelease = Readonly<{ + version: string; + publishedAt: string; + repository: 'Nexa-Language/AgenNet'; + artifacts: ReadonlyArray<{ + target: + | 'aarch64-apple-darwin' + | 'x86_64-apple-darwin' + | 'aarch64-unknown-linux-gnu' + | 'x86_64-unknown-linux-gnu'; + url: string; + sha256: string; + }>; +}>; +``` + +- [ ] Write tests that compare public generated files byte-for-byte with the + canonical release manifest and Agent guides after normalization. Fail on a + moving URL, private path, secret-shaped token, wrong project name, absent + target, non-HTTPS download, or untranslated command semantics. +- [ ] Implement a deterministic sync script that validates the release manifest + against its schema, copies only approved public fields, normalizes line + endings, and refuses to write when the release is unpublished. +- [ ] Keep localized explanatory copy in typed `copy.ts`; keep commands, + versions, artifact URLs, and hashes in generated release data. Components may + not hardcode them. +- [ ] Generate installation MDX from the canonical human guides while preserving + the exact CLI commands and adding locale-specific prose around them. +- [ ] Expose the Chinese raw Agent guide and release manifest at the approved + `/bootstrap/v0.2/agent-bootstrap.md` and `/bootstrap/v0.2/manifest.json` + paths; expose the English raw guide beside them as `agent-bootstrap.en.md`. +- [ ] Add `prebuild` and `check:generated` scripts; CI fails if generation changes + tracked files. +- [ ] Run sync twice to prove idempotency, run tests, and inspect generated files + for local paths, sentinels, and private metadata. +- [ ] Commit: + +```text +[feat][Site][2/10] Sync canonical bootstrap data + +Root cause: NA +Solution: Generate install metadata and Agent guides from validated +canonical repository sources. +Risks: Builds stop when no valid published release exists. +Dependency: Site step 1 and installation plan steps 1-5. +Links: docs/bootstrap/agent-node-setup.md +``` + +## Task 3: Build the A3 Field Study visual system and semantic hero + +**Files:** + +- Create: `site/src/styles/tokens.css` +- Create: `site/src/styles/field-study.css` +- Create: `site/src/components/BrandMark.astro` +- Create: `site/src/components/SiteHeader.astro` +- Create: `site/src/components/Hero.astro` +- Create: `site/src/components/StatusBadge.astro` +- Create: `site/src/layouts/LandingLayout.astro` +- Modify: `site/src/pages/index.astro` +- Modify: `site/src/pages/en/index.astro` +- Create: `site/tests/hero-structure.test.ts` + +**Token contract:** + +```css +:root { + --field-ink: #050608; + --field-ink-soft: #0a0d10; + --field-text: #eef2f0; + --field-muted: #99a39f; + --field-line: rgb(189 211 202 / 14%); + --field-mint: #b9f3d2; + --field-cyan: #7ed8dc; + --field-warm: #d8c3a2; + --space-unit: 0.25rem; + --motion-slow: 18s; + --motion-enter: 700ms; +} +``` + +- [ ] Write structure tests first for exactly one `h1`, semantic header/nav/main, + visible Developer Preview status, primary install and docs actions, no fake + metrics, correct localized labels, language alternates, and functional content + without Canvas. +- [ ] Define a restrained mineral palette, type hierarchy, spacing rhythm, + hairline treatment, focus ring, selection colors, and two motion durations in + tokens. Bundle or self-host fonts only when their licenses and subset sizes are + recorded; otherwise use a deliberate system stack. +- [ ] Compose an asymmetric hero: compact mark/header, high-impact AgenNet name, + one precise coordination statement, status boundary, two actions, and a quiet + protocol notation strip. Avoid centered SaaS-card composition. +- [ ] Build the CSS-only atmosphere with grid, grain via a tiny local texture or + layered gradients, radial light, and bottom black fade. The fallback must look + complete before WebGL is mounted. +- [ ] Add a restrained staged entrance for header, title, statement, and actions. + Disable all nonessential entrance motion under `prefers-reduced-motion`. +- [ ] Run unit tests, Astro check/build, keyboard navigation, and snapshots with + JavaScript disabled at 390×844 and 1440×1000. +- [ ] Commit: + +```text +[feat][Site][3/10] Compose Field Study hero + +Root cause: NA +Solution: Establish A3 visual tokens and an atmospheric hero that +remains complete without WebGL. +Risks: Typography may vary until approved fonts ship. +Dependency: Site step 2. +Links: docs/superpowers/specs/ +2026-08-14-node-bootstrap-and-pages-design.md +``` + +## Task 4: Implement deterministic WebGL2 light ribbons and point field + +**Files:** + +- Create: `site/src/components/field/FieldCanvas.astro` +- Create: `site/src/components/field/field-client.ts` +- Create: `site/src/components/field/renderer.ts` +- Create: `site/src/components/field/shaders.ts` +- Create: `site/src/components/field/capability.ts` +- Create: `site/tests/field-math.test.ts` +- Create: `site/e2e/field-canvas.spec.ts` +- Modify: `site/src/components/Hero.astro` +- Modify: `site/src/styles/field-study.css` + +**Renderer boundary:** + +```ts +export interface FieldRenderer { + resize(width: number, height: number, dpr: number): void; + setPointer(x: number, y: number, active: boolean): void; + render(timeSeconds: number): void; + dispose(): void; +} + +export type FieldQuality = 'full' | 'reduced' | 'static'; +``` + +- [ ] Write math tests for deterministic lattice coordinates, pointer force + radius/falloff, viewport normalization, DPR cap, quality selection, stable + seed, and zero NaN/Infinity values at boundary coordinates. +- [ ] Write browser tests that fail before implementation: WebGL canvas mounts + once, shaders compile, point field is nonempty, pointer changes a sampled + frame, animation pauses when hidden, context loss falls back, reduced motion + renders one frame, and no console/WebGL errors occur. +- [ ] Implement one full-screen WebGL2 canvas behind semantic content. Use a + deterministic ordered lattice in a point-sprite pass and displace only points + near the normalized pointer with smooth radial falloff and damped return. +- [ ] Implement two or three large slow ribbons as an analytic fragment shader, + using domain-warped signed distance bands, low-frequency motion, controlled + mint/cyan/warm energy, soft additive blending, and dark occlusion. Do not + allocate per-frame particles or simulate random physics. +- [ ] Cap DPR at 1.5 on full quality, reduce lattice density/steps on narrow or + low-core devices, and select static mode for reduced motion, missing WebGL2, + context loss, or shader compilation failure. +- [ ] Use one `requestAnimationFrame` loop, `ResizeObserver`, passive pointer + events, page visibility suspension, and complete listener/GPU cleanup. Never + read device identifiers or send timing data. +- [ ] Add a 60-second performance test at 1440×1000: no unbounded memory growth, + no long task over the documented threshold after warmup, and an average frame + budget appropriate to the test runner. Record the measured budget rather than + claiming a universal FPS. +- [ ] Run unit/E2E tests in Chromium, reduced-motion mode, software-rendered + fallback, and mobile viewport. Visually compare to the approved A3 study. +- [ ] Commit: + +```text +[feat][Site][4/10] Render interactive field + +Root cause: NA +Solution: Add deterministic WebGL2 ribbons and a pointer-responsive +ordered lattice with bounded quality and complete static fallback. +Risks: GPU drivers can render subtle shader differences across devices. +Dependency: Site step 3. +Links: plan/03-v1-public-site.md +``` + +## Task 5: Add verified installation paths and local copy controls + +**Files:** + +- Create: `site/src/components/InstallPanel.astro` +- Create: `site/src/components/InstallTabs.astro` +- Create: `site/src/components/CopyButton.astro` +- Create: `site/src/components/copy-client.ts` +- Create: `site/e2e/install-panel.spec.ts` +- Modify: `site/src/pages/index.astro` +- Modify: `site/src/pages/en/index.astro` + +- [ ] Write E2E tests for macOS/Linux tabs, Agent guide, direct CLI path, + fixed-version commands, correct base-path downloads, keyboard tabs, live-region + copy feedback, clipboard failure, no invitation input, and no network request + on copy. +- [ ] Present three entry paths without duplicating security logic: verified + human install, “send this guide to your Agent,” and direct non-Agent CLI. +- [ ] Separate convenience and high-assurance verification visually and + textually. Explain that installation does not enroll the node. +- [ ] Bind all version, manifest, checksum, and raw-guide URLs to typed generated + data. Build must fail when data is missing rather than displaying a moving + placeholder. +- [ ] Implement local clipboard behavior with explicit success/failure status, + selection fallback, and no analytics. Keep the full commands visible for + manual inspection. +- [ ] Run E2E tests in both locales, with clipboard denied, JavaScript disabled, + and narrow/mobile viewports. +- [ ] Commit: + +```text +[feat][Site][5/10] Add verified install paths + +Root cause: NA +Solution: Present human, Agent, and CLI paths from one +validated release source with accessible local copy controls. +Risks: High assurance requires the GitHub CLI attestation workflow. +Dependency: Site step 4 and installation plan step 7. +Links: docs/bootstrap/node-setup.md +``` + +## Task 6: Complete the landing-page narrative without invented proof + +**Files:** + +- Create: `site/src/components/ProtocolFlow.astro` +- Create: `site/src/components/Principles.astro` +- Create: `site/src/components/CurrentProof.astro` +- Create: `site/src/components/RoadmapBoundary.astro` +- Create: `site/src/components/SiteFooter.astro` +- Modify: `site/src/data/copy.ts` +- Modify: `site/src/pages/index.astro` +- Modify: `site/src/pages/en/index.astro` +- Create: `site/tests/public-claims.test.ts` + +- [ ] Write claim tests that allow only evidence-backed status phrases and fail + on Internet-scale, sandbox, quota, failover, public-network, self-improving, or + multi-host claims not marked verified by the canonical status data. +- [ ] Add a compact protocol flow showing Intent → routing → bilateral Contract + → Evidence → independent verification → Accepted. Use semantic HTML and CSS, + not a raster diagram. +- [ ] Add principles for signed objects, capability-scoped grants, independent + verification, and replaceable adapters. Distinguish candidate invariants from + revisable Developer Preview defaults. +- [ ] Add “currently proven” and “not yet proven” sections sourced from the + design verification matrix. Show exact release/commit only when generated + status evidence exists; never synthesize live counts. +- [ ] End with docs/GitHub calls to action and a compact footer. Preserve the + hero's visual hierarchy instead of turning each section into equal cards. +- [ ] Run claim tests, semantic heading checks, both locale builds, and manual + copy review against README/design. +- [ ] Commit: + +```text +[feat][Site][6/10] Complete honest narrative + +Root cause: NA +Solution: Explain the protocol, proof boundary, and roadmap through an +evidence-gated bilingual landing narrative. +Risks: Status copy must be regenerated after each milestone change. +Dependency: Site step 5. +Links: docs/design/agenet-v0.1.md +``` + +## Task 7: Build the bilingual Starlight documentation system + +**Files:** + +- Modify: `site/astro.config.mjs` +- Create: `site/src/content/docs/guide/*.mdx` +- Create: `site/src/content/docs/en/guide/*.mdx` +- Create: `site/src/content/docs/concepts/*.mdx` +- Create: `site/src/content/docs/en/concepts/*.mdx` +- Create: `site/src/content/docs/reference/*.mdx` +- Create: `site/src/content/docs/en/reference/*.mdx` +- Create: `site/src/content/docs/security/*.mdx` +- Create: `site/src/content/docs/en/security/*.mdx` +- Create: `site/src/content/docs/status/*.mdx` +- Create: `site/src/content/docs/en/status/*.mdx` +- Create: `site/tests/docs-parity.test.ts` + +**Required documentation map:** + +```text +guide: quickstart, create-a-domain, join-a-node, agent-bootstrap/v0.2, + non-agent-node, enable-an-adapter +concepts: identity-and-trust, capabilities, contracts-and-evidence +reference: cli, configuration, bootstrap-manifest +security: threat-model, revocation-and-recovery +status: validation-matrix; plus roadmap and changelog +``` + +- [ ] Write parity tests first: every required slug exists in both locales, + stable anchors match, command blocks match, internal links resolve under the + base path, and no page uses a rejected AgenNet spelling. +- [ ] Configure explicit Starlight sidebars, locale labels, GitHub edit links, + last-updated metadata, and custom CSS that shares the landing tokens without + reducing docs readability. +- [ ] Write concept pages from the approved design and current Rust interfaces. + Label implemented, planned, and deferred behavior on every boundary page. +- [ ] Generate CLI/error references from Clap and stable error definitions; + fail `check:generated` when checked-in reference output is stale. +- [ ] Add operational recovery paths for wrong network boundary, stale + revocation, expired credentials, unavailable Authority, failed service start, + and safe uninstall. +- [ ] Run parity/link/spelling tests, Starlight search build, and manually find + the same topic through Chinese and English navigation. +- [ ] Commit: + +```text +[doc][Site][7/10] Publish bilingual docs + +Root cause: NA +Solution: Add parallel Chinese and English concept and reference +documentation with generated CLI and error contracts. +Risks: Prose translation still needs native-language editorial review. +Dependency: Site step 6. +Links: plan/03-v1-public-site.md +``` + +## Task 8: Enforce accessibility, performance, and content integrity + +**Files:** + +- Create: `site/playwright.config.ts` +- Create: `site/e2e/accessibility.spec.ts` +- Create: `site/e2e/navigation.spec.ts` +- Create: `site/e2e/performance.spec.ts` +- Create: `site/scripts/check-public-output.mjs` +- Modify: `site/package.json` + +- [ ] Add Playwright checks for keyboard-only navigation, visible focus, + skip-link behavior, language switching, tab semantics, reduced motion, forced + colors, 200% zoom, and meaningful page landmarks. +- [ ] Test widths 320, 390, 768, 1024, 1440, and 1920; prevent horizontal + overflow, clipped actions, unreadable line lengths, and Canvas interception of + clicks or selection. +- [ ] Establish budgets for initial JS, CSS, fonts, images, WebGL code, and total + page weight. Fail the build when checked assets exceed the recorded budgets; + exclude no first-party file from measurement. +- [ ] Make `check-public-output.mjs` scan built HTML, JS, source maps, JSON, + Markdown, and headers for private paths, secret-shaped values, source-map + leakage, wrong base URLs, rejected spellings, broken canonical/hreflang links, + and external trackers. +- [ ] Run E2E with WebGL2, forced WebGL failure, JavaScript disabled, reduced + motion, mobile emulation, and both locales. Record real measured page weights + and interaction timings in a checked-in test note. +- [ ] Commit: + +```text +[chore][Site][8/10] Gate site quality + +Root cause: NA +Solution: Add accessibility, responsive, performance, fallback, and +output integrity gates for the complete static site. +Risks: Browser differences require reviewed visual tolerances. +Dependency: Site step 7. +Links: plan/03-v1-public-site.md +``` + +## Task 9: Add least-privilege GitHub Pages deployment + +**Files:** + +- Create: `.github/workflows/pages.yml` +- Create: `site/scripts/verify-deploy-artifact.mjs` +- Create: `site/tests/deploy-artifact.test.ts` +- Modify: `README.md` + +- [ ] Write deployment-artifact tests first: require `.nojekyll`, root and + English pages, docs/search assets, raw guides, release manifest, correct base + links, no source maps, no secrets, and a deterministic content inventory. +- [ ] Create a `master` push/manual workflow with a concurrency group that + cancels superseded builds. Pin checkout, pnpm setup, Node setup, Astro Pages + action, upload-pages-artifact, and deploy-pages Actions to full commit SHAs. +- [ ] Give the build job only `contents: read`; give the deploy job only + `pages: write` and `id-token: write`. Declare the GitHub Pages environment and + use its returned URL. +- [ ] Run clean `pnpm install --frozen-lockfile`, canonical-content check, type + check, unit tests, build, public-output scan, deployment-artifact validation, + and E2E against the built static server before upload. +- [ ] Deploy only the generated `site/dist` directory. Do not expose repository + roots, `.env`, `.local`, evidence journals, plans, or administrative docs as + downloadable site assets. +- [ ] Document repository Settings prerequisites and the expected URL + `https://nexa-language.github.io/AgenNet/`; do not make DNS or custom-domain + changes in this milestone. +- [ ] Validate the workflow with `actionlint` and a pull-request build that has no + Pages write permission. +- [ ] Commit: + +```text +[feat][Site][9/10] Deploy GitHub Pages + +Root cause: NA +Solution: Build, validate, and deploy only the static artifact through a +least-privilege master-only GitHub Pages workflow. +Risks: Pages settings require organization-level permission. +Dependency: Site step 8. +Links: .github/workflows/pages.yml +``` + +## Task 10: Publish and verify the real public site + +**Files:** + +- Create: `docs/testing/public-site-acceptance.md` +- Create: `site/e2e/production.spec.ts` +- Modify: `ROADMAP.md` +- Modify: `README.md` +- Modify: `docs/design/agenet-v0.1.md` + +- [ ] Write production tests for HTTP success, canonical URL, Chinese root, + English path, docs search, raw Agent guides, release manifest, fixed-version + archive links, GitHub link, no mixed content, no trackers, and no invitation + input. +- [ ] Merge only through the user's normal reviewed MR path after rebasing the + feature branch onto current `master`; do not merge directly during execution. +- [ ] Enable GitHub Pages with GitHub Actions as source if repository settings do + not already match. This is the only external configuration mutation in the + site plan and must target `Nexa-Language/AgenNet` exactly. +- [ ] Wait for the Pages workflow, open the real URL, run production Playwright + tests, and inspect desktop/mobile/reduced-motion screenshots plus Canvas + fallback in the browser. +- [ ] Verify GitHub's deployed environment references the expected commit and + that the public manifest/archive attestations resolve to the same release. +- [ ] Record the URL, commit, workflow run, page-weight results, supported + locales, physical-device verification status, and known visual/platform + limitations in ROADMAP. Do not claim completion of multi-host onboarding if + Bootstrap Task 14 is still pending. +- [ ] Commit any evidence-only documentation update through a new feature commit + and the same review path: + +```text +[milestone][Site][10/10] Verify public site + +Root cause: NA +Solution: Validate the bilingual site, bootstrap artifacts, fallbacks, +and release-link integrity at the public URL. +Risks: GitHub Pages availability remains outside AgenNet's control. +Dependency: Site step 9 and reviewed master deployment. +Links: docs/testing/public-site-acceptance.md +``` + +## Final Acceptance Commands + +```bash +pnpm --dir site install --frozen-lockfile +pnpm --dir site check:generated +pnpm --dir site astro check +pnpm --dir site test +pnpm --dir site build +pnpm --dir site test:e2e +node site/scripts/check-public-output.mjs site/dist +node site/scripts/verify-deploy-artifact.mjs site/dist +``` + +The site is incomplete if Canvas is required to understand or install AgenNet, +if release/guide content is manually duplicated, if reduced-motion or mobile +fallbacks are visually broken, if public output contains private state, or if +the deployed page implies capabilities that the protocol and physical-device +evidence have not established. From 7babf4e49e0d3843f907fa23cd2a9ffff3b6bc74 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 14:35:07 +0800 Subject: [PATCH 08/67] [feat][Bootstrap][1/14] Establish v0.2 boundary Root cause: NA Solution: Pin the security stack and introduce an explicit v0.2 kernel boundary with typed migration diagnostics. Risks: The age crate remains pinned below 1.0. Dependency: AgenNet v0.1 at fda7a6b. Links: plan/01-v1-multi-host-node-bootstrap.md --- Cargo.lock | 1449 ++++++++++++++++++++++- Cargo.toml | 15 +- README.md | 21 +- docs/security/dependency-review-v0.2.md | 38 + src/bootstrap/mod.rs | 1 + src/lib.rs | 37 + src/service/mod.rs | 1 + tests/version_boundary.rs | 17 + 8 files changed, 1541 insertions(+), 38 deletions(-) create mode 100644 docs/security/dependency-review-v0.2.md create mode 100644 src/bootstrap/mod.rs create mode 100644 src/service/mod.rs create mode 100644 tests/version_boundary.rs diff --git a/Cargo.lock b/Cargo.lock index 30253de..5f51cd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,23 +2,120 @@ # 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 0.22.1", + "bech32", + "chacha20poly1305", + "cipher", + "cookie-factory", + "hkdf", + "hmac 0.12.1", + "hpke", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "ml-kem", + "nom 8.0.0", + "p256", + "pin-project", + "rand 0.8.7", + "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 0.22.1", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "hpke", + "io_tee", + "nom 8.0.0", + "rand 0.8.7", + "secrecy", + "sha2 0.10.9", +] + [[package]] name = "agenet" -version = "0.1.0" +version = "0.2.0" dependencies = [ + "age", "axum", + "axum-server", "base64 0.23.1", "clap", + "directories", "dotenvy", "ed25519-dalek", "getrandom 0.4.3", + "hmac 0.13.0", "http-body-util", + "ipnet", + "plist", "proptest", + "rcgen", "reqwest", + "rpassword", + "rustls", + "rustls-pemfile", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tempfile", + "time", "tokio", "tower", "tracing", @@ -86,6 +183,54 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -105,6 +250,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -173,6 +319,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[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" @@ -185,13 +359,28 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[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 = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -200,19 +389,38 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[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", + "hybrid-array 0.4.14", + "zeroize", ] [[package]] @@ -251,6 +459,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[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 = "chacha20" version = "0.10.1" @@ -258,10 +477,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "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 = "clap" version = "4.6.6" @@ -311,6 +554,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -327,12 +576,27 @@ dependencies = [ "memchr", ] +[[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 = "core-foundation" version = "0.9.4" @@ -359,6 +623,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[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.0" @@ -368,16 +641,72 @@ 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 0.6.4", + "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 0.6.4", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "hybrid-array", + "hybrid-array 0.4.14", "rand_core 0.10.1", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[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 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -385,10 +714,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "curve25519-dalek-derive", - "digest", - "fiat-crypto", + "digest 0.11.3", + "fiat-crypto 0.3.0", "rand_core 0.10.1", "rustc_version", "subtle", @@ -406,15 +735,85 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[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 = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.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", - "const-oid", - "crypto-common", + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", ] [[package]] @@ -455,15 +854,40 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 5.0.0", "ed25519", "rand_core 0.10.1", - "sha2", + "sha2 0.11.0", "signature", "subtle", "zeroize", ] +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[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 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -495,18 +919,88 @@ 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 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml", +] + [[package]] name = "find-msvc-tools" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[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 = "fnv" version = "1.0.7" @@ -522,12 +1016,37 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[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" @@ -535,6 +1054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -543,6 +1063,34 @@ 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.3", +] + [[package]] name = "futures-sink" version = "0.3.34" @@ -561,10 +1109,26 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", + "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]] @@ -606,6 +1170,27 @@ dependencies = [ "wasm-bindgen", ] +[[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 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.15" @@ -637,6 +1222,53 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[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 = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[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 0.12.1", + "p256", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "http" version = "1.5.0" @@ -682,6 +1314,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[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.14" @@ -753,6 +1394,72 @@ dependencies = [ "windows-registry", ] +[[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 = "icu_collections" version = "2.2.0" @@ -866,11 +1573,48 @@ 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.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +dependencies = [ + "serde", +] [[package]] name = "is_terminal_polyfill" @@ -896,7 +1640,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -954,6 +1698,25 @@ dependencies = [ "wasm-bindgen", ] +[[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 0.6.4", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -966,6 +1729,15 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1026,6 +1798,22 @@ 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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.2" @@ -1037,6 +1825,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 0.6.4", + "sha3", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1046,6 +1865,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1055,6 +1899,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1067,12 +1920,34 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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" @@ -1096,12 +1971,52 @@ dependencies = [ "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 0.12.1", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[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" @@ -1114,22 +2029,95 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[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 = "potential_utf" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" dependencies = [ - "zerovec", + "proc-macro2", + "quote", ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "proc-macro-error3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" dependencies = [ - "zerocopy", + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -1148,11 +2136,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", - "bit-vec", + "bit-vec 0.8.0", "bitflags", "num-traits", "rand 0.9.5", - "rand_chacha", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -1166,6 +2154,15 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1180,7 +2177,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -1203,7 +2200,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -1244,13 +2241,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -1260,11 +2268,21 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] +[[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 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1275,6 +2293,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[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 = "rand_core" version = "0.9.5" @@ -1308,6 +2335,21 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", + "zeroize", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1317,6 +2359,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1385,10 +2438,66 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1404,6 +2513,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1424,6 +2542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -1443,6 +2562,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1489,7 +2617,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1516,6 +2644,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[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" @@ -1540,6 +2677,39 @@ 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 = "security-framework" version = "3.7.0" @@ -1563,6 +2733,12 @@ dependencies = [ "libc", ] +[[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" @@ -1635,6 +2811,17 @@ dependencies = [ "serde", ] +[[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.0" @@ -1642,8 +2829,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "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]] @@ -1812,13 +3009,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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]] @@ -1841,6 +3058,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1848,6 +3095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -1918,6 +3166,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "tower" version = "0.5.3" @@ -2032,6 +3289,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[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" @@ -2044,12 +3310,53 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[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 = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2098,6 +3405,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2278,6 +3591,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2363,6 +3685,46 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.3" @@ -2432,6 +3794,20 @@ 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 = "zerotrie" @@ -2450,6 +3826,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index 4ef17f9..3cfec76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agenet" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.97.1" description = "Experimental AgenNet loopback coordination substrate" @@ -15,22 +15,35 @@ name = "agenet" path = "src/main.rs" [dependencies] +age = "=0.12.1" axum = "=0.8.9" +axum-server = { version = "=0.8.0", features = ["tls-rustls"] } base64 = "=0.23.1" clap = { version = "=4.6.6", features = ["derive"] } +directories = "=6.0.0" dotenvy = "=0.15.7" ed25519-dalek = { version = "=3.0.0", features = ["rand_core"] } getrandom = "=0.4.3" +hmac = { version = "=0.13.0", features = ["zeroize"] } +ipnet = { version = "=2.12.1", features = ["serde"] } +rcgen = { version = "=0.14.9", default-features = false, features = ["aws_lc_rs", "pem", "zeroize"] } reqwest = { version = "=0.13.4", features = ["json", "query"] } +rpassword = "=7.5.4" +rustls = "=0.23.43" +rustls-pemfile = "=2.2.0" serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" sha2 = "=0.11.0" +time = "=0.3.55" tokio = { version = "=1.53.1", features = ["full"] } tracing = "=0.1.44" tracing-subscriber = { version = "=0.3.20", features = ["env-filter", "fmt"] } uuid = { version = "=1.24.0", features = ["serde", "v4"] } zeroize = "=1.9.0" +[target.'cfg(target_os = "macos")'.dependencies] +plist = "=1.10.0" + [dev-dependencies] http-body-util = "=0.1.5" proptest = "=1.11.0" diff --git a/README.md b/README.md index b8a9d86..409e059 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,25 @@ # AgenNet -AgenNet is an experimental Agent-native coordination substrate built on existing network transports. The first milestone is a real, local, multi-process loopback network that proves dynamic capability discovery, signed bilateral contracts, scoped artifact access, independent verification, and evidence-gated acceptance. +AgenNet is an experimental Agent-native coordination substrate built on existing network transports. The v0.2 Developer Preview starts with a real, local, multi-process loopback network that proves dynamic capability discovery, signed bilateral contracts, scoped artifact access, independent verification, and evidence-gated acceptance. + +## v0.2 boundary and security dependencies + +The package is now version `0.2.0`. New v0.2 objects will use the explicit +`agenet.kernel.v0.2` kernel version. `agenet.kernel.v0.1` remains identifiable +only for migration diagnostics; it is not an implicit compatibility mode and +does not authorize a v0.2 effect endpoint. The version discriminator is an +addition to the versioned protocol boundary, not a rewrite of the retained +v0.1 wire or on-disk formats. + +There are no external v0.1 consumers in this repository's supported release +surface. The v0.1 loopback demo and its tests remain in tree as migration +fixtures until the explicitly versioned v0.2 authority-chain migration lands. + +The v0.2 baseline pins the TLS server, certificate, encryption, protected +input, HMAC, network-prefix, platform-directory, clock, and macOS plist +dependencies required by the subsequent bootstrap tasks. The review record, +including licenses, transitive footprint, and removal boundaries, is in +[`docs/security/dependency-review-v0.2.md`](docs/security/dependency-review-v0.2.md). ## MVP boundary diff --git a/docs/security/dependency-review-v0.2.md b/docs/security/dependency-review-v0.2.md new file mode 100644 index 0000000..bc501a2 --- /dev/null +++ b/docs/security/dependency-review-v0.2.md @@ -0,0 +1,38 @@ +# v0.2 Dependency Review + +**Review scope:** the direct dependencies introduced for the v0.2 multi-host +bootstrap baseline, pinned in `Cargo.toml` and resolved in `Cargo.lock`. +**Review date:** 2026-08-14. + +The review uses each resolved crate's embedded Cargo metadata for license and +upstream repository data, and `cargo tree --offline` for the lockfile +footprint. “Maintained” means the selected release is supplied by its named +upstream repository and remains within a supported upstream release line; it +does not replace ongoing advisory monitoring. Every dependency is exact-pinned +so an update requires this review to be revisited. + +| Crate | License | Maintenance status | Purpose | Transitive footprint | Removal boundary | +| --- | --- | --- | --- | --- | --- | +| `axum-server` 0.8.0 | MIT | Maintained upstream (`programatik29/axum-server`) | HTTPS server integration for Authority and peer endpoints. | Shares the existing Axum/Hyper/Tokio stack; adds `tokio-rustls`, `rustls-pki-types`, and server support crates. | Remove if the v0.2 server transport is replaced with another reviewed server implementation. | +| `rustls` 0.23.43 | Apache-2.0 OR ISC OR MIT | Maintained upstream (`rustls/rustls`) | Rust TLS implementation and the selected AWS-LC cryptographic provider. | `aws-lc-rs`, `rustls-pki-types`, `rustls-webpki`, and `zeroize`; exactly one Rustls 0.23 line is resolved. | Remove only with the reviewed replacement of all peer and enrollment TLS. | +| `rcgen` 0.14.9 | MIT OR Apache-2.0 | Maintained upstream (`rustls/rcgen`) | Create the Authority CA and leaf certificate material. | `aws-lc-rs`, `pem`, `rustls-pki-types`, `time`, and `yasna`; default `ring` support is disabled. | Remove if certificates are externally provisioned through a reviewed provider. | +| `rustls-pemfile` 2.2.0 | Apache-2.0 OR ISC OR MIT | Maintained upstream (`rustls/pemfile`) | Strict PEM decoding for local TLS key and certificate loading. | `rustls-pki-types` and `zeroize`. | Remove if no supported persistence format uses PEM. | +| `age` 0.12.1 | MIT OR Apache-2.0 | Maintained upstream (`str4d/rage`), explicitly beta/pre-1.0 | Encrypt the offline Domain Root material at rest. | Broad crypto and localization closure, including `age-core`, AEAD/HPKE primitives, `scrypt`, `secrecy`, and `zeroize`. | Remove only with a reviewed replacement for offline Root encryption and a versioned keystore migration. | +| `rpassword` 7.5.4 | Apache-2.0 | Maintained upstream (`conradkleinespel/rpassword`) | Read Root passphrases from a hidden terminal. | Small platform I/O closure: `libc` and `rtoolbox`. | Remove if a reviewed OS-native secure prompt replaces terminal passphrase entry. | +| `hmac` 0.13.0 | MIT OR Apache-2.0 | Maintained RustCrypto MACs project | HMAC-SHA-256 for invitation derivation and verification. | `digest` 0.11, `crypto-common`, `ctutils`, and `zeroize`. The separate `hmac` 0.12 is transitive to `age`, not used by AgenNet directly. | Remove when invitations use a different reviewed, versioned authenticator. | +| `ipnet` 2.12.1 | MIT OR Apache-2.0 | Maintained upstream (`krisprice/ipnet`) | Parse and validate explicit loopback/private-overlay network prefixes. | Optional `serde` support shares the existing Serde closure. | Remove if private-network policy no longer accepts CIDR configuration. | +| `directories` 6.0.0 | MIT OR Apache-2.0 | Maintained upstream (`soc/directories-rs`) | Locate per-user config and state directories across supported platforms. | `dirs-sys`, `option-ext`, and platform APIs. | Remove if all service storage is replaced by a reviewed platform abstraction. | +| `time` 0.3.55 | MIT OR Apache-2.0 | Maintained upstream (`time-rs/time`) | Handle certificate and protocol validity timestamps. | `deranged`, `num-conv`, `powerfmt`, and `time-core`; also used by `rcgen` and macOS `plist`. | Remove if a reviewed certificate/time abstraction makes this direct dependency unnecessary. | +| `plist` 1.10.0 | MIT | Maintained upstream (`ebarnard/rust-plist`) | Generate and validate macOS LaunchAgent property lists. macOS target only. | `base64`, `indexmap`, `quick-xml`, `serde`, and `time`; omitted from non-macOS targets. | Remove if LaunchAgent support is retired or moved behind another reviewed serializer. | + +## Duplicate-major investigation + +`cargo tree -d --offline` reports several duplicate-major packages, including +`hmac` 0.12/0.13 and `digest` 0.10/0.11. The older lines are confined to +`age`'s transitive cryptography closure; AgenNet's direct HMAC uses the pinned +0.13 line. No duplicate Rustls major version is present: TLS resolves only +`rustls` 0.23.43. `rcgen` has `default-features = false` and explicitly selects +`aws_lc_rs`, preventing its default `ring` provider from adding a second TLS +provider. These remaining dependency-family duplicates are expected by the +selected exact crate versions and require review again when `age` or the +crypto stack changes. diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs new file mode 100644 index 0000000..7c300a6 --- /dev/null +++ b/src/bootstrap/mod.rs @@ -0,0 +1 @@ +//! Bootstrap orchestration boundary for the v0.2 multi-host preview. diff --git a/src/lib.rs b/src/lib.rs index 43c9169..1b50c9e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,43 @@ pub mod adapters; +pub mod bootstrap; pub mod demo; pub mod node; pub mod protocol; pub mod runtime; +pub mod service; pub mod transport; + +pub const KERNEL_VERSION_V1: &str = "agenet.kernel.v0.1"; +pub const KERNEL_VERSION_V2: &str = "agenet.kernel.v0.2"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SupportedKernelVersion { + V1, + V2, +} + +impl SupportedKernelVersion { + pub const fn emitted() -> Self { + Self::V2 + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::V1 => KERNEL_VERSION_V1, + Self::V2 => KERNEL_VERSION_V2, + } + } + + pub fn parse(value: &str) -> Result { + match value { + KERNEL_VERSION_V1 => Ok(Self::V1), + KERNEL_VERSION_V2 => Ok(Self::V2), + _ => Err(KernelVersionError::UnsupportedKernelVersion), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KernelVersionError { + UnsupportedKernelVersion, +} diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 0000000..68c836c --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1 @@ +//! User-service integration boundary for the v0.2 multi-host preview. diff --git a/tests/version_boundary.rs b/tests/version_boundary.rs new file mode 100644 index 0000000..2eb13fd --- /dev/null +++ b/tests/version_boundary.rs @@ -0,0 +1,17 @@ +use agenet::{KERNEL_VERSION_V1, KERNEL_VERSION_V2, KernelVersionError, SupportedKernelVersion}; + +#[test] +fn version_boundary_emits_v2_and_classifies_legacy_and_unknown_versions() { + assert_eq!( + SupportedKernelVersion::emitted().as_str(), + KERNEL_VERSION_V2 + ); + assert_eq!( + SupportedKernelVersion::parse(KERNEL_VERSION_V1), + Ok(SupportedKernelVersion::V1) + ); + assert_eq!( + SupportedKernelVersion::parse("agenet.kernel.v9.9"), + Err(KernelVersionError::UnsupportedKernelVersion) + ); +} From f57de0f817010628a3c30ca48b75093dea91e060 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 15:03:18 +0800 Subject: [PATCH 09/67] [feat][Bootstrap][2/14] Add authority credentials Root cause: NA Solution: Replace direct Root-to-node trust with a versioned Root-to-Authority-to-node chain and strict validation. Risks: Retained v0.1 demo state requires a new run rather than in-place credential reuse. Dependency: Bootstrap step 1. Links: docs/superpowers/specs/ 2026-08-14-node-bootstrap-and-pages-design.md --- README.md | 14 +- src/demo.rs | 93 +++++-- src/node.rs | 28 +- src/protocol/authority.rs | 268 +++++++++++++++++++ src/protocol/envelope.rs | 88 +++++-- src/protocol/error.rs | 11 + src/protocol/identity.rs | 113 ++++++-- src/protocol/mod.rs | 17 +- src/protocol/sealed_contract.rs | 70 +++-- src/protocol/types.rs | 3 +- src/runtime/identity.rs | 37 ++- src/runtime/provider.rs | 5 +- src/runtime/recorder.rs | 31 ++- src/runtime/requester.rs | 6 +- src/transport/client.rs | 27 +- src/transport/directory.rs | 27 +- src/transport/node.rs | 24 +- tests/authority_protocol.rs | 444 ++++++++++++++++++++++++++++++++ tests/common/mod.rs | 80 ++++++ tests/http_artifact.rs | 27 +- tests/http_client.rs | 42 +-- tests/http_directory.rs | 30 +-- tests/protocol_kernel.rs | 239 ++++++++++++----- tests/runtime_storage.rs | 34 +-- 24 files changed, 1483 insertions(+), 275 deletions(-) create mode 100644 src/protocol/authority.rs create mode 100644 tests/authority_protocol.rs create mode 100644 tests/common/mod.rs diff --git a/README.md b/README.md index 409e059..dc849d1 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,17 @@ AgenNet is an experimental Agent-native coordination substrate built on existing The package is now version `0.2.0`. New v0.2 objects will use the explicit `agenet.kernel.v0.2` kernel version. `agenet.kernel.v0.1` remains identifiable only for migration diagnostics; it is not an implicit compatibility mode and -does not authorize a v0.2 effect endpoint. The version discriminator is an -addition to the versioned protocol boundary, not a rewrite of the retained -v0.1 wire or on-disk formats. +does not authorize a v0.2 effect endpoint. The v0.2 wire envelope carries a +Root-to-Authority-to-Node credential chain, validates its Domain, scope, +profile, role, issuer, and lifetime before deserializing signed business +payload bytes, and rejects direct-root v0.1 credentials with +`MigrationRequiredV1Credential`. There are no external v0.1 consumers in this repository's supported release -surface. The v0.1 loopback demo and its tests remain in tree as migration -fixtures until the explicitly versioned v0.2 authority-chain migration lands. +surface. The loopback demo now provisions fresh v0.2 chains on every run. +Retained v0.1 credential state is read only for migration diagnosis and must +be regenerated; there is no v0.1 credential issuance API or v0.2 effect-path +fallback. The v0.2 baseline pins the TLS server, certificate, encryption, protected input, HMAC, network-prefix, platform-directory, clock, and macOS plist diff --git a/src/demo.rs b/src/demo.rs index 3e27fcb..4de9ab9 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeSet, HashMap, HashSet}, fs::{self, File}, path::{Path, PathBuf}, process::{Child, Command, Stdio}, @@ -12,7 +12,10 @@ use serde::{Deserialize, Serialize}; use crate::{ node::{NodeProfile, ReadyState}, - protocol::{CredentialClaims, NodeId, NodeRole, SignedNodeCredential}, + protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, CredentialChain, DomainId, + NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, + }, runtime::{PursuitRequest, PursuitResult, write_signing_key}, }; @@ -210,6 +213,32 @@ fn provision_nodes( now: u64, ) -> Result, String> { let mut nodes = HashMap::new(); + let now_ms = i64::try_from(now).map_err(sanitized)?; + let authority_key = random_signing_key()?; + let domain_id = DomainId::new(format!("domain:{}", uuid::Uuid::new_v4())).map_err(sanitized)?; + let authority_credential = SignedAuthorityCredential::issue( + root_key, + AuthorityClaims { + domain_id: domain_id.clone(), + authority_id: NodeId::new(format!("authority:{}", uuid::Uuid::new_v4())) + .map_err(sanitized)?, + signing_public_key_base64: STANDARD.encode(authority_key.verifying_key().to_bytes()), + tls_ca_sha256: "00".repeat(32), + scopes: BTreeSet::from([ + AuthorityScope::IssueNodeCredential, + AuthorityScope::IssueFoundingDirectoryCredential, + ]), + allowed_profiles: BTreeSet::from([ + BootstrapProfile::Base, + BootstrapProfile::Provider, + BootstrapProfile::AgentCandidate, + ]), + maximum_node_lifetime_ms: 601_000, + issued_at_ms: now_ms.saturating_sub(1_000), + expires_at_ms: now_ms.saturating_add(1_200_000), + }, + ) + .map_err(sanitized)?; for profile in [ NodeProfile::Directory, NodeProfile::Requester, @@ -225,17 +254,48 @@ fn provision_nodes( uuid::Uuid::new_v4() )) .map_err(sanitized)?; - let credential = SignedNodeCredential::issue( - root_key, - CredentialClaims { - node_id, - public_key: signing_key.verifying_key().to_bytes(), - role: role(profile), - issued_at_unix_ms: now.saturating_sub(1_000), - expires_at_unix_ms: now + 600_000, - }, - ) + let bootstrap_profile = match profile { + NodeProfile::Directory | NodeProfile::Requester => BootstrapProfile::Base, + NodeProfile::Executor | NodeProfile::Verifier => BootstrapProfile::Provider, + }; + let allowed_roles = match profile { + NodeProfile::Directory => BTreeSet::from([NodeRole::Directory]), + NodeProfile::Requester => BTreeSet::from([NodeRole::Requester]), + NodeProfile::Executor | NodeProfile::Verifier => { + BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]) + } + }; + let claims = NodeCredentialClaims { + domain_id: domain_id.clone(), + authority_id: authority_credential.claims.authority_id.clone(), + node_id, + signing_public_key_base64: STANDARD.encode(signing_key.verifying_key().to_bytes()), + bootstrap_profile, + allowed_roles, + issued_at_ms: now_ms.saturating_sub(1_000), + expires_at_ms: now_ms.saturating_add(600_000), + }; + let node_credential = match profile { + NodeProfile::Directory => authority_credential.issue_founding_directory_credential( + &root_key.verifying_key(), + &authority_key, + claims, + now_ms, + ), + NodeProfile::Requester | NodeProfile::Executor | NodeProfile::Verifier => { + authority_credential.issue_node_credential( + &root_key.verifying_key(), + &authority_key, + claims, + now_ms, + ) + } + } .map_err(sanitized)?; + let credential = CredentialChain { + authority: authority_credential.clone(), + node: node_credential, + }; let key_file = state_dir.join("identity.key"); let credential_file = state_dir.join("credential.json"); let ready_file = state_dir.join("ready.json"); @@ -382,15 +442,6 @@ fn random_signing_key() -> Result { Ok(SigningKey::from_bytes(&secret)) } -fn role(profile: NodeProfile) -> NodeRole { - match profile { - NodeProfile::Directory => NodeRole::Directory, - NodeProfile::Requester => NodeRole::Requester, - NodeProfile::Executor => NodeRole::Executor, - NodeProfile::Verifier => NodeRole::Verifier, - } -} - fn unix_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/src/node.rs b/src/node.rs index 2847853..827cf01 100644 --- a/src/node.rs +++ b/src/node.rs @@ -13,9 +13,7 @@ use tokio::net::TcpListener; use crate::{ adapters::LlmDecisionAdapter, - protocol::{ - CapabilityId, CapabilityManifest, NodeRole, SideEffectProfile, SignedNodeCredential, - }, + protocol::{CapabilityId, CapabilityManifest, CredentialChain, NodeRole, SideEffectProfile}, runtime::{ ArtifactAccessService, ArtifactStore, ContractRecorder, DirectoryRegistry, NodeIdentity, ProviderService, RequesterService, read_signing_key, @@ -68,7 +66,7 @@ pub async fn run(options: NodeOptions) -> Result { fs::create_dir_all(&options.state_dir).map_err(sanitized)?; let now = unix_ms(); let identity = load_identity(&options, now)?; - if identity.claims().role != options.profile.role() { + if identity.role() != options.profile.role() { return Err("CredentialRoleMismatch".to_owned()); } let listener = TcpListener::bind("127.0.0.1:0").await.map_err(sanitized)?; @@ -76,9 +74,14 @@ pub async fn run(options: NodeOptions) -> Result { ensure_loopback(address)?; let endpoint = format!("http://{address}"); let recorder = Arc::new( - ContractRecorder::open(&options.state_dir, *identity.root(), now) - .await - .map_err(sanitized)?, + ContractRecorder::open( + &options.state_dir, + *identity.root(), + identity.domain_id().clone(), + now, + ) + .await + .map_err(sanitized)?, ); let app = match options.profile { NodeProfile::Directory => directory_router(DirectoryRegistry::new(), identity.clone(), now), @@ -87,7 +90,8 @@ pub async fn run(options: NodeOptions) -> Result { .directory_seed .as_deref() .ok_or_else(|| "DirectorySeedRequired".to_owned())?; - let client = PeerClient::new(*identity.root(), now).map_err(sanitized)?; + let client = PeerClient::new(*identity.root(), identity.domain_id().clone(), now) + .map_err(sanitized)?; let service = ProviderService::new( identity.clone(), recorder, @@ -115,7 +119,8 @@ pub async fn run(options: NodeOptions) -> Result { let store = ArtifactStore::open(&options.state_dir, identity.node_id().clone()) .map_err(sanitized)?; let access = ArtifactAccessService::new(store.clone(), *identity.root(), now); - let client = PeerClient::new(*identity.root(), now).map_err(sanitized)?; + let client = PeerClient::new(*identity.root(), identity.domain_id().clone(), now) + .map_err(sanitized)?; let decision = LlmDecisionAdapter::new_modelhub( &required_env("OPENAI_BASE_URL")?, &required_env("OPENAI_API_KEY")?, @@ -153,7 +158,7 @@ pub async fn run(options: NodeOptions) -> Result { fn load_identity(options: &NodeOptions, now: u64) -> Result { let signing_key = read_signing_key(&options.key_file).map_err(sanitized)?; - let credential: SignedNodeCredential = + let credential: CredentialChain = serde_json::from_slice(&fs::read(&options.credential_file).map_err(sanitized)?) .map_err(sanitized)?; let root_bytes = STANDARD @@ -167,7 +172,7 @@ fn load_identity(options: &NodeOptions, now: u64) -> Result, + pub allowed_profiles: BTreeSet, + pub maximum_node_lifetime_ms: u64, + pub issued_at_ms: i64, + pub expires_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignedAuthorityCredential { + pub claims: AuthorityClaims, + pub root_signature_base64: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CredentialChain { + pub authority: SignedAuthorityCredential, + pub node: SignedNodeCredential, +} + +impl SignedAuthorityCredential { + pub fn issue(root: &SigningKey, claims: AuthorityClaims) -> Result { + validate_authority_claims(&claims)?; + let claims_bytes = + serde_json::to_vec(&claims).map_err(|_| ProtocolError::SerializationFailed)?; + let signature = root.sign(&credential_message( + AUTHORITY_CREDENTIAL_DOMAIN, + &claims_bytes, + )); + Ok(Self { + claims, + root_signature_base64: STANDARD.encode(signature.to_bytes()), + }) + } + + pub fn issue_node_credential( + &self, + root_public_key: &VerifyingKey, + authority_signing_key: &SigningKey, + claims: NodeCredentialClaims, + now_ms: i64, + ) -> Result { + verify_authority_credential(root_public_key, self, now_ms)?; + let authority_public_key = decode_verifying_key(&self.claims.signing_public_key_base64)?; + if authority_public_key != authority_signing_key.verifying_key() { + return Err(ProtocolError::CredentialIssuerMismatch); + } + validate_node_issuance(&self.claims, &claims)?; + SignedNodeCredential::sign(authority_signing_key, claims) + } + + pub fn issue_founding_directory_credential( + &self, + root_public_key: &VerifyingKey, + authority_signing_key: &SigningKey, + claims: NodeCredentialClaims, + now_ms: i64, + ) -> Result { + verify_authority_credential(root_public_key, self, now_ms)?; + let authority_public_key = decode_verifying_key(&self.claims.signing_public_key_base64)?; + if authority_public_key != authority_signing_key.verifying_key() { + return Err(ProtocolError::CredentialIssuerMismatch); + } + validate_founding_directory_issuance(&self.claims, &claims)?; + SignedNodeCredential::sign(authority_signing_key, claims) + } +} + +pub fn verify_credential_chain( + root_public_key: &VerifyingKey, + chain: &CredentialChain, + expected_domain: &DomainId, + expected_role: NodeRole, + now_ms: i64, +) -> Result { + let authority_key = verify_authority_credential(root_public_key, &chain.authority, now_ms)?; + let authority = &chain.authority.claims; + if &authority.domain_id != expected_domain { + return Err(ProtocolError::DomainMismatch); + } + let node = chain.node.verify(&authority_key, now_ms)?; + if node.allowed_roles == BTreeSet::from([NodeRole::Directory]) { + validate_founding_directory_issuance(authority, &node)?; + } else { + validate_node_issuance(authority, &node)?; + } + if &node.domain_id != expected_domain { + return Err(ProtocolError::DomainMismatch); + } + if !node.allowed_roles.contains(&expected_role) { + return Err(ProtocolError::CredentialRoleMismatch); + } + super::VerifiedNodeClaims::try_from(node) +} + +fn verify_authority_credential( + root_public_key: &VerifyingKey, + credential: &SignedAuthorityCredential, + now_ms: i64, +) -> Result { + let claims_bytes = + serde_json::to_vec(&credential.claims).map_err(|_| ProtocolError::SerializationFailed)?; + let signature_bytes = STANDARD + .decode(&credential.root_signature_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| ProtocolError::InvalidAuthoritySignature)?; + root_public_key + .verify_strict( + &credential_message(AUTHORITY_CREDENTIAL_DOMAIN, &claims_bytes), + &signature, + ) + .map_err(|_| ProtocolError::InvalidAuthoritySignature)?; + + validate_authority_claims(&credential.claims)?; + if now_ms < credential.claims.issued_at_ms { + return Err(ProtocolError::AuthorityCredentialNotYetValid); + } + if now_ms > credential.claims.expires_at_ms { + return Err(ProtocolError::AuthorityCredentialExpired); + } + decode_verifying_key(&credential.claims.signing_public_key_base64) +} + +fn validate_authority_claims(claims: &AuthorityClaims) -> Result<(), ProtocolError> { + if claims.issued_at_ms > claims.expires_at_ms { + return Err(ProtocolError::AuthorityCredentialExpired); + } + if claims.tls_ca_sha256.len() != 64 + || !claims + .tls_ca_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ProtocolError::InvalidTlsCaFingerprint); + } + decode_verifying_key(&claims.signing_public_key_base64).map(|_| ()) +} + +fn validate_node_issuance( + authority: &AuthorityClaims, + node: &NodeCredentialClaims, +) -> Result<(), ProtocolError> { + if node.domain_id != authority.domain_id { + return Err(ProtocolError::DomainMismatch); + } + if node.authority_id != authority.authority_id { + return Err(ProtocolError::CredentialIssuerMismatch); + } + if !authority + .scopes + .contains(&AuthorityScope::IssueNodeCredential) + { + return Err(ProtocolError::AuthorityScopeViolation); + } + if !authority.allowed_profiles.contains(&node.bootstrap_profile) { + return Err(ProtocolError::BootstrapProfileNotAllowed); + } + validate_profile_roles(node.bootstrap_profile, &node.allowed_roles)?; + validate_node_lifetime(authority, node) +} + +fn validate_founding_directory_issuance( + authority: &AuthorityClaims, + node: &NodeCredentialClaims, +) -> Result<(), ProtocolError> { + if node.domain_id != authority.domain_id { + return Err(ProtocolError::DomainMismatch); + } + if node.authority_id != authority.authority_id { + return Err(ProtocolError::CredentialIssuerMismatch); + } + if !authority + .scopes + .contains(&AuthorityScope::IssueFoundingDirectoryCredential) + { + return Err(ProtocolError::AuthorityScopeViolation); + } + if !authority.allowed_profiles.contains(&node.bootstrap_profile) { + return Err(ProtocolError::BootstrapProfileNotAllowed); + } + if node.allowed_roles != BTreeSet::from([NodeRole::Directory]) { + return Err(ProtocolError::CredentialRoleMismatch); + } + validate_node_lifetime(authority, node) +} + +fn validate_profile_roles( + profile: BootstrapProfile, + roles: &BTreeSet, +) -> Result<(), ProtocolError> { + let permitted = match profile { + BootstrapProfile::Base | BootstrapProfile::AgentCandidate => { + BTreeSet::from([NodeRole::Requester]) + } + BootstrapProfile::Provider => { + BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]) + } + }; + if roles.is_empty() || !roles.is_subset(&permitted) { + return Err(ProtocolError::CredentialRoleMismatch); + } + Ok(()) +} + +fn validate_node_lifetime( + authority: &AuthorityClaims, + node: &NodeCredentialClaims, +) -> Result<(), ProtocolError> { + let lifetime = node + .expires_at_ms + .checked_sub(node.issued_at_ms) + .and_then(|value| u64::try_from(value).ok()) + .ok_or(ProtocolError::NodeCredentialLifetimeExceeded)?; + if lifetime > authority.maximum_node_lifetime_ms + || node.issued_at_ms < authority.issued_at_ms + || node.expires_at_ms > authority.expires_at_ms + { + return Err(ProtocolError::NodeCredentialLifetimeExceeded); + } + Ok(()) +} + +fn decode_verifying_key(encoded: &str) -> Result { + let bytes = STANDARD + .decode(encoded) + .map_err(|_| ProtocolError::InvalidBase64)?; + let key_bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| ProtocolError::InvalidAuthorityPublicKey)?; + VerifyingKey::from_bytes(&key_bytes).map_err(|_| ProtocolError::InvalidAuthorityPublicKey) +} + +pub(crate) fn credential_message(domain: &[u8], claims: &[u8]) -> Vec { + let mut message = Vec::with_capacity(domain.len() + 8 + claims.len()); + message.extend_from_slice(domain); + message.extend_from_slice(&(claims.len() as u64).to_be_bytes()); + message.extend_from_slice(claims); + message +} diff --git a/src/protocol/envelope.rs b/src/protocol/envelope.rs index 82daad2..b1e2a38 100644 --- a/src/protocol/envelope.rs +++ b/src/protocol/envelope.rs @@ -1,34 +1,52 @@ use base64::{Engine, engine::general_purpose::STANDARD}; -use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use ed25519_dalek::{Signature, Signer, SigningKey}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use super::{KERNEL_VERSION, NodeId, ProtocolError, SignedNodeCredential}; +use super::{ + CredentialChain, DomainId, KERNEL_VERSION, NodeId, NodeRole, ProtocolError, + verify_credential_chain, +}; +use crate::{KERNEL_VERSION_V1, KERNEL_VERSION_V2, SupportedKernelVersion}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct WireEnvelope { pub kernel_version: String, pub object_type: String, pub issuer_id: NodeId, - pub credential: SignedNodeCredential, + pub credential_chain: CredentialChain, pub payload_base64: String, pub signature_base64: String, } impl WireEnvelope { + pub fn parse(bytes: &[u8]) -> Result { + let version: EnvelopeVersion = + serde_json::from_slice(bytes).map_err(|_| ProtocolError::SerializationFailed)?; + match SupportedKernelVersion::parse(&version.kernel_version) { + Ok(SupportedKernelVersion::V1) => { + let _: LegacyWireEnvelopeV1 = serde_json::from_slice(bytes) + .map_err(|_| ProtocolError::SerializationFailed)?; + Err(ProtocolError::MigrationRequiredV1Credential) + } + Ok(SupportedKernelVersion::V2) => { + serde_json::from_slice(bytes).map_err(|_| ProtocolError::SerializationFailed) + } + Err(_) => Err(ProtocolError::UnexpectedObjectType), + } + } + pub fn seal( object_type: &str, payload: &T, signer: &SigningKey, - credential: SignedNodeCredential, + credential_chain: CredentialChain, ) -> Result { let payload_bytes = serde_json::to_vec(payload).map_err(|_| ProtocolError::SerializationFailed)?; - let claims_bytes = STANDARD - .decode(&credential.claims_base64) - .map_err(|_| ProtocolError::InvalidBase64)?; - let claims: super::CredentialClaims = serde_json::from_slice(&claims_bytes) - .map_err(|_| ProtocolError::SerializationFailed)?; - if signer.verifying_key().to_bytes() != claims.public_key { + let claims = credential_chain.node.decode_claims()?; + let node_key = super::identity::decode_node_key(&claims.signing_public_key_base64)?; + if node_key != signer.verifying_key() { return Err(ProtocolError::CredentialIssuerMismatch); } let signature = signer.sign(&signature_message( @@ -41,7 +59,7 @@ impl WireEnvelope { kernel_version: KERNEL_VERSION.to_owned(), object_type: object_type.to_owned(), issuer_id: claims.node_id, - credential, + credential_chain, payload_base64: STANDARD.encode(payload_bytes), signature_base64: STANDARD.encode(signature.to_bytes()), }) @@ -50,16 +68,29 @@ impl WireEnvelope { pub fn open( &self, expected_object_type: &str, - root: &VerifyingKey, - now_unix_ms: u64, + root: &ed25519_dalek::VerifyingKey, + expected_domain: &DomainId, + expected_role: NodeRole, + now_ms: i64, ) -> Result { - if self.kernel_version != KERNEL_VERSION || self.object_type != expected_object_type { + if self.kernel_version == KERNEL_VERSION_V1 { + return Err(ProtocolError::MigrationRequiredV1Credential); + } + if self.kernel_version != KERNEL_VERSION_V2 || self.object_type != expected_object_type { return Err(ProtocolError::UnexpectedObjectType); } - let claims = self.credential.verify(root, now_unix_ms)?; + + let claims = verify_credential_chain( + root, + &self.credential_chain, + expected_domain, + expected_role, + now_ms, + )?; if claims.node_id != self.issuer_id { return Err(ProtocolError::CredentialIssuerMismatch); } + let payload_bytes = STANDARD .decode(&self.payload_base64) .map_err(|_| ProtocolError::InvalidBase64)?; @@ -68,9 +99,8 @@ impl WireEnvelope { .map_err(|_| ProtocolError::InvalidBase64)?; let signature = Signature::from_slice(&signature_bytes) .map_err(|_| ProtocolError::InvalidEnvelopeSignature)?; - let verifying_key = VerifyingKey::from_bytes(&claims.public_key) - .map_err(|_| ProtocolError::InvalidEnvelopeSignature)?; - verifying_key + claims + .signing_public_key .verify_strict( &signature_message( &self.kernel_version, @@ -85,6 +115,28 @@ impl WireEnvelope { } } +#[derive(Deserialize)] +struct EnvelopeVersion { + kernel_version: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacyWireEnvelopeV1 { + #[serde(rename = "kernel_version")] + _kernel_version: String, + #[serde(rename = "object_type")] + _object_type: String, + #[serde(rename = "issuer_id")] + _issuer_id: NodeId, + #[serde(rename = "credential")] + _credential: super::identity::LegacySignedNodeCredentialV1, + #[serde(rename = "payload_base64")] + _payload_base64: String, + #[serde(rename = "signature_base64")] + _signature_base64: String, +} + fn signature_message( kernel_version: &str, object_type: &str, diff --git a/src/protocol/error.rs b/src/protocol/error.rs index f58564a..2f793f1 100644 --- a/src/protocol/error.rs +++ b/src/protocol/error.rs @@ -8,12 +8,23 @@ pub enum ProtocolError { SerializationFailed, InvalidBase64, InvalidCredentialSignature, + InvalidAuthoritySignature, + InvalidAuthorityPublicKey, + InvalidTlsCaFingerprint, + AuthorityCredentialExpired, + AuthorityCredentialNotYetValid, + AuthorityScopeViolation, + BootstrapProfileNotAllowed, + NodeCredentialLifetimeExceeded, + DomainMismatch, + CredentialRoleMismatch, CredentialExpired, CredentialNotYetValid, CredentialIssuerMismatch, InvalidEnvelopeSignature, InvalidContractSignature, UnexpectedObjectType, + MigrationRequiredV1Credential, GrantExpired, GrantScopeViolation, ContractMismatch, diff --git a/src/protocol/identity.rs b/src/protocol/identity.rs index a1721d4..5404144 100644 --- a/src/protocol/identity.rs +++ b/src/protocol/identity.rs @@ -1,42 +1,92 @@ +use std::collections::BTreeSet; + use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; -use super::{NodeId, NodeRole, ProtocolError}; +use super::{DomainId, NodeId, NodeRole, ProtocolError}; +use crate::protocol::authority::credential_message; + +const NODE_CREDENTIAL_DOMAIN: &[u8] = b"AGENET\0node-credential-v0.2\0"; -const CREDENTIAL_DOMAIN: &[u8] = b"AGENET\0credential\0"; +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BootstrapProfile { + Base, + Provider, + AgentCandidate, +} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CredentialClaims { +#[serde(deny_unknown_fields)] +pub struct NodeCredentialClaims { + pub domain_id: DomainId, + pub authority_id: NodeId, pub node_id: NodeId, - pub public_key: [u8; 32], - pub role: NodeRole, - pub issued_at_unix_ms: u64, - pub expires_at_unix_ms: u64, + pub signing_public_key_base64: String, + pub bootstrap_profile: BootstrapProfile, + pub allowed_roles: BTreeSet, + pub issued_at_ms: i64, + pub expires_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedNodeClaims { + pub domain_id: DomainId, + pub authority_id: NodeId, + pub node_id: NodeId, + pub signing_public_key: VerifyingKey, + pub bootstrap_profile: BootstrapProfile, + pub allowed_roles: BTreeSet, + pub issued_at_ms: i64, + pub expires_at_ms: i64, +} + +impl TryFrom for VerifiedNodeClaims { + type Error = ProtocolError; + + fn try_from(claims: NodeCredentialClaims) -> Result { + let signing_public_key = decode_node_key(&claims.signing_public_key_base64)?; + Ok(Self { + domain_id: claims.domain_id, + authority_id: claims.authority_id, + node_id: claims.node_id, + signing_public_key, + bootstrap_profile: claims.bootstrap_profile, + allowed_roles: claims.allowed_roles, + issued_at_ms: claims.issued_at_ms, + expires_at_ms: claims.expires_at_ms, + }) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SignedNodeCredential { pub claims_base64: String, pub signature_base64: String, } impl SignedNodeCredential { - pub fn issue(root: &SigningKey, claims: CredentialClaims) -> Result { + pub(crate) fn sign( + authority: &SigningKey, + claims: NodeCredentialClaims, + ) -> Result { + decode_node_key(&claims.signing_public_key_base64)?; let claims_bytes = serde_json::to_vec(&claims).map_err(|_| ProtocolError::SerializationFailed)?; - let signature = root.sign(&credential_message(&claims_bytes)); + let signature = authority.sign(&credential_message(NODE_CREDENTIAL_DOMAIN, &claims_bytes)); Ok(Self { claims_base64: STANDARD.encode(claims_bytes), signature_base64: STANDARD.encode(signature.to_bytes()), }) } - pub fn verify( + pub(crate) fn verify( &self, - root: &VerifyingKey, - now_unix_ms: u64, - ) -> Result { + authority: &VerifyingKey, + now_ms: i64, + ) -> Result { let claims_bytes = STANDARD .decode(&self.claims_base64) .map_err(|_| ProtocolError::InvalidBase64)?; @@ -45,20 +95,25 @@ impl SignedNodeCredential { .map_err(|_| ProtocolError::InvalidBase64)?; let signature = Signature::from_slice(&signature_bytes) .map_err(|_| ProtocolError::InvalidCredentialSignature)?; - root.verify_strict(&credential_message(&claims_bytes), &signature) + authority + .verify_strict( + &credential_message(NODE_CREDENTIAL_DOMAIN, &claims_bytes), + &signature, + ) .map_err(|_| ProtocolError::InvalidCredentialSignature)?; - let claims: CredentialClaims = serde_json::from_slice(&claims_bytes) + let claims: NodeCredentialClaims = serde_json::from_slice(&claims_bytes) .map_err(|_| ProtocolError::SerializationFailed)?; - if now_unix_ms < claims.issued_at_unix_ms { + decode_node_key(&claims.signing_public_key_base64)?; + if now_ms < claims.issued_at_ms { return Err(ProtocolError::CredentialNotYetValid); } - if now_unix_ms > claims.expires_at_unix_ms { + if now_ms > claims.expires_at_ms { return Err(ProtocolError::CredentialExpired); } Ok(claims) } - pub(crate) fn decode_claims(&self) -> Result { + pub(crate) fn decode_claims(&self) -> Result { let claims_bytes = STANDARD .decode(&self.claims_base64) .map_err(|_| ProtocolError::InvalidBase64)?; @@ -66,10 +121,20 @@ impl SignedNodeCredential { } } -fn credential_message(claims: &[u8]) -> Vec { - let mut message = Vec::with_capacity(CREDENTIAL_DOMAIN.len() + 8 + claims.len()); - message.extend_from_slice(CREDENTIAL_DOMAIN); - message.extend_from_slice(&(claims.len() as u64).to_be_bytes()); - message.extend_from_slice(claims); - message +pub(crate) fn decode_node_key(encoded: &str) -> Result { + let bytes = STANDARD + .decode(encoded) + .map_err(|_| ProtocolError::InvalidBase64)?; + let key_bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| ProtocolError::InvalidCredentialSignature)?; + VerifyingKey::from_bytes(&key_bytes).map_err(|_| ProtocolError::InvalidCredentialSignature) +} + +#[derive(Debug, Deserialize)] +pub(crate) struct LegacySignedNodeCredentialV1 { + #[serde(rename = "claims_base64")] + _claims_base64: String, + #[serde(rename = "signature_base64")] + _signature_base64: String, } diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 278f156..5d49a01 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -1,3 +1,4 @@ +mod authority; mod contract; mod envelope; mod error; @@ -5,17 +6,23 @@ mod identity; mod sealed_contract; mod types; +pub use authority::{ + AuthorityClaims, AuthorityScope, CredentialChain, SignedAuthorityCredential, + verify_credential_chain, +}; pub use contract::{ContractProjection, apply_event, event_hash}; pub use envelope::WireEnvelope; pub use error::ProtocolError; -pub use identity::{CredentialClaims, SignedNodeCredential}; +pub use identity::{ + BootstrapProfile, NodeCredentialClaims, SignedNodeCredential, VerifiedNodeClaims, +}; pub use sealed_contract::{ContractOffer, SealedContract}; pub use types::{ AcceptanceProfile, ArtifactId, ArtifactPayload, ArtifactReadRequest, ArtifactRef, CandidateSet, CapabilityId, CapabilityManifest, ContractDraft, ContractEvent, ContractId, - ContractProposeRequest, ContractProposeResponse, ContractQuery, ContractState, ErrorEnvelope, - EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, NodeRole, RouteQuery, - SideEffectProfile, SourceMetrics, + ContractProposeRequest, ContractProposeResponse, ContractQuery, ContractState, DomainId, + ErrorEnvelope, EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, NodeRole, + RouteQuery, SideEffectProfile, SourceMetrics, }; -pub const KERNEL_VERSION: &str = "agenet-kernel-v0.1"; +pub const KERNEL_VERSION: &str = crate::KERNEL_VERSION_V2; diff --git a/src/protocol/sealed_contract.rs b/src/protocol/sealed_contract.rs index e8b793d..174c4d2 100644 --- a/src/protocol/sealed_contract.rs +++ b/src/protocol/sealed_contract.rs @@ -2,13 +2,16 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; -use super::{ContractDraft, ProtocolError, SignedNodeCredential}; +use super::{ + ContractDraft, CredentialChain, NodeRole, ProtocolError, VerifiedNodeClaims, + verify_credential_chain, +}; const CONTRACT_DOMAIN: &[u8] = b"AGENET\0contract\0"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct PartySignature { - credential: SignedNodeCredential, + credential_chain: CredentialChain, signature_base64: String, } @@ -29,12 +32,12 @@ impl ContractOffer { pub fn create( draft: &ContractDraft, requester: &SigningKey, - requester_credential: SignedNodeCredential, + requester_credential: CredentialChain, ) -> Result { let draft_bytes = serde_json::to_vec(draft).map_err(|_| ProtocolError::SerializationFailed)?; let requester_signature = sign_party(&draft_bytes, requester, requester_credential)?; - let claims = requester_signature.credential.decode_claims()?; + let claims = requester_signature.credential_chain.node.decode_claims()?; if claims.node_id != draft.requester { return Err(ProtocolError::InvalidContractSignature); } @@ -52,7 +55,13 @@ impl ContractOffer { let draft_bytes = decode_draft_bytes(&self.draft_payload_base64)?; let draft: ContractDraft = serde_json::from_slice(&draft_bytes).map_err(|_| ProtocolError::SerializationFailed)?; - let requester = verify_party(&draft_bytes, &self.requester_signature, root, now_unix_ms)?; + let requester = verify_party( + &draft_bytes, + &self.requester_signature, + root, + NodeRole::Requester, + now_unix_ms, + )?; if requester.node_id != draft.requester { return Err(ProtocolError::InvalidContractSignature); } @@ -62,13 +71,13 @@ impl ContractOffer { pub fn countersign( self, provider: &SigningKey, - provider_credential: SignedNodeCredential, + provider_credential: CredentialChain, ) -> Result { let draft_bytes = decode_draft_bytes(&self.draft_payload_base64)?; let draft: ContractDraft = serde_json::from_slice(&draft_bytes).map_err(|_| ProtocolError::SerializationFailed)?; let provider_signature = sign_party(&draft_bytes, provider, provider_credential)?; - let provider_claims = provider_signature.credential.decode_claims()?; + let provider_claims = provider_signature.credential_chain.node.decode_claims()?; if provider_claims.node_id != draft.provider { return Err(ProtocolError::InvalidContractSignature); } @@ -84,9 +93,9 @@ impl SealedContract { pub fn seal( draft: &ContractDraft, requester: &SigningKey, - requester_credential: SignedNodeCredential, + requester_credential: CredentialChain, provider: &SigningKey, - provider_credential: SignedNodeCredential, + provider_credential: CredentialChain, ) -> Result { ContractOffer::create(draft, requester, requester_credential)? .countersign(provider, provider_credential) @@ -100,8 +109,20 @@ impl SealedContract { let draft_bytes = decode_draft_bytes(&self.draft_payload_base64)?; let draft: ContractDraft = serde_json::from_slice(&draft_bytes).map_err(|_| ProtocolError::SerializationFailed)?; - let requester = verify_party(&draft_bytes, &self.requester_signature, root, now_unix_ms)?; - let provider = verify_party(&draft_bytes, &self.provider_signature, root, now_unix_ms)?; + let requester = verify_party( + &draft_bytes, + &self.requester_signature, + root, + NodeRole::Requester, + now_unix_ms, + )?; + let provider = verify_party( + &draft_bytes, + &self.provider_signature, + root, + NodeRole::Executor, + now_unix_ms, + )?; if requester.node_id != draft.requester || provider.node_id != draft.provider { return Err(ProtocolError::InvalidContractSignature); } @@ -118,15 +139,16 @@ fn decode_draft_bytes(encoded: &str) -> Result, ProtocolError> { fn sign_party( draft_bytes: &[u8], signer: &SigningKey, - credential: SignedNodeCredential, + credential_chain: CredentialChain, ) -> Result { - let claims = credential.decode_claims()?; - if claims.public_key != signer.verifying_key().to_bytes() { + let claims = credential_chain.node.decode_claims()?; + let verifying_key = super::identity::decode_node_key(&claims.signing_public_key_base64)?; + if verifying_key != signer.verifying_key() { return Err(ProtocolError::CredentialIssuerMismatch); } let signature = signer.sign(&contract_message(draft_bytes)); Ok(PartySignature { - credential, + credential_chain, signature_base64: STANDARD.encode(signature.to_bytes()), }) } @@ -135,17 +157,25 @@ fn verify_party( draft_bytes: &[u8], party: &PartySignature, root: &VerifyingKey, + expected_role: NodeRole, now_unix_ms: u64, -) -> Result { - let claims = party.credential.verify(root, now_unix_ms)?; - let verifying_key = VerifyingKey::from_bytes(&claims.public_key) - .map_err(|_| ProtocolError::InvalidContractSignature)?; +) -> Result { + let now_ms = i64::try_from(now_unix_ms).map_err(|_| ProtocolError::CredentialExpired)?; + let expected_domain = &party.credential_chain.authority.claims.domain_id; + let claims = verify_credential_chain( + root, + &party.credential_chain, + expected_domain, + expected_role, + now_ms, + )?; let signature_bytes = STANDARD .decode(&party.signature_base64) .map_err(|_| ProtocolError::InvalidBase64)?; let signature = Signature::from_slice(&signature_bytes) .map_err(|_| ProtocolError::InvalidContractSignature)?; - verifying_key + claims + .signing_public_key .verify_strict(&contract_message(draft_bytes), &signature) .map_err(|_| ProtocolError::InvalidContractSignature)?; Ok(claims) diff --git a/src/protocol/types.rs b/src/protocol/types.rs index f2ee53f..d918cb2 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -25,12 +25,13 @@ macro_rules! identifier { } identifier!(NodeId); +identifier!(DomainId); identifier!(CapabilityId); identifier!(ArtifactId); identifier!(IntentId); identifier!(ContractId); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NodeRole { Directory, diff --git a/src/runtime/identity.rs b/src/runtime/identity.rs index c06e60e..23a2767 100644 --- a/src/runtime/identity.rs +++ b/src/runtime/identity.rs @@ -2,8 +2,8 @@ use ed25519_dalek::{SigningKey, VerifyingKey}; use serde::Serialize; use crate::protocol::{ - ContractDraft, ContractOffer, CredentialClaims, NodeId, ProtocolError, SealedContract, - SignedNodeCredential, WireEnvelope, + ContractDraft, ContractOffer, CredentialChain, DomainId, NodeId, NodeRole, ProtocolError, + SealedContract, VerifiedNodeClaims, WireEnvelope, verify_credential_chain, }; use super::RuntimeError; @@ -11,8 +11,9 @@ use super::RuntimeError; #[derive(Clone)] pub struct NodeIdentity { signing_key: SigningKey, - credential: SignedNodeCredential, - claims: CredentialClaims, + credential_chain: CredentialChain, + claims: VerifiedNodeClaims, + role: NodeRole, root: VerifyingKey, validation_time_unix_ms: u64, } @@ -20,18 +21,22 @@ pub struct NodeIdentity { impl NodeIdentity { pub fn new( signing_key: SigningKey, - credential: SignedNodeCredential, + credential_chain: CredentialChain, + role: NodeRole, root: VerifyingKey, now_unix_ms: u64, ) -> Result { - let claims = credential.verify(&root, now_unix_ms)?; - if claims.public_key != signing_key.verifying_key().to_bytes() { + let now_ms = i64::try_from(now_unix_ms).map_err(|_| ProtocolError::CredentialExpired)?; + let domain_id = credential_chain.authority.claims.domain_id.clone(); + let claims = verify_credential_chain(&root, &credential_chain, &domain_id, role, now_ms)?; + if claims.signing_public_key != signing_key.verifying_key() { return Err(ProtocolError::CredentialIssuerMismatch.into()); } Ok(Self { signing_key, - credential, + credential_chain, claims, + role, root, validation_time_unix_ms: now_unix_ms, }) @@ -41,10 +46,18 @@ impl NodeIdentity { &self.claims.node_id } - pub fn claims(&self) -> &CredentialClaims { + pub fn domain_id(&self) -> &DomainId { + &self.claims.domain_id + } + + pub fn claims(&self) -> &VerifiedNodeClaims { &self.claims } + pub fn role(&self) -> NodeRole { + self.role + } + pub fn root(&self) -> &VerifyingKey { &self.root } @@ -62,7 +75,7 @@ impl NodeIdentity { object_type, payload, &self.signing_key, - self.credential.clone(), + self.credential_chain.clone(), ) .map_err(RuntimeError::from) } @@ -71,7 +84,7 @@ impl NodeIdentity { &self, draft: &ContractDraft, ) -> Result { - ContractOffer::create(draft, &self.signing_key, self.credential.clone()) + ContractOffer::create(draft, &self.signing_key, self.credential_chain.clone()) .map_err(RuntimeError::from) } @@ -80,7 +93,7 @@ impl NodeIdentity { offer: ContractOffer, ) -> Result { offer - .countersign(&self.signing_key, self.credential.clone()) + .countersign(&self.signing_key, self.credential_chain.clone()) .map_err(RuntimeError::from) } } diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs index 8973fd6..2a11ee6 100644 --- a/src/runtime/provider.rs +++ b/src/runtime/provider.rs @@ -31,9 +31,7 @@ impl ProviderService { role: NodeRole, now_unix_ms: u64, ) -> Result { - if !matches!(role, NodeRole::Executor | NodeRole::Verifier) - || identity.claims().role != role - { + if !matches!(role, NodeRole::Executor | NodeRole::Verifier) || identity.role() != role { return Err(RuntimeError::CredentialRoleMismatch); } Ok(Self { @@ -132,6 +130,7 @@ impl ProviderService { "/v0/artifacts/read", &read_envelope, "artifact.payload.v1", + NodeRole::Requester, ) .await .map_err(map_transport)?; diff --git a/src/runtime/recorder.rs b/src/runtime/recorder.rs index 3d6f40f..2a25251 100644 --- a/src/runtime/recorder.rs +++ b/src/runtime/recorder.rs @@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use crate::protocol::{ - ContractEvent, ContractId, ContractProjection, ContractState, SealedContract, WireEnvelope, - apply_event, + ContractEvent, ContractId, ContractProjection, ContractState, DomainId, SealedContract, + WireEnvelope, apply_event, }; use super::RuntimeError; @@ -30,6 +30,7 @@ struct RecorderState { pub struct ContractRecorder { root: VerifyingKey, + domain_id: DomainId, validation_time_unix_ms: u64, state: Mutex, } @@ -38,6 +39,7 @@ impl ContractRecorder { pub async fn open( state_directory: &Path, root: VerifyingKey, + domain_id: DomainId, validation_time_unix_ms: u64, ) -> Result { fs::create_dir_all(state_directory)?; @@ -50,7 +52,13 @@ impl ContractRecorder { let mut projections = HashMap::new(); for line in contents.lines() { let entry: JournalEntry = serde_json::from_str(line)?; - replay_entry(entry, &root, validation_time_unix_ms, &mut projections)?; + replay_entry( + entry, + &root, + &domain_id, + validation_time_unix_ms, + &mut projections, + )?; } let journal = OpenOptions::new() .create(true) @@ -59,6 +67,7 @@ impl ContractRecorder { .open(journal_path)?; Ok(Self { root, + domain_id, validation_time_unix_ms, state: Mutex::new(RecorderState { journal, @@ -93,7 +102,10 @@ impl ContractRecorder { let event: ContractEvent = envelope.open( "contract.event.v1", &self.root, - self.validation_time_unix_ms, + &self.domain_id, + crate::protocol::NodeRole::Requester, + i64::try_from(self.validation_time_unix_ms) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?, )?; let mut state = self.state.lock().await; let projection = state @@ -141,6 +153,7 @@ fn append_entry(journal: &mut File, entry: &JournalEntry) -> Result<(), RuntimeE fn replay_entry( entry: JournalEntry, root: &VerifyingKey, + domain_id: &DomainId, validation_time_unix_ms: u64, projections: &mut HashMap, ) -> Result<(), RuntimeError> { @@ -153,8 +166,14 @@ fn replay_entry( projections.insert(draft.contract_id.clone(), ContractProjection::new(draft)); } JournalEntry::Event { envelope } => { - let event: ContractEvent = - envelope.open("contract.event.v1", root, validation_time_unix_ms)?; + let event: ContractEvent = envelope.open( + "contract.event.v1", + root, + domain_id, + crate::protocol::NodeRole::Requester, + i64::try_from(validation_time_unix_ms) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?, + )?; let projection = projections .get_mut(&event.contract_id) .ok_or(RuntimeError::UnknownContract)?; diff --git a/src/runtime/requester.rs b/src/runtime/requester.rs index 5a928c8..8579cc6 100644 --- a/src/runtime/requester.rs +++ b/src/runtime/requester.rs @@ -69,7 +69,7 @@ impl RequesterService { directory_endpoint: String, artifact_endpoint: String, ) -> Result { - if identity.claims().role != NodeRole::Requester { + if identity.role() != NodeRole::Requester { return Err(RuntimeError::CredentialRoleMismatch); } Ok(Self { @@ -205,6 +205,7 @@ impl RequesterService { "/v0/routes/query", &envelope, "route.candidates.v1", + NodeRole::Directory, ) .await .map_err(|_| RuntimeError::TransportFailed)?; @@ -265,6 +266,7 @@ impl RequesterService { "/v0/contracts/propose", &envelope, "contract.sealed.v1", + NodeRole::Executor, ) .await .map_err(|_| RuntimeError::TransportFailed)?; @@ -296,6 +298,7 @@ impl RequesterService { "/v0/contracts/events/query", &envelope, "contract.projection.v1", + NodeRole::Executor, ) .await .map_err(|_| RuntimeError::TransportFailed)?; @@ -339,6 +342,7 @@ impl RequesterService { "/v0/contracts/events/append", &envelope, "contract.event.appended.v1", + NodeRole::Executor, ) .await .map_err(|_| RuntimeError::TransportFailed)?; diff --git a/src/transport/client.rs b/src/transport/client.rs index 225a10c..4a349a5 100644 --- a/src/transport/client.rs +++ b/src/transport/client.rs @@ -11,7 +11,7 @@ use ed25519_dalek::VerifyingKey; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use crate::protocol::WireEnvelope; +use crate::protocol::{DomainId, NodeRole, WireEnvelope}; use super::MAX_JSON_BODY_BYTES; @@ -43,6 +43,7 @@ struct Counters { pub struct PeerClient { client: reqwest::Client, root: VerifyingKey, + domain_id: DomainId, validation_time_unix_ms: u64, counters: Arc, } @@ -54,9 +55,14 @@ impl Debug for PeerClient { } impl PeerClient { - pub fn new(root: VerifyingKey, validation_time_unix_ms: u64) -> Result { + pub fn new( + root: VerifyingKey, + domain_id: DomainId, + validation_time_unix_ms: u64, + ) -> Result { Self::with_timeouts( root, + domain_id, validation_time_unix_ms, Duration::from_secs(2), Duration::from_secs(5), @@ -65,6 +71,7 @@ impl PeerClient { pub fn with_timeouts( root: VerifyingKey, + domain_id: DomainId, validation_time_unix_ms: u64, connect_timeout: Duration, request_timeout: Duration, @@ -77,6 +84,7 @@ impl PeerClient { Ok(Self { client, root, + domain_id, validation_time_unix_ms, counters: Arc::new(Counters::default()), }) @@ -88,6 +96,7 @@ impl PeerClient { path: &str, envelope: &WireEnvelope, expected_object_type: &str, + expected_response_role: NodeRole, ) -> Result { let url = endpoint_url(endpoint, path)?; let request_bytes = @@ -129,7 +138,10 @@ impl PeerClient { .open( expected_object_type, &self.root, - self.validation_time_unix_ms, + &self.domain_id, + expected_response_role, + i64::try_from(self.validation_time_unix_ms) + .map_err(|_| TransportError::InvalidSignedResponse)?, ) .map_err(|_| TransportError::InvalidSignedResponse) } @@ -140,11 +152,18 @@ impl PeerClient { path: &str, envelope: &WireEnvelope, expected_object_type: &str, + expected_response_role: NodeRole, ) -> Result { let mut last_error = TransportError::RequestFailed; for attempt in 0..=2 { match self - .post_signed(endpoint, path, envelope, expected_object_type) + .post_signed( + endpoint, + path, + envelope, + expected_object_type, + expected_response_role, + ) .await { Ok(value) => return Ok(value), diff --git a/src/transport/directory.rs b/src/transport/directory.rs index 2be2d16..8c3b2f3 100644 --- a/src/transport/directory.rs +++ b/src/transport/directory.rs @@ -52,10 +52,16 @@ async fn register( Ok(envelope) => envelope, Err(response) => return *response, }; + let now_ms = match i64::try_from(state.now_unix_ms) { + Ok(value) => value, + Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; let manifest: CapabilityManifest = match envelope.open( "capability.manifest.v1", state.identity.root(), - state.now_unix_ms, + state.identity.domain_id(), + crate::protocol::NodeRole::Executor, + now_ms, ) { Ok(manifest) => manifest, Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), @@ -85,11 +91,20 @@ async fn query( Ok(envelope) => envelope, Err(response) => return *response, }; - let query: RouteQuery = - match envelope.open("route.query.v1", state.identity.root(), state.now_unix_ms) { - Ok(query) => query, - Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), - }; + let now_ms = match i64::try_from(state.now_unix_ms) { + Ok(value) => value, + Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; + let query: RouteQuery = match envelope.open( + "route.query.v1", + state.identity.root(), + state.identity.domain_id(), + crate::protocol::NodeRole::Requester, + now_ms, + ) { + Ok(query) => query, + Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; let candidates = state.registry.query(query, state.now_unix_ms).await; match state.identity.seal("route.candidates.v1", &candidates) { Ok(response) => (StatusCode::OK, Json(response)).into_response(), diff --git a/src/transport/node.rs b/src/transport/node.rs index 3948b26..9e35dfb 100644 --- a/src/transport/node.rs +++ b/src/transport/node.rs @@ -39,10 +39,16 @@ async fn provider_propose( Ok(envelope) => envelope, Err(response) => return *response, }; + let now_ms = match i64::try_from(service.validation_time_unix_ms()) { + Ok(value) => value, + Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; let request: ContractProposeRequest = match envelope.open( "contract.propose.v1", service.identity().root(), - service.validation_time_unix_ms(), + service.identity().domain_id(), + crate::protocol::NodeRole::Requester, + now_ms, ) { Ok(request) => request, Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), @@ -85,10 +91,16 @@ async fn provider_query( Ok(envelope) => envelope, Err(response) => return *response, }; + let now_ms = match i64::try_from(service.validation_time_unix_ms()) { + Ok(value) => value, + Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; let query: ContractQuery = match envelope.open( "contract.query.v1", service.identity().root(), - service.validation_time_unix_ms(), + service.identity().domain_id(), + crate::protocol::NodeRole::Requester, + now_ms, ) { Ok(query) => query, Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), @@ -195,10 +207,16 @@ async fn artifact_read( Ok(envelope) => envelope, Err(response) => return *response, }; + let now_ms = match i64::try_from(state.identity.validation_time_unix_ms()) { + Ok(value) => value, + Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; let request: ArtifactReadRequest = match envelope.open( "artifact.read.v1", state.identity.root(), - state.identity.validation_time_unix_ms(), + state.identity.domain_id(), + crate::protocol::NodeRole::Requester, + now_ms, ) { Ok(request) => request, Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), diff --git a/tests/authority_protocol.rs b/tests/authority_protocol.rs new file mode 100644 index 0000000..b6b075f --- /dev/null +++ b/tests/authority_protocol.rs @@ -0,0 +1,444 @@ +use std::collections::BTreeSet; + +use agenet::protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, CredentialChain, DomainId, + NodeCredentialClaims, NodeId, NodeRole, ProtocolError, SignedAuthorityCredential, + verify_credential_chain, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; +use proptest::prelude::*; + +const NOW: i64 = 1_800_000_000; + +fn signing_key(byte: u8) -> SigningKey { + SigningKey::from_bytes(&[byte; 32]) +} + +fn domain_id() -> DomainId { + DomainId::new("domain:founding").expect("valid Domain ID") +} + +fn node_id() -> NodeId { + NodeId::new("node:provider").expect("valid Node ID") +} + +fn authority_claims(authority: &SigningKey) -> AuthorityClaims { + AuthorityClaims { + domain_id: domain_id(), + authority_id: NodeId::new("authority:online").expect("valid Authority ID"), + signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([AuthorityScope::IssueNodeCredential]), + allowed_profiles: BTreeSet::from([ + BootstrapProfile::Base, + BootstrapProfile::Provider, + BootstrapProfile::AgentCandidate, + ]), + maximum_node_lifetime_ms: 60_000, + issued_at_ms: NOW - 10_000, + expires_at_ms: NOW + 120_000, + } +} + +fn node_claims(authority_id: NodeId, node: &SigningKey) -> NodeCredentialClaims { + NodeCredentialClaims { + domain_id: domain_id(), + authority_id, + node_id: node_id(), + signing_public_key_base64: STANDARD.encode(node.verifying_key().to_bytes()), + bootstrap_profile: BootstrapProfile::Provider, + allowed_roles: BTreeSet::from([ + NodeRole::Requester, + NodeRole::Executor, + NodeRole::Verifier, + ]), + issued_at_ms: NOW - 1_000, + expires_at_ms: NOW + 30_000, + } +} + +fn valid_chain() -> (SigningKey, SigningKey, SigningKey, CredentialChain) { + let root = signing_key(1); + let authority = signing_key(2); + let node = signing_key(3); + let authority_credential = + SignedAuthorityCredential::issue(&root, authority_claims(&authority)) + .expect("Authority credential issued"); + let node_credential = authority_credential + .issue_node_credential( + &root.verifying_key(), + &authority, + node_claims(authority_credential.claims.authority_id.clone(), &node), + NOW, + ) + .expect("Node credential issued"); + ( + root, + authority, + node, + CredentialChain { + authority: authority_credential, + node: node_credential, + }, + ) +} + +#[test] +fn verifies_a_valid_root_authority_node_chain() { + let (root, _, _, chain) = valid_chain(); + + let verified = verify_credential_chain( + &root.verifying_key(), + &chain, + &domain_id(), + NodeRole::Executor, + NOW, + ) + .expect("credential chain verifies"); + + assert_eq!(verified.node_id, node_id()); + assert_eq!(verified.bootstrap_profile, BootstrapProfile::Provider); + assert!(verified.allowed_roles.contains(&NodeRole::Executor)); +} + +#[test] +fn rejects_wrong_root_and_domain_mismatch() { + let (root, _, _, chain) = valid_chain(); + let wrong_root = signing_key(9); + + assert_eq!( + verify_credential_chain( + &wrong_root.verifying_key(), + &chain, + &domain_id(), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::InvalidAuthoritySignature) + ); + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &chain, + &DomainId::new("domain:other").expect("valid Domain ID"), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::DomainMismatch) + ); +} + +#[test] +fn rejects_expired_authority_and_expired_node() { + let (root, _, _, chain) = valid_chain(); + + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &chain, + &domain_id(), + NodeRole::Executor, + NOW + 120_001, + ), + Err(ProtocolError::AuthorityCredentialExpired) + ); + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &chain, + &domain_id(), + NodeRole::Executor, + NOW + 30_001, + ), + Err(ProtocolError::CredentialExpired) + ); +} + +#[test] +fn issuance_rejects_missing_scope_and_node_issuer_mismatch() { + let root = signing_key(10); + let authority = signing_key(11); + let node = signing_key(12); + let mut claims = authority_claims(&authority); + claims.scopes.clear(); + let without_scope = SignedAuthorityCredential::issue(&root, claims).expect("signed claims"); + + assert_eq!( + without_scope.issue_node_credential( + &root.verifying_key(), + &authority, + node_claims(without_scope.claims.authority_id.clone(), &node), + NOW, + ), + Err(ProtocolError::AuthorityScopeViolation) + ); + + let authorized = SignedAuthorityCredential::issue(&root, authority_claims(&authority)) + .expect("Authority credential issued"); + let mismatched = node_claims( + NodeId::new("authority:other").expect("valid Authority ID"), + &node, + ); + assert_eq!( + authorized.issue_node_credential(&root.verifying_key(), &authority, mismatched, NOW,), + Err(ProtocolError::CredentialIssuerMismatch) + ); +} + +#[test] +fn chain_verification_rejects_missing_scope_and_node_issuer_mismatch() { + let (root, authority, node, chain) = valid_chain(); + + let mut claims_without_scope = chain.authority.claims.clone(); + claims_without_scope.scopes.clear(); + let authority_without_scope = + SignedAuthorityCredential::issue(&root, claims_without_scope).expect("signed claims"); + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &CredentialChain { + authority: authority_without_scope, + node: chain.node.clone(), + }, + &domain_id(), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::AuthorityScopeViolation) + ); + + let mut other_authority_claims = chain.authority.claims.clone(); + other_authority_claims.authority_id = + NodeId::new("authority:other").expect("valid Authority ID"); + let other_authority = SignedAuthorityCredential::issue(&root, other_authority_claims) + .expect("Authority credential issued"); + let node_from_other_issuer = other_authority + .issue_node_credential( + &root.verifying_key(), + &authority, + node_claims(other_authority.claims.authority_id.clone(), &node), + NOW, + ) + .expect("Node credential issued"); + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &CredentialChain { + authority: chain.authority, + node: node_from_other_issuer, + }, + &domain_id(), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::CredentialIssuerMismatch) + ); +} + +#[test] +fn rejects_role_mismatch_and_profile_privilege_escalation() { + let (root, _, _, chain) = valid_chain(); + + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &chain, + &domain_id(), + NodeRole::Directory, + NOW, + ), + Err(ProtocolError::CredentialRoleMismatch) + ); + + for profile in [BootstrapProfile::Base, BootstrapProfile::AgentCandidate] { + let authority = signing_key(21); + let node = signing_key(22); + let authority_credential = + SignedAuthorityCredential::issue(&root, authority_claims(&authority)) + .expect("Authority credential issued"); + let mut claims = node_claims(authority_credential.claims.authority_id.clone(), &node); + claims.bootstrap_profile = profile; + claims.allowed_roles.insert(NodeRole::Executor); + assert_eq!( + authority_credential.issue_node_credential( + &root.verifying_key(), + &authority, + claims, + NOW, + ), + Err(ProtocolError::CredentialRoleMismatch) + ); + } +} + +#[test] +fn rejects_disallowed_profile_lifetime_and_directory_invitation() { + let root = signing_key(30); + let authority = signing_key(31); + let node = signing_key(32); + let mut authority_values = authority_claims(&authority); + authority_values.allowed_profiles = BTreeSet::from([BootstrapProfile::Base]); + let authority_credential = SignedAuthorityCredential::issue(&root, authority_values) + .expect("Authority credential issued"); + + let provider = node_claims(authority_credential.claims.authority_id.clone(), &node); + assert_eq!( + authority_credential.issue_node_credential( + &root.verifying_key(), + &authority, + provider, + NOW, + ), + Err(ProtocolError::BootstrapProfileNotAllowed) + ); + + let mut too_long = node_claims(authority_credential.claims.authority_id.clone(), &node); + too_long.bootstrap_profile = BootstrapProfile::Base; + too_long.allowed_roles = BTreeSet::from([NodeRole::Requester]); + too_long.expires_at_ms = too_long.issued_at_ms + 60_001; + assert_eq!( + authority_credential.issue_node_credential( + &root.verifying_key(), + &authority, + too_long, + NOW, + ), + Err(ProtocolError::NodeCredentialLifetimeExceeded) + ); + + let mut directory = node_claims(authority_credential.claims.authority_id.clone(), &node); + directory.bootstrap_profile = BootstrapProfile::Base; + directory.allowed_roles = BTreeSet::from([NodeRole::Directory]); + assert_eq!( + authority_credential.issue_node_credential( + &root.verifying_key(), + &authority, + directory, + NOW, + ), + Err(ProtocolError::CredentialRoleMismatch) + ); +} + +#[test] +fn founding_scope_is_the_only_directory_issuance_path() { + let root = signing_key(33); + let authority = signing_key(34); + let node = signing_key(35); + let mut authority_values = authority_claims(&authority); + authority_values + .scopes + .insert(AuthorityScope::IssueFoundingDirectoryCredential); + let authority_credential = SignedAuthorityCredential::issue(&root, authority_values) + .expect("Authority credential issued"); + let mut claims = node_claims(authority_credential.claims.authority_id.clone(), &node); + claims.bootstrap_profile = BootstrapProfile::Base; + claims.allowed_roles = BTreeSet::from([NodeRole::Directory]); + + let node_credential = authority_credential + .issue_founding_directory_credential(&root.verifying_key(), &authority, claims, NOW) + .expect("founding Directory credential issued"); + let chain = CredentialChain { + authority: authority_credential, + node: node_credential, + }; + + assert!( + verify_credential_chain( + &root.verifying_key(), + &chain, + &domain_id(), + NodeRole::Directory, + NOW, + ) + .is_ok() + ); +} + +#[test] +fn tls_ca_fingerprint_tampering_invalidates_the_root_signature() { + let (root, _, _, mut chain) = valid_chain(); + chain + .authority + .claims + .tls_ca_sha256 + .replace_range(0..1, "c"); + + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &chain, + &domain_id(), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::InvalidAuthoritySignature) + ); +} + +#[test] +fn every_authority_and_node_signature_bit_is_covered() { + let (root, _, _, chain) = valid_chain(); + + for authority_bit in 0..512 { + let mut mutated = chain.clone(); + flip_signature_bit(&mut mutated.authority.root_signature_base64, authority_bit); + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &mutated, + &domain_id(), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::InvalidAuthoritySignature), + "Authority signature bit {authority_bit} was not covered" + ); + } + + for node_bit in 0..512 { + let mut mutated = chain.clone(); + flip_signature_bit(&mut mutated.node.signature_base64, node_bit); + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &mutated, + &domain_id(), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::InvalidCredentialSignature), + "Node signature bit {node_bit} was not covered" + ); + } +} + +fn flip_signature_bit(encoded: &mut String, bit: usize) { + let mut bytes = STANDARD + .decode(encoded.as_bytes()) + .expect("signature base64"); + bytes[bit / 8] ^= 1 << (bit % 8); + *encoded = STANDARD.encode(bytes); +} + +proptest! { + #[test] + fn strict_verification_rejects_each_mutated_authority_claim_byte(index in 0usize..64) { + let (root, _, _, mut chain) = valid_chain(); + let original = chain.authority.claims.tls_ca_sha256.as_bytes()[index]; + let replacement = if original == b'a' { "b" } else { "a" }; + chain.authority.claims.tls_ca_sha256.replace_range(index..index + 1, replacement); + + prop_assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &chain, + &domain_id(), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::InvalidAuthoritySignature) + ); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..1aa0f4a --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,80 @@ +use std::collections::BTreeSet; + +use agenet::protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, CredentialChain, DomainId, + NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; + +pub fn domain_id() -> DomainId { + DomainId::new("domain:test").expect("valid Domain ID") +} + +pub fn credential_chain( + root: &SigningKey, + node: &SigningKey, + node_id: &str, + role: NodeRole, + now: u64, +) -> CredentialChain { + let now_ms = i64::try_from(now).expect("test timestamp fits i64"); + let authority = SigningKey::from_bytes(&[98; 32]); + let authority_credential = SignedAuthorityCredential::issue( + root, + AuthorityClaims { + domain_id: domain_id(), + authority_id: NodeId::new("authority:test").expect("valid Authority ID"), + signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([ + AuthorityScope::IssueNodeCredential, + AuthorityScope::IssueFoundingDirectoryCredential, + ]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Base, BootstrapProfile::Provider]), + maximum_node_lifetime_ms: 61_000, + issued_at_ms: now_ms - 2_000, + expires_at_ms: now_ms + 120_000, + }, + ) + .expect("Authority credential issued"); + let (bootstrap_profile, allowed_roles) = match role { + NodeRole::Directory => ( + BootstrapProfile::Base, + BTreeSet::from([NodeRole::Directory]), + ), + NodeRole::Requester => ( + BootstrapProfile::Base, + BTreeSet::from([NodeRole::Requester]), + ), + NodeRole::Executor | NodeRole::Verifier => ( + BootstrapProfile::Provider, + BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]), + ), + }; + let claims = NodeCredentialClaims { + domain_id: domain_id(), + authority_id: authority_credential.claims.authority_id.clone(), + node_id: NodeId::new(node_id).expect("valid Node ID"), + signing_public_key_base64: STANDARD.encode(node.verifying_key().to_bytes()), + bootstrap_profile, + allowed_roles, + issued_at_ms: now_ms - 1_000, + expires_at_ms: now_ms + 60_000, + }; + let node_credential = match role { + NodeRole::Directory => authority_credential.issue_founding_directory_credential( + &root.verifying_key(), + &authority, + claims, + now_ms, + ), + NodeRole::Requester | NodeRole::Executor | NodeRole::Verifier => authority_credential + .issue_node_credential(&root.verifying_key(), &authority, claims, now_ms), + } + .expect("Node credential issued"); + CredentialChain { + authority: authority_credential, + node: node_credential, + } +} diff --git a/tests/http_artifact.rs b/tests/http_artifact.rs index 2827e54..659a23a 100644 --- a/tests/http_artifact.rs +++ b/tests/http_artifact.rs @@ -1,8 +1,10 @@ +mod common; + use agenet::{ protocol::{ AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, CapabilityId, - ContractDraft, ContractId, CredentialClaims, Grant, IntentId, NodeId, NodeRole, - SealedContract, SignedNodeCredential, + ContractDraft, ContractId, CredentialChain, Grant, IntentId, NodeId, NodeRole, + SealedContract, }, runtime::{ArtifactAccessService, ArtifactStore, NodeIdentity}, transport::artifact_router, @@ -21,28 +23,13 @@ fn key(byte: u8) -> SigningKey { SigningKey::from_bytes(&[byte; 32]) } -fn credential( - root: &SigningKey, - key: &SigningKey, - id: &str, - role: NodeRole, -) -> SignedNodeCredential { - SignedNodeCredential::issue( - root, - CredentialClaims { - node_id: NodeId::new(id).unwrap(), - public_key: key.verifying_key().to_bytes(), - role, - issued_at_unix_ms: NOW - 1, - expires_at_unix_ms: NOW + 60_000, - }, - ) - .unwrap() +fn credential(root: &SigningKey, key: &SigningKey, id: &str, role: NodeRole) -> CredentialChain { + common::credential_chain(root, key, id, role, NOW) } fn identity(root: &SigningKey, key: SigningKey, id: &str, role: NodeRole) -> NodeIdentity { let credential = credential(root, &key, id, role); - NodeIdentity::new(key, credential, root.verifying_key(), NOW).unwrap() + NodeIdentity::new(key, credential, role, root.verifying_key(), NOW).unwrap() } #[tokio::test] diff --git a/tests/http_client.rs b/tests/http_client.rs index aae9b45..b7bfee4 100644 --- a/tests/http_client.rs +++ b/tests/http_client.rs @@ -1,7 +1,9 @@ +mod common; + use std::time::Duration; use agenet::{ - protocol::{CredentialClaims, NodeId, NodeRole, SignedNodeCredential, WireEnvelope}, + protocol::{NodeRole, WireEnvelope}, transport::{MAX_JSON_BODY_BYTES, PeerClient, TransportError}, }; use axum::{Router, http::StatusCode, response::IntoResponse, routing::post}; @@ -44,20 +46,12 @@ async fn peer_client_maps_non_success_oversize_and_timeout_without_urls() { }); let root = SigningKey::from_bytes(&[70; 32]); let node = SigningKey::from_bytes(&[71; 32]); - let credential = SignedNodeCredential::issue( - &root, - CredentialClaims { - node_id: NodeId::new("node:requester").unwrap(), - public_key: node.verifying_key().to_bytes(), - role: NodeRole::Requester, - issued_at_unix_ms: NOW - 1, - expires_at_unix_ms: NOW + 1, - }, - ) - .unwrap(); + let credential = + common::credential_chain(&root, &node, "node:requester", NodeRole::Requester, NOW); let envelope = WireEnvelope::seal("test.v1", &json!({}), &node, credential).unwrap(); let client = PeerClient::with_timeouts( root.verifying_key(), + common::domain_id(), NOW, Duration::from_millis(100), Duration::from_millis(100), @@ -65,19 +59,37 @@ async fn peer_client_maps_non_success_oversize_and_timeout_without_urls() { .unwrap(); let non_success = client - .post_signed::(&endpoint, "/unavailable", &envelope, "response.v1") + .post_signed::( + &endpoint, + "/unavailable", + &envelope, + "response.v1", + NodeRole::Requester, + ) .await .unwrap_err(); assert_eq!(non_success, TransportError::NonSuccessStatus(503)); let oversized = client - .post_signed::(&endpoint, "/oversized", &envelope, "response.v1") + .post_signed::( + &endpoint, + "/oversized", + &envelope, + "response.v1", + NodeRole::Requester, + ) .await .unwrap_err(); assert_eq!(oversized, TransportError::ResponseTooLarge); let timeout = client - .post_signed::(&endpoint, "/slow", &envelope, "response.v1") + .post_signed::( + &endpoint, + "/slow", + &envelope, + "response.v1", + NodeRole::Requester, + ) .await .unwrap_err(); assert_eq!(timeout, TransportError::RequestFailed); diff --git a/tests/http_directory.rs b/tests/http_directory.rs index e14e19c..f5e4713 100644 --- a/tests/http_directory.rs +++ b/tests/http_directory.rs @@ -1,7 +1,9 @@ +mod common; + use agenet::{ protocol::{ - CandidateSet, CapabilityId, CapabilityManifest, CredentialClaims, NodeId, NodeRole, - RouteQuery, SideEffectProfile, SignedNodeCredential, WireEnvelope, + CandidateSet, CapabilityId, CapabilityManifest, CredentialChain, NodeRole, RouteQuery, + SideEffectProfile, WireEnvelope, }, runtime::{DirectoryRegistry, NodeIdentity}, transport::{MAX_JSON_BODY_BYTES, directory_router}, @@ -25,23 +27,13 @@ fn credential( node: &SigningKey, node_id: &str, role: NodeRole, -) -> SignedNodeCredential { - SignedNodeCredential::issue( - root, - CredentialClaims { - node_id: NodeId::new(node_id).unwrap(), - public_key: node.verifying_key().to_bytes(), - role, - issued_at_unix_ms: NOW - 1, - expires_at_unix_ms: NOW + 60_000, - }, - ) - .unwrap() +) -> CredentialChain { + common::credential_chain(root, node, node_id, role, NOW) } fn identity(root: &SigningKey, node: SigningKey, node_id: &str, role: NodeRole) -> NodeIdentity { let credential = credential(root, &node, node_id, role); - NodeIdentity::new(node, credential, root.verifying_key(), NOW).unwrap() + NodeIdentity::new(node, credential, role, root.verifying_key(), NOW).unwrap() } #[tokio::test] @@ -114,7 +106,13 @@ async fn signed_manifest_registration_and_deterministic_query_round_trip() { .to_bytes(); let envelope: WireEnvelope = serde_json::from_slice(&bytes).unwrap(); let candidates: CandidateSet = envelope - .open("route.candidates.v1", &root.verifying_key(), NOW) + .open( + "route.candidates.v1", + &root.verifying_key(), + &common::domain_id(), + NodeRole::Directory, + NOW as i64, + ) .unwrap(); assert_eq!(candidates.candidates, vec![manifest]); } diff --git a/tests/protocol_kernel.rs b/tests/protocol_kernel.rs index da002fa..c73e06a 100644 --- a/tests/protocol_kernel.rs +++ b/tests/protocol_kernel.rs @@ -1,9 +1,11 @@ +use std::collections::BTreeSet; + use agenet::protocol::{ AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, CandidateSet, CapabilityId, CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractOffer, - ContractProjection, ContractState, CredentialClaims, ErrorEnvelope, EventKind, EvidenceClaim, - Grant, IntentId, IntentProjection, NodeId, NodeRole, ProtocolError, RouteQuery, SealedContract, - SideEffectProfile, SignedNodeCredential, SourceMetrics, WireEnvelope, apply_event, event_hash, + ContractProjection, ContractState, CredentialChain, DomainId, ErrorEnvelope, EventKind, + EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, NodeRole, ProtocolError, RouteQuery, + SealedContract, SideEffectProfile, SourceMetrics, WireEnvelope, apply_event, event_hash, }; use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::SigningKey; @@ -21,47 +23,84 @@ fn signing_key(byte: u8) -> SigningKey { SigningKey::from_bytes(&[byte; 32]) } -fn credential( +fn credential_chain( root: &SigningKey, node: &SigningKey, node_id: &str, role: NodeRole, -) -> SignedNodeCredential { - SignedNodeCredential::issue( +) -> CredentialChain { + use agenet::protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, NodeCredentialClaims, + SignedAuthorityCredential, + }; + + let authority = signing_key(99); + let domain_id = DomainId::new("domain:test").expect("valid Domain ID"); + let authority_credential = SignedAuthorityCredential::issue( root, - CredentialClaims { - node_id: NodeId::new(node_id).expect("valid node id"), - public_key: node.verifying_key().to_bytes(), - role, - issued_at_unix_ms: NOW - 1_000, - expires_at_unix_ms: NOW + 60_000, + AuthorityClaims { + domain_id: domain_id.clone(), + authority_id: NodeId::new("authority:test").expect("valid Authority ID"), + signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([AuthorityScope::IssueNodeCredential]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Base, BootstrapProfile::Provider]), + maximum_node_lifetime_ms: 61_000, + issued_at_ms: NOW as i64 - 2_000, + expires_at_ms: NOW as i64 + 120_000, }, ) - .expect("credential issued") + .expect("Authority credential issued"); + let bootstrap_profile = match role { + NodeRole::Directory => panic!("Directory credentials require the founding Domain path"), + NodeRole::Requester => BootstrapProfile::Base, + NodeRole::Executor | NodeRole::Verifier => BootstrapProfile::Provider, + }; + let allowed_roles = match bootstrap_profile { + BootstrapProfile::Base | BootstrapProfile::AgentCandidate => { + BTreeSet::from([NodeRole::Requester]) + } + BootstrapProfile::Provider => { + BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]) + } + }; + let node_credential = authority_credential + .issue_node_credential( + &root.verifying_key(), + &authority, + NodeCredentialClaims { + domain_id, + authority_id: authority_credential.claims.authority_id.clone(), + node_id: NodeId::new(node_id).expect("valid node id"), + signing_public_key_base64: STANDARD.encode(node.verifying_key().to_bytes()), + bootstrap_profile, + allowed_roles, + issued_at_ms: NOW as i64 - 1_000, + expires_at_ms: NOW as i64 + 60_000, + }, + NOW as i64, + ) + .expect("Node credential issued"); + CredentialChain { + authority: authority_credential, + node: node_credential, + } } #[test] -fn credential_rejects_expiry_and_wrong_root() { - let root = signing_key(1); - let other_root = signing_key(2); - let node = signing_key(3); - let credential = credential(&root, &node, "node:executor", NodeRole::Executor); +fn legacy_v1_credential_shape_returns_stable_migration_error() { + let legacy = br#"{ + "kernel_version":"agenet.kernel.v0.1", + "object_type":"test.payload.v1", + "issuer_id":"node:legacy", + "credential":{"claims_base64":"e30=","signature_base64":"AA=="}, + "payload_base64":"e30=", + "signature_base64":"AA==" + }"#; assert_eq!( - credential - .verify(&root.verifying_key(), NOW) - .expect("valid credential") - .node_id - .as_str(), - "node:executor" - ); - assert_eq!( - credential.verify(&root.verifying_key(), NOW + 60_001), - Err(ProtocolError::CredentialExpired) - ); - assert_eq!( - credential.verify(&other_root.verifying_key(), NOW), - Err(ProtocolError::InvalidCredentialSignature) + WireEnvelope::parse(legacy), + Err(ProtocolError::MigrationRequiredV1Credential) ); } @@ -69,7 +108,7 @@ fn credential_rejects_expiry_and_wrong_root() { fn envelope_binds_exact_payload_type_and_issuer() { let root = signing_key(4); let node = signing_key(5); - let credential = credential(&root, &node, "node:requester", NodeRole::Requester); + let credential = credential_chain(&root, &node, "node:requester", NodeRole::Requester); let payload = TestPayload { value: "preserve exact bytes".to_owned(), }; @@ -78,7 +117,13 @@ fn envelope_binds_exact_payload_type_and_issuer() { assert_eq!( envelope - .open::("test.payload.v1", &root.verifying_key(), NOW,) + .open::( + "test.payload.v1", + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NodeRole::Requester, + NOW as i64, + ) .expect("envelope verified"), payload ); @@ -87,7 +132,13 @@ fn envelope_binds_exact_payload_type_and_issuer() { tampered_payload.payload_base64.push('A'); assert!( tampered_payload - .open::("test.payload.v1", &root.verifying_key(), NOW) + .open::( + "test.payload.v1", + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NodeRole::Requester, + NOW as i64, + ) .is_err() ); @@ -95,7 +146,13 @@ fn envelope_binds_exact_payload_type_and_issuer() { tampered_type.object_type = "test.other.v1".to_owned(); assert!( tampered_type - .open::("test.other.v1", &root.verifying_key(), NOW) + .open::( + "test.other.v1", + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NodeRole::Requester, + NOW as i64, + ) .is_err() ); @@ -103,40 +160,86 @@ fn envelope_binds_exact_payload_type_and_issuer() { tampered_issuer.issuer_id = NodeId::new("node:someone-else").expect("valid node id"); assert!( tampered_issuer - .open::("test.payload.v1", &root.verifying_key(), NOW) + .open::( + "test.payload.v1", + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NodeRole::Requester, + NOW as i64, + ) .is_err() ); } #[test] -fn strict_verification_rejects_a_weak_public_key() { +fn envelope_validates_the_chain_before_decoding_business_payload() { let root = signing_key(6); - let weak_credential = SignedNodeCredential::issue( - &root, - CredentialClaims { - node_id: NodeId::new("node:weak").unwrap(), - public_key: [0; 32], - role: NodeRole::Executor, - issued_at_unix_ms: NOW - 1, - expires_at_unix_ms: NOW + 1, - }, - ) - .unwrap(); - let envelope = WireEnvelope { - kernel_version: "agenet-kernel-v0.1".to_owned(), - object_type: "test.payload.v1".to_owned(), - issuer_id: NodeId::new("node:weak").unwrap(), - credential: weak_credential, - payload_base64: STANDARD.encode(br#"{"value":"x"}"#), - signature_base64: STANDARD.encode([0; 64]), + let node = signing_key(7); + let payload = TestPayload { + value: "chain first".to_owned(), }; + let mut envelope = WireEnvelope::seal( + "test.payload.v1", + &payload, + &node, + credential_chain(&root, &node, "node:requester", NodeRole::Requester), + ) + .expect("envelope sealed"); + envelope.payload_base64 = "not-base64".to_owned(); + envelope + .credential_chain + .authority + .claims + .tls_ca_sha256 + .replace_range(0..1, "c"); assert_eq!( - envelope.open::("test.payload.v1", &root.verifying_key(), NOW), - Err(ProtocolError::InvalidEnvelopeSignature) + envelope.open::( + "test.payload.v1", + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NodeRole::Requester, + NOW as i64, + ), + Err(ProtocolError::InvalidAuthoritySignature) ); } +#[test] +fn every_envelope_signature_bit_is_covered() { + let root = signing_key(8); + let node = signing_key(9); + let envelope = WireEnvelope::seal( + "test.payload.v1", + &TestPayload { + value: "signature bits".to_owned(), + }, + &node, + credential_chain(&root, &node, "node:requester", NodeRole::Requester), + ) + .expect("envelope sealed"); + + for bit in 0..512 { + let mut mutated = envelope.clone(); + let mut signature = STANDARD + .decode(&mutated.signature_base64) + .expect("signature base64"); + signature[bit / 8] ^= 1 << (bit % 8); + mutated.signature_base64 = STANDARD.encode(signature); + assert_eq!( + mutated.open::( + "test.payload.v1", + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NodeRole::Requester, + NOW as i64, + ), + Err(ProtocolError::InvalidEnvelopeSignature), + "Envelope signature bit {bit} was not covered" + ); + } +} + #[test] fn grant_enforces_subject_capability_artifact_and_expiry() { let grant = Grant { @@ -314,8 +417,10 @@ fn bilateral_contract_signatures_cover_identical_draft_bytes() { let root = signing_key(20); let requester = signing_key(21); let executor = signing_key(22); - let requester_credential = credential(&root, &requester, "node:requester", NodeRole::Requester); - let executor_credential = credential(&root, &executor, "node:executor", NodeRole::Executor); + let requester_credential = + credential_chain(&root, &requester, "node:requester", NodeRole::Requester); + let executor_credential = + credential_chain(&root, &executor, "node:executor", NodeRole::Executor); let contract = SealedContract::seal( &draft(), @@ -345,7 +450,7 @@ fn requester_offer_is_countersigned_by_the_provider() { let offer = ContractOffer::create( &draft(), &requester, - credential(&root, &requester, "node:requester", NodeRole::Requester), + credential_chain(&root, &requester, "node:requester", NodeRole::Requester), ) .unwrap(); assert_eq!(offer.verify(&root.verifying_key(), NOW).unwrap(), draft()); @@ -353,7 +458,7 @@ fn requester_offer_is_countersigned_by_the_provider() { let sealed = offer .countersign( &executor, - credential(&root, &executor, "node:executor", NodeRole::Executor), + credential_chain(&root, &executor, "node:executor", NodeRole::Executor), ) .unwrap(); assert_eq!(sealed.verify(&root.verifying_key(), NOW).unwrap(), draft()); @@ -463,7 +568,7 @@ proptest! { fn any_payload_bit_flip_breaks_the_envelope_signature(byte_index in 0usize..32, bit in 0u8..8) { let root = signing_key(30); let node = signing_key(31); - let credential = credential(&root, &node, "node:requester", NodeRole::Requester); + let credential = credential_chain(&root, &node, "node:requester", NodeRole::Requester); let payload = [7_u8; 32]; let mut envelope = WireEnvelope::seal("test.bytes.v1", &payload, &node, credential).unwrap(); let mut bytes = STANDARD.decode(&envelope.payload_base64).unwrap(); @@ -471,7 +576,13 @@ proptest! { envelope.payload_base64 = STANDARD.encode(bytes); prop_assert!(envelope - .open::<[u8; 32]>("test.bytes.v1", &root.verifying_key(), NOW) + .open::<[u8; 32]>( + "test.bytes.v1", + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NodeRole::Requester, + NOW as i64, + ) .is_err()); } diff --git a/tests/runtime_storage.rs b/tests/runtime_storage.rs index 10eeb66..ae2002a 100644 --- a/tests/runtime_storage.rs +++ b/tests/runtime_storage.rs @@ -1,10 +1,12 @@ +mod common; + use std::{fs, os::unix::fs::PermissionsExt}; use agenet::{ protocol::{ AcceptanceProfile, ArtifactId, ArtifactRef, CapabilityId, ContractDraft, ContractEvent, - ContractId, ContractState, CredentialClaims, EventKind, Grant, IntentId, NodeId, NodeRole, - SealedContract, SignedNodeCredential, WireEnvelope, event_hash, + ContractId, ContractState, CredentialChain, EventKind, Grant, IntentId, NodeId, NodeRole, + SealedContract, WireEnvelope, event_hash, }, runtime::{ArtifactStore, ContractRecorder, RuntimeError, read_signing_key, write_signing_key}, }; @@ -22,18 +24,8 @@ fn credential( node: &SigningKey, node_id: &str, role: NodeRole, -) -> SignedNodeCredential { - SignedNodeCredential::issue( - root, - CredentialClaims { - node_id: NodeId::new(node_id).unwrap(), - public_key: node.verifying_key().to_bytes(), - role, - issued_at_unix_ms: NOW - 1_000, - expires_at_unix_ms: NOW + 60_000, - }, - ) - .unwrap() +) -> CredentialChain { + common::credential_chain(root, node, node_id, role, NOW) } #[test] @@ -150,9 +142,10 @@ async fn journal_replays_projection_and_deduplicates_operations() { let root = signing_key(10); let requester = signing_key(11); let executor = signing_key(12); - let recorder = ContractRecorder::open(temp.path(), root.verifying_key(), NOW) - .await - .unwrap(); + let recorder = + ContractRecorder::open(temp.path(), root.verifying_key(), common::domain_id(), NOW) + .await + .unwrap(); recorder .register_contract(contract(&root, &requester, &executor)) .await @@ -214,9 +207,10 @@ async fn journal_replays_projection_and_deduplicates_operations() { ); drop(recorder); - let replayed = ContractRecorder::open(temp.path(), root.verifying_key(), NOW) - .await - .unwrap(); + let replayed = + ContractRecorder::open(temp.path(), root.verifying_key(), common::domain_id(), NOW) + .await + .unwrap(); let projection = replayed .projection(&ContractId::new("contract:source").unwrap()) .await From 1501cfbe261bdf9d11ed2092bac33211dd2f4374 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 15:17:56 +0800 Subject: [PATCH 10/67] [bug] Fix contract Domain validation Root cause: Contract verification used the credential's Domain as its own expected value. Solution: Require the configured Domain for offers, sealed contracts, recorders, artifact access, providers, and requester responses. Risks: This is a source-breaking verification API change for v0.2. Dependency: Bootstrap step 2. Links: task-2-report.md Post-mortem: Add negative trust-boundary tests for expected values. --- src/node.rs | 7 +- src/protocol/sealed_contract.rs | 9 +- src/runtime/artifact_access.rs | 17 +++- src/runtime/provider.rs | 8 +- src/runtime/recorder.rs | 4 +- src/runtime/requester.rs | 4 +- tests/http_artifact.rs | 2 +- tests/protocol_kernel.rs | 140 ++++++++++++++++++++++++++++++-- 8 files changed, 172 insertions(+), 19 deletions(-) diff --git a/src/node.rs b/src/node.rs index 827cf01..3d1a837 100644 --- a/src/node.rs +++ b/src/node.rs @@ -118,7 +118,12 @@ pub async fn run(options: NodeOptions) -> Result { .to_owned(); let store = ArtifactStore::open(&options.state_dir, identity.node_id().clone()) .map_err(sanitized)?; - let access = ArtifactAccessService::new(store.clone(), *identity.root(), now); + let access = ArtifactAccessService::new( + store.clone(), + *identity.root(), + identity.domain_id().clone(), + now, + ); let client = PeerClient::new(*identity.root(), identity.domain_id().clone(), now) .map_err(sanitized)?; let decision = LlmDecisionAdapter::new_modelhub( diff --git a/src/protocol/sealed_contract.rs b/src/protocol/sealed_contract.rs index 174c4d2..1d3938a 100644 --- a/src/protocol/sealed_contract.rs +++ b/src/protocol/sealed_contract.rs @@ -3,7 +3,7 @@ use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; use super::{ - ContractDraft, CredentialChain, NodeRole, ProtocolError, VerifiedNodeClaims, + ContractDraft, CredentialChain, DomainId, NodeRole, ProtocolError, VerifiedNodeClaims, verify_credential_chain, }; @@ -50,6 +50,7 @@ impl ContractOffer { pub fn verify( &self, root: &VerifyingKey, + expected_domain: &DomainId, now_unix_ms: u64, ) -> Result { let draft_bytes = decode_draft_bytes(&self.draft_payload_base64)?; @@ -59,6 +60,7 @@ impl ContractOffer { &draft_bytes, &self.requester_signature, root, + expected_domain, NodeRole::Requester, now_unix_ms, )?; @@ -104,6 +106,7 @@ impl SealedContract { pub fn verify( &self, root: &VerifyingKey, + expected_domain: &DomainId, now_unix_ms: u64, ) -> Result { let draft_bytes = decode_draft_bytes(&self.draft_payload_base64)?; @@ -113,6 +116,7 @@ impl SealedContract { &draft_bytes, &self.requester_signature, root, + expected_domain, NodeRole::Requester, now_unix_ms, )?; @@ -120,6 +124,7 @@ impl SealedContract { &draft_bytes, &self.provider_signature, root, + expected_domain, NodeRole::Executor, now_unix_ms, )?; @@ -157,11 +162,11 @@ fn verify_party( draft_bytes: &[u8], party: &PartySignature, root: &VerifyingKey, + expected_domain: &DomainId, expected_role: NodeRole, now_unix_ms: u64, ) -> Result { let now_ms = i64::try_from(now_unix_ms).map_err(|_| ProtocolError::CredentialExpired)?; - let expected_domain = &party.credential_chain.authority.claims.domain_id; let claims = verify_credential_chain( root, &party.credential_chain, diff --git a/src/runtime/artifact_access.rs b/src/runtime/artifact_access.rs index ec5e958..f858f1d 100644 --- a/src/runtime/artifact_access.rs +++ b/src/runtime/artifact_access.rs @@ -3,7 +3,9 @@ use std::{collections::HashMap, sync::Arc}; use ed25519_dalek::VerifyingKey; use tokio::sync::RwLock; -use crate::protocol::{ArtifactPayload, ArtifactReadRequest, ContractId, NodeId, SealedContract}; +use crate::protocol::{ + ArtifactPayload, ArtifactReadRequest, ContractId, DomainId, NodeId, SealedContract, +}; use super::{ArtifactStore, RuntimeError}; @@ -11,22 +13,29 @@ use super::{ArtifactStore, RuntimeError}; pub struct ArtifactAccessService { store: ArtifactStore, root: VerifyingKey, + domain_id: DomainId, validation_time_unix_ms: u64, contracts: Arc>>, } impl ArtifactAccessService { - pub fn new(store: ArtifactStore, root: VerifyingKey, validation_time_unix_ms: u64) -> Self { + pub fn new( + store: ArtifactStore, + root: VerifyingKey, + domain_id: DomainId, + validation_time_unix_ms: u64, + ) -> Self { Self { store, root, + domain_id, validation_time_unix_ms, contracts: Arc::new(RwLock::new(HashMap::new())), } } pub async fn authorize(&self, contract: SealedContract) -> Result<(), RuntimeError> { - let draft = contract.verify(&self.root, self.validation_time_unix_ms)?; + let draft = contract.verify(&self.root, &self.domain_id, self.validation_time_unix_ms)?; self.contracts .write() .await @@ -43,7 +52,7 @@ impl ArtifactAccessService { let contract = contracts .get(&request.contract_id) .ok_or(RuntimeError::ArtifactAccessDenied)?; - let draft = contract.verify(&self.root, self.validation_time_unix_ms)?; + let draft = contract.verify(&self.root, &self.domain_id, self.validation_time_unix_ms)?; draft.grant.allows( caller, &draft.capability_id, diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs index 2a11ee6..478d57e 100644 --- a/src/runtime/provider.rs +++ b/src/runtime/provider.rs @@ -56,9 +56,11 @@ impl ProviderService { issuer: &crate::protocol::NodeId, request: ContractProposeRequest, ) -> Result { - let draft = request - .offer - .verify(self.identity.root(), self.now_unix_ms)?; + let draft = request.offer.verify( + self.identity.root(), + self.identity.domain_id(), + self.now_unix_ms, + )?; if issuer != &draft.requester || &draft.provider != self.identity.node_id() { return Err(RuntimeError::ArtifactAccessDenied); } diff --git a/src/runtime/recorder.rs b/src/runtime/recorder.rs index 2a25251..503edb9 100644 --- a/src/runtime/recorder.rs +++ b/src/runtime/recorder.rs @@ -80,7 +80,7 @@ impl ContractRecorder { &self, contract: SealedContract, ) -> Result { - let draft = contract.verify(&self.root, self.validation_time_unix_ms)?; + let draft = contract.verify(&self.root, &self.domain_id, self.validation_time_unix_ms)?; let mut state = self.state.lock().await; if let Some(existing) = state.projections.get(&draft.contract_id) { if existing.draft == draft { @@ -159,7 +159,7 @@ fn replay_entry( ) -> Result<(), RuntimeError> { match entry { JournalEntry::Contract { contract } => { - let draft = contract.verify(root, validation_time_unix_ms)?; + let draft = contract.verify(root, domain_id, validation_time_unix_ms)?; if projections.contains_key(&draft.contract_id) { return Err(RuntimeError::ContractAlreadyExists); } diff --git a/src/runtime/requester.rs b/src/runtime/requester.rs index 8579cc6..ef49178 100644 --- a/src/runtime/requester.rs +++ b/src/runtime/requester.rs @@ -270,7 +270,9 @@ impl RequesterService { ) .await .map_err(|_| RuntimeError::TransportFailed)?; - response.contract.verify(self.identity.root(), unix_ms())?; + response + .contract + .verify(self.identity.root(), self.identity.domain_id(), unix_ms())?; self.access.authorize(response.contract).await?; let projection = self .poll_delivered(&manifest.endpoint, &contract_id) diff --git a/tests/http_artifact.rs b/tests/http_artifact.rs index 659a23a..61ac519 100644 --- a/tests/http_artifact.rs +++ b/tests/http_artifact.rs @@ -54,7 +54,7 @@ async fn artifact_endpoint_requires_the_exact_contract_caller_and_hash() { let intruder = identity(&root, intruder_key, "node:intruder", NodeRole::Verifier); let store = ArtifactStore::open(temp.path(), requester.node_id().clone()).unwrap(); let artifact = store.import(b"fn main() {}\n", "text/x-rust").unwrap(); - let access = ArtifactAccessService::new(store, root.verifying_key(), NOW); + let access = ArtifactAccessService::new(store, root.verifying_key(), common::domain_id(), NOW); let contract_id = ContractId::new("contract:source").unwrap(); let capability_id = CapabilityId::new("capability:source-metrics").unwrap(); let contract = SealedContract::seal( diff --git a/tests/protocol_kernel.rs b/tests/protocol_kernel.rs index c73e06a..6aec1dd 100644 --- a/tests/protocol_kernel.rs +++ b/tests/protocol_kernel.rs @@ -28,6 +28,16 @@ fn credential_chain( node: &SigningKey, node_id: &str, role: NodeRole, +) -> CredentialChain { + credential_chain_for_domain(root, node, node_id, role, "domain:test") +} + +fn credential_chain_for_domain( + root: &SigningKey, + node: &SigningKey, + node_id: &str, + role: NodeRole, + domain: &str, ) -> CredentialChain { use agenet::protocol::{ AuthorityClaims, AuthorityScope, BootstrapProfile, NodeCredentialClaims, @@ -35,7 +45,7 @@ fn credential_chain( }; let authority = signing_key(99); - let domain_id = DomainId::new("domain:test").expect("valid Domain ID"); + let domain_id = DomainId::new(domain).expect("valid Domain ID"); let authority_credential = SignedAuthorityCredential::issue( root, AuthorityClaims { @@ -432,14 +442,26 @@ fn bilateral_contract_signatures_cover_identical_draft_bytes() { .expect("contract sealed"); assert_eq!( contract - .verify(&root.verifying_key(), NOW) + .verify( + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NOW, + ) .expect("bilateral signatures valid"), draft() ); let mut tampered = contract; tampered.draft_payload_base64.push('A'); - assert!(tampered.verify(&root.verifying_key(), NOW).is_err()); + assert!( + tampered + .verify( + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NOW, + ) + .is_err() + ); } #[test] @@ -453,7 +475,16 @@ fn requester_offer_is_countersigned_by_the_provider() { credential_chain(&root, &requester, "node:requester", NodeRole::Requester), ) .unwrap(); - assert_eq!(offer.verify(&root.verifying_key(), NOW).unwrap(), draft()); + assert_eq!( + offer + .verify( + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NOW, + ) + .unwrap(), + draft() + ); let sealed = offer .countersign( @@ -461,7 +492,106 @@ fn requester_offer_is_countersigned_by_the_provider() { credential_chain(&root, &executor, "node:executor", NodeRole::Executor), ) .unwrap(); - assert_eq!(sealed.verify(&root.verifying_key(), NOW).unwrap(), draft()); + assert_eq!( + sealed + .verify( + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NOW, + ) + .unwrap(), + draft() + ); +} + +#[test] +fn contract_offer_rejects_a_requester_chain_from_the_wrong_domain() { + let root = signing_key(26); + let requester = signing_key(27); + let offer = ContractOffer::create( + &draft(), + &requester, + credential_chain_for_domain( + &root, + &requester, + "node:requester", + NodeRole::Requester, + "domain:other", + ), + ) + .expect("contract offer created"); + + assert_eq!( + offer.verify( + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NOW, + ), + Err(ProtocolError::DomainMismatch) + ); +} + +#[test] +fn sealed_contract_rejects_a_provider_chain_from_the_wrong_domain() { + let root = signing_key(28); + let requester = signing_key(29); + let provider = signing_key(30); + let contract = SealedContract::seal( + &draft(), + &requester, + credential_chain(&root, &requester, "node:requester", NodeRole::Requester), + &provider, + credential_chain_for_domain( + &root, + &provider, + "node:executor", + NodeRole::Executor, + "domain:other", + ), + ) + .expect("contract sealed"); + + assert_eq!( + contract.verify( + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NOW, + ), + Err(ProtocolError::DomainMismatch) + ); +} + +#[test] +fn sealed_contract_rejects_parties_from_different_domains() { + let root = signing_key(32); + let requester = signing_key(33); + let provider = signing_key(34); + let requester_domain = DomainId::new("domain:requester").unwrap(); + let contract = SealedContract::seal( + &draft(), + &requester, + credential_chain_for_domain( + &root, + &requester, + "node:requester", + NodeRole::Requester, + requester_domain.as_str(), + ), + &provider, + credential_chain_for_domain( + &root, + &provider, + "node:executor", + NodeRole::Executor, + "domain:provider", + ), + ) + .expect("contract sealed"); + + assert_eq!( + contract.verify(&root.verifying_key(), &requester_domain, NOW), + Err(ProtocolError::DomainMismatch) + ); } #[test] From 1039cc69160b57c9eb2d82f939d5b1ba7a57219c Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 15:39:14 +0800 Subject: [PATCH 11/67] [feat][Bootstrap][3/14] Enforce network boundary Root cause: NA Solution: Validate exact private-overlay listeners and peers before creating sockets or HTTP requests. Risks: Overlay interface discovery can differ across OS releases. Dependency: Bootstrap step 2. Links: plan/01-v1-multi-host-node-bootstrap.md --- src/bootstrap/mod.rs | 2 + src/bootstrap/network.rs | 158 ++++++++++++++++ src/main.rs | 3 +- src/node.rs | 48 +++-- src/protocol/error.rs | 1 + src/transport/client.rs | 66 +++++-- tests/network_boundary.rs | 381 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 634 insertions(+), 25 deletions(-) create mode 100644 src/bootstrap/network.rs create mode 100644 tests/network_boundary.rs diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 7c300a6..96d3a5f 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -1 +1,3 @@ //! Bootstrap orchestration boundary for the v0.2 multi-host preview. + +pub mod network; diff --git a/src/bootstrap/network.rs b/src/bootstrap/network.rs new file mode 100644 index 0000000..98d4587 --- /dev/null +++ b/src/bootstrap/network.rs @@ -0,0 +1,158 @@ +use std::{ + net::{IpAddr, Ipv4Addr}, + process::{Command, Stdio}, +}; + +use ipnet::IpNet; + +use crate::{protocol::ProtocolError, runtime::RuntimeError}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverlayKind { + Loopback, + Tailscale, + WireGuard, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NetworkBoundary { + pub kind: OverlayKind, + pub bind_ip: IpAddr, + pub allowed_cidrs: Vec, +} + +pub trait AssignedAddressVerifier { + fn is_assigned(&self, address: IpAddr) -> Result; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct TailscaleAddressVerifier; + +impl AssignedAddressVerifier for TailscaleAddressVerifier { + fn is_assigned(&self, address: IpAddr) -> Result { + let status = Command::new("tailscale") + .arg("ip") + .arg(format!("--assert={address}")) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|_| unsupported_boundary())?; + Ok(status.success()) + } +} + +impl NetworkBoundary { + pub fn loopback_ipv4() -> Self { + Self { + kind: OverlayKind::Loopback, + bind_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + allowed_cidrs: vec![IpNet::from(IpAddr::V4(Ipv4Addr::LOCALHOST))], + } + } + + pub fn validate_bind(&self) -> Result<(), RuntimeError> { + self.validate_bind_with(&TailscaleAddressVerifier) + } + + pub fn validate_bind_with( + &self, + verifier: &dyn AssignedAddressVerifier, + ) -> Result<(), RuntimeError> { + if (self.kind == OverlayKind::WireGuard && self.allowed_cidrs.is_empty()) + || !address_matches_overlay(self.kind, self.bind_ip) + || !self.contains(self.bind_ip) + { + return Err(unsupported_boundary()); + } + if self.kind == OverlayKind::Tailscale && !verifier.is_assigned(self.bind_ip)? { + return Err(unsupported_boundary()); + } + Ok(()) + } + + pub fn allows_peer(&self, peer: IpAddr) -> bool { + (self.kind != OverlayKind::WireGuard || !self.allowed_cidrs.is_empty()) + && address_matches_overlay(self.kind, peer) + && self.contains(peer) + } + + pub fn validate_peer_endpoint(&self, endpoint: &str) -> Result<(), RuntimeError> { + let url = reqwest::Url::parse(endpoint).map_err(|_| unsupported_boundary())?; + if authority_contains_userinfo(endpoint) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || url.query().is_some() + || !matches!(url.path(), "" | "/") + { + return Err(unsupported_boundary()); + } + let address = url + .host_str() + .ok_or_else(unsupported_boundary)? + .trim_matches(['[', ']']) + .parse::() + .map_err(|_| unsupported_boundary())?; + let valid_scheme = if address.is_loopback() { + matches!(url.scheme(), "http" | "https") + } else { + url.scheme() == "https" + }; + if !valid_scheme || !self.allows_peer(address) { + return Err(unsupported_boundary()); + } + Ok(()) + } + + fn contains(&self, address: IpAddr) -> bool { + self.allowed_cidrs.is_empty() + || self + .allowed_cidrs + .iter() + .any(|network| network.contains(&address)) + } +} + +fn authority_contains_userinfo(endpoint: &str) -> bool { + endpoint + .split_once("://") + .and_then(|(_, remainder)| remainder.split(['/', '?', '#']).next()) + .is_some_and(|authority| authority.contains('@')) +} + +fn address_matches_overlay(kind: OverlayKind, address: IpAddr) -> bool { + match kind { + OverlayKind::Loopback => address.is_loopback(), + OverlayKind::Tailscale => is_tailscale_address(address), + OverlayKind::WireGuard => is_private_unicast(address), + } +} + +fn is_tailscale_address(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + let octets = address.octets(); + octets[0] == 100 && (64..=127).contains(&octets[1]) + } + IpAddr::V6(address) => { + let segments = address.segments(); + segments[0] == 0xfd7a && segments[1] == 0x115c && segments[2] == 0xa1e0 + } + } +} + +fn is_private_unicast(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + let octets = address.octets(); + (octets[0] == 10) + || (octets[0] == 172 && (16..=31).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 168) + } + IpAddr::V6(address) => address.segments()[0] & 0xfe00 == 0xfc00, + } +} + +fn unsupported_boundary() -> RuntimeError { + RuntimeError::Protocol(ProtocolError::UnsupportedNetworkBoundary) +} diff --git a/src/main.rs b/src/main.rs index f8d133e..9030f26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use agenet::{demo, node}; +use agenet::{bootstrap::network::NetworkBoundary, demo, node}; use clap::{Args, Parser, Subcommand}; #[derive(Debug, Parser)] @@ -63,6 +63,7 @@ async fn main() { ready_file: args.ready_file, directory_seed: args.directory_seed, control_token_file: args.control_token_file, + network: NetworkBoundary::loopback_ipv4(), }) .await .map(|_| ()), diff --git a/src/node.rs b/src/node.rs index 3d1a837..cebea3e 100644 --- a/src/node.rs +++ b/src/node.rs @@ -13,6 +13,7 @@ use tokio::net::TcpListener; use crate::{ adapters::LlmDecisionAdapter, + bootstrap::network::NetworkBoundary, protocol::{CapabilityId, CapabilityManifest, CredentialChain, NodeRole, SideEffectProfile}, runtime::{ ArtifactAccessService, ArtifactStore, ContractRecorder, DirectoryRegistry, NodeIdentity, @@ -50,6 +51,7 @@ pub struct NodeOptions { pub ready_file: PathBuf, pub directory_seed: Option, pub control_token_file: Option, + pub network: NetworkBoundary, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -69,10 +71,18 @@ pub async fn run(options: NodeOptions) -> Result { if identity.role() != options.profile.role() { return Err("CredentialRoleMismatch".to_owned()); } - let listener = TcpListener::bind("127.0.0.1:0").await.map_err(sanitized)?; + options.network.validate_bind().map_err(sanitized)?; + let listener = TcpListener::bind(SocketAddr::new(options.network.bind_ip, 0)) + .await + .map_err(sanitized)?; let address = listener.local_addr().map_err(sanitized)?; - ensure_loopback(address)?; - let endpoint = format!("http://{address}"); + ensure_exact_bind(address, options.network.bind_ip)?; + let scheme = if address.ip().is_loopback() { + "http" + } else { + "https" + }; + let endpoint = format!("{scheme}://{address}"); let recorder = Arc::new( ContractRecorder::open( &options.state_dir, @@ -90,8 +100,13 @@ pub async fn run(options: NodeOptions) -> Result { .directory_seed .as_deref() .ok_or_else(|| "DirectorySeedRequired".to_owned())?; - let client = PeerClient::new(*identity.root(), identity.domain_id().clone(), now) - .map_err(sanitized)?; + let client = PeerClient::new_with_boundary( + *identity.root(), + identity.domain_id().clone(), + now, + options.network.clone(), + ) + .map_err(sanitized)?; let service = ProviderService::new( identity.clone(), recorder, @@ -124,8 +139,13 @@ pub async fn run(options: NodeOptions) -> Result { identity.domain_id().clone(), now, ); - let client = PeerClient::new(*identity.root(), identity.domain_id().clone(), now) - .map_err(sanitized)?; + let client = PeerClient::new_with_boundary( + *identity.root(), + identity.domain_id().clone(), + now, + options.network.clone(), + ) + .map_err(sanitized)?; let decision = LlmDecisionAdapter::new_modelhub( &required_env("OPENAI_BASE_URL")?, &required_env("OPENAI_API_KEY")?, @@ -234,9 +254,9 @@ fn write_ready(path: &PathBuf, state: &ReadyState) -> Result<(), String> { fs::write(path, bytes).map_err(sanitized) } -fn ensure_loopback(address: SocketAddr) -> Result<(), String> { - if !matches!(address.ip(), IpAddr::V4(ip) if ip.is_loopback()) { - return Err("UnsupportedNonLoopbackTransport".to_owned()); +fn ensure_exact_bind(address: SocketAddr, expected_ip: IpAddr) -> Result<(), String> { + if address.ip() != expected_ip { + return Err("UnsupportedNetworkBoundary".to_owned()); } Ok(()) } @@ -248,9 +268,13 @@ fn required_env(name: &str) -> Result { async fn shutdown_signal() { #[cfg(unix)] { - let mut terminate = + let Ok(mut terminate) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("SIGTERM handler must install"); + else { + tracing::error!("failed to install SIGTERM handler"); + let _ = tokio::signal::ctrl_c().await; + return; + }; tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {}, diff --git a/src/protocol/error.rs b/src/protocol/error.rs index 2f793f1..8226055 100644 --- a/src/protocol/error.rs +++ b/src/protocol/error.rs @@ -25,6 +25,7 @@ pub enum ProtocolError { InvalidContractSignature, UnexpectedObjectType, MigrationRequiredV1Credential, + UnsupportedNetworkBoundary, GrantExpired, GrantScopeViolation, ContractMismatch, diff --git a/src/transport/client.rs b/src/transport/client.rs index 4a349a5..ce8ca6d 100644 --- a/src/transport/client.rs +++ b/src/transport/client.rs @@ -11,7 +11,10 @@ use ed25519_dalek::VerifyingKey; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use crate::protocol::{DomainId, NodeRole, WireEnvelope}; +use crate::{ + bootstrap::network::NetworkBoundary, + protocol::{DomainId, NodeRole, WireEnvelope}, +}; use super::MAX_JSON_BODY_BYTES; @@ -46,6 +49,7 @@ pub struct PeerClient { domain_id: DomainId, validation_time_unix_ms: u64, counters: Arc, + boundary: NetworkBoundary, } impl Debug for PeerClient { @@ -60,12 +64,27 @@ impl PeerClient { domain_id: DomainId, validation_time_unix_ms: u64, ) -> Result { - Self::with_timeouts( + Self::new_with_boundary( + root, + domain_id, + validation_time_unix_ms, + NetworkBoundary::loopback_ipv4(), + ) + } + + pub fn new_with_boundary( + root: VerifyingKey, + domain_id: DomainId, + validation_time_unix_ms: u64, + boundary: NetworkBoundary, + ) -> Result { + Self::with_timeouts_and_boundary( root, domain_id, validation_time_unix_ms, Duration::from_secs(2), Duration::from_secs(5), + boundary, ) } @@ -75,8 +94,28 @@ impl PeerClient { validation_time_unix_ms: u64, connect_timeout: Duration, request_timeout: Duration, + ) -> Result { + Self::with_timeouts_and_boundary( + root, + domain_id, + validation_time_unix_ms, + connect_timeout, + request_timeout, + NetworkBoundary::loopback_ipv4(), + ) + } + + pub fn with_timeouts_and_boundary( + root: VerifyingKey, + domain_id: DomainId, + validation_time_unix_ms: u64, + connect_timeout: Duration, + request_timeout: Duration, + boundary: NetworkBoundary, ) -> Result { let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) .connect_timeout(connect_timeout) .timeout(request_timeout) .build() @@ -87,6 +126,7 @@ impl PeerClient { domain_id, validation_time_unix_ms, counters: Arc::new(Counters::default()), + boundary, }) } @@ -98,7 +138,7 @@ impl PeerClient { expected_object_type: &str, expected_response_role: NodeRole, ) -> Result { - let url = endpoint_url(endpoint, path)?; + let url = endpoint_url(&self.boundary, endpoint, path)?; let request_bytes = serde_json::to_vec(envelope).map_err(|_| TransportError::InvalidResponse)?; self.counters.requests.fetch_add(1, Ordering::Relaxed); @@ -188,17 +228,19 @@ impl PeerClient { } } -fn endpoint_url(endpoint: &str, path: &str) -> Result { - let endpoint = endpoint.trim_end_matches('/'); - let url = reqwest::Url::parse(&format!("{endpoint}{path}")) - .map_err(|_| TransportError::InvalidEndpoint)?; - let address: std::net::IpAddr = url - .host_str() - .ok_or(TransportError::InvalidEndpoint)? - .parse() +fn endpoint_url( + boundary: &NetworkBoundary, + endpoint: &str, + path: &str, +) -> Result { + boundary + .validate_peer_endpoint(endpoint) .map_err(|_| TransportError::InvalidEndpoint)?; - if url.scheme() != "http" || !address.is_loopback() || url.port().is_none() { + if !path.starts_with('/') || path.contains('#') { return Err(TransportError::InvalidEndpoint); } + let mut url = reqwest::Url::parse(endpoint).map_err(|_| TransportError::InvalidEndpoint)?; + url.set_path(path); + url.set_query(None); Ok(url) } diff --git a/tests/network_boundary.rs b/tests/network_boundary.rs new file mode 100644 index 0000000..cf455e9 --- /dev/null +++ b/tests/network_boundary.rs @@ -0,0 +1,381 @@ +mod common; + +use std::{ + collections::BTreeSet, + net::IpAddr, + str::FromStr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use agenet::{ + bootstrap::network::{AssignedAddressVerifier, NetworkBoundary, OverlayKind}, + protocol::{NodeRole, ProtocolError, WireEnvelope}, + runtime::RuntimeError, + transport::{PeerClient, TransportError}, +}; +use ed25519_dalek::SigningKey; +use ipnet::IpNet; +use serde_json::json; +use tokio::net::TcpListener; + +const NOW: u64 = 1_800_000_000; + +#[derive(Debug, Default)] +struct FakeAssignedAddresses { + addresses: BTreeSet, +} + +impl FakeAssignedAddresses { + fn containing(addresses: &[&str]) -> Self { + Self { + addresses: addresses.iter().map(|address| parse_ip(address)).collect(), + } + } +} + +impl AssignedAddressVerifier for FakeAssignedAddresses { + fn is_assigned(&self, address: IpAddr) -> Result { + Ok(self.addresses.contains(&address)) + } +} + +#[test] +fn validates_explicit_bind_addresses_against_overlay_policy() { + struct Case { + name: &'static str, + kind: OverlayKind, + bind_ip: &'static str, + allowed_cidrs: &'static [&'static str], + assigned: &'static [&'static str], + accepted: bool, + } + + let cases = [ + Case { + name: "IPv4 loopback", + kind: OverlayKind::Loopback, + bind_ip: "127.0.0.1", + allowed_cidrs: &["127.0.0.0/8"], + assigned: &[], + accepted: true, + }, + Case { + name: "IPv6 loopback", + kind: OverlayKind::Loopback, + bind_ip: "::1", + allowed_cidrs: &["::1/128"], + assigned: &[], + accepted: true, + }, + Case { + name: "assigned Tailscale CGNAT address", + kind: OverlayKind::Tailscale, + bind_ip: "100.100.10.20", + allowed_cidrs: &["100.64.0.0/10"], + assigned: &["100.100.10.20"], + accepted: true, + }, + Case { + name: "assigned Tailscale IPv6 address", + kind: OverlayKind::Tailscale, + bind_ip: "fd7a:115c:a1e0::42", + allowed_cidrs: &["fd7a:115c:a1e0::/48"], + assigned: &["fd7a:115c:a1e0::42"], + accepted: true, + }, + Case { + name: "explicit WireGuard CIDR", + kind: OverlayKind::WireGuard, + bind_ip: "10.23.0.7", + allowed_cidrs: &["10.23.0.0/24"], + assigned: &[], + accepted: true, + }, + Case { + name: "wildcard IPv4", + kind: OverlayKind::WireGuard, + bind_ip: "0.0.0.0", + allowed_cidrs: &["0.0.0.0/0"], + assigned: &[], + accepted: false, + }, + Case { + name: "unspecified IPv6", + kind: OverlayKind::WireGuard, + bind_ip: "::", + allowed_cidrs: &["::/0"], + assigned: &[], + accepted: false, + }, + Case { + name: "public IPv4", + kind: OverlayKind::WireGuard, + bind_ip: "203.0.113.8", + allowed_cidrs: &["203.0.113.0/24"], + assigned: &[], + accepted: false, + }, + Case { + name: "multicast IPv4", + kind: OverlayKind::WireGuard, + bind_ip: "239.1.2.3", + allowed_cidrs: &["239.0.0.0/8"], + assigned: &[], + accepted: false, + }, + Case { + name: "link-local IPv4", + kind: OverlayKind::WireGuard, + bind_ip: "169.254.1.2", + allowed_cidrs: &["169.254.0.0/16"], + assigned: &[], + accepted: false, + }, + Case { + name: "link-local IPv6", + kind: OverlayKind::WireGuard, + bind_ip: "fe80::1", + allowed_cidrs: &["fe80::/10"], + assigned: &[], + accepted: false, + }, + Case { + name: "bind outside declared CIDR", + kind: OverlayKind::WireGuard, + bind_ip: "10.24.0.7", + allowed_cidrs: &["10.23.0.0/24"], + assigned: &[], + accepted: false, + }, + ]; + + for case in cases { + let boundary = boundary(case.kind, case.bind_ip, case.allowed_cidrs); + let verifier = FakeAssignedAddresses::containing(case.assigned); + assert_eq!( + boundary.validate_bind_with(&verifier).is_ok(), + case.accepted, + "{}", + case.name + ); + } +} + +#[test] +fn tailscale_cgnat_membership_is_not_local_address_ownership() { + let boundary = boundary(OverlayKind::Tailscale, "100.100.10.20", &["100.64.0.0/10"]); + + assert_eq!( + boundary.validate_bind_with(&FakeAssignedAddresses::default()), + Err(unsupported_boundary()) + ); +} + +#[test] +fn wireguard_requires_an_operator_supplied_cidr() { + let boundary = boundary(OverlayKind::WireGuard, "10.23.0.7", &[]); + + assert_eq!( + boundary.validate_bind_with(&FakeAssignedAddresses::default()), + Err(unsupported_boundary()) + ); + assert!(!boundary.allows_peer(parse_ip("10.23.0.8"))); +} + +#[test] +fn fixed_overlay_ranges_do_not_require_operator_cidrs() { + let loopback = boundary(OverlayKind::Loopback, "127.0.0.1", &[]); + let tailscale = boundary(OverlayKind::Tailscale, "100.100.10.20", &[]); + let assigned = FakeAssignedAddresses::containing(&["100.100.10.20"]); + + assert!(loopback.validate_bind_with(&assigned).is_ok()); + assert!(tailscale.validate_bind_with(&assigned).is_ok()); + assert!(loopback.allows_peer(parse_ip("127.0.0.2"))); + assert!(tailscale.allows_peer(parse_ip("100.100.10.21"))); +} + +#[test] +fn peer_membership_rejects_unsafe_and_outside_addresses() { + let boundary = boundary(OverlayKind::WireGuard, "10.23.0.7", &["10.23.0.0/24"]); + let cases = [ + ("10.23.0.8", true), + ("10.24.0.8", false), + ("0.0.0.0", false), + ("169.254.1.2", false), + ("224.0.0.1", false), + ("8.8.8.8", false), + ("::", false), + ("fe80::1", false), + ("ff02::1", false), + ]; + + for (address, accepted) in cases { + assert_eq!( + boundary.allows_peer(parse_ip(address)), + accepted, + "{address}" + ); + } +} + +#[test] +fn validates_peer_urls_before_transport() { + let boundary = boundary(OverlayKind::WireGuard, "10.23.0.7", &["10.23.0.0/24"]); + let cases = [ + ("https://10.23.0.8:443", true), + ("http://10.23.0.8:443", false), + ("https://peer.example:443", false), + ("https://user@10.23.0.8:443", false), + ("https://@10.23.0.8:443", false), + ("https://10.23.0.8:443/#fragment", false), + ("https://10.24.0.8:443", false), + ("https://0.0.0.0:443", false), + ]; + + for (endpoint, accepted) in cases { + assert_eq!( + boundary.validate_peer_endpoint(endpoint).is_ok(), + accepted, + "{endpoint}" + ); + } +} + +#[test] +fn loopback_peer_urls_retain_http_for_ipv4_and_ipv6() { + let ipv4 = boundary(OverlayKind::Loopback, "127.0.0.1", &["127.0.0.0/8"]); + let ipv6 = boundary(OverlayKind::Loopback, "::1", &["::1/128"]); + + assert!(ipv4.validate_peer_endpoint("http://127.0.0.1:8080").is_ok()); + assert!(ipv6.validate_peer_endpoint("http://[::1]:8080").is_ok()); +} + +#[tokio::test] +async fn peer_client_rejects_an_invalid_endpoint_before_request_accounting() { + let root = SigningKey::from_bytes(&[80; 32]); + let node = SigningKey::from_bytes(&[81; 32]); + let credential = + common::credential_chain(&root, &node, "node:requester", NodeRole::Requester, NOW); + let envelope = WireEnvelope::seal("test.v1", &json!({}), &node, credential) + .expect("test envelope must seal"); + let client = PeerClient::new_with_boundary( + root.verifying_key(), + common::domain_id(), + NOW, + boundary(OverlayKind::WireGuard, "10.23.0.7", &["10.23.0.0/24"]), + ) + .expect("test client must build"); + + let result = client + .post_signed::( + "https://peer.example:443", + "/effect", + &envelope, + "response.v1", + NodeRole::Requester, + ) + .await; + + assert_eq!(result, Err(TransportError::InvalidEndpoint)); + assert_eq!(client.stats().requests, 0); +} + +#[tokio::test] +async fn peer_client_does_not_follow_unvalidated_redirects() { + let redirected_requests = Arc::new(AtomicUsize::new(0)); + let redirected_counter = Arc::clone(&redirected_requests); + let destination_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("destination listener must bind"); + let destination = destination_listener + .local_addr() + .expect("destination address must be available"); + let destination_server = tokio::spawn(async move { + axum::serve( + destination_listener, + axum::Router::new().fallback(move || { + let counter = Arc::clone(&redirected_counter); + async move { + counter.fetch_add(1, Ordering::SeqCst); + axum::http::StatusCode::OK + } + }), + ) + .await + .expect("destination server must run"); + }); + + let redirect_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("redirect listener must bind"); + let redirect_endpoint = format!( + "http://{}", + redirect_listener + .local_addr() + .expect("redirect address must be available") + ); + let redirect_server = tokio::spawn(async move { + let location = format!("http://{destination}/redirected"); + axum::serve( + redirect_listener, + axum::Router::new().fallback(move || { + let location = location.clone(); + async move { + ( + axum::http::StatusCode::TEMPORARY_REDIRECT, + [(axum::http::header::LOCATION, location)], + ) + } + }), + ) + .await + .expect("redirect server must run"); + }); + + let root = SigningKey::from_bytes(&[82; 32]); + let node = SigningKey::from_bytes(&[83; 32]); + let credential = + common::credential_chain(&root, &node, "node:requester", NodeRole::Requester, NOW); + let envelope = WireEnvelope::seal("test.v1", &json!({}), &node, credential) + .expect("test envelope must seal"); + let client = PeerClient::new(root.verifying_key(), common::domain_id(), NOW) + .expect("test client must build"); + + let result = client + .post_signed::( + &redirect_endpoint, + "/effect", + &envelope, + "response.v1", + NodeRole::Requester, + ) + .await; + + assert_eq!(result, Err(TransportError::NonSuccessStatus(307))); + assert_eq!(redirected_requests.load(Ordering::SeqCst), 0); + redirect_server.abort(); + destination_server.abort(); +} + +fn boundary(kind: OverlayKind, bind_ip: &str, allowed_cidrs: &[&str]) -> NetworkBoundary { + NetworkBoundary { + kind, + bind_ip: parse_ip(bind_ip), + allowed_cidrs: allowed_cidrs.iter().map(|cidr| parse_net(cidr)).collect(), + } +} + +fn parse_ip(value: &str) -> IpAddr { + IpAddr::from_str(value).expect("test IP address must parse") +} + +fn parse_net(value: &str) -> IpNet { + IpNet::from_str(value).expect("test CIDR must parse") +} + +fn unsupported_boundary() -> RuntimeError { + RuntimeError::Protocol(ProtocolError::UnsupportedNetworkBoundary) +} From de3fdb975814511198f5b3a9006650bcfbef7843 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 15:55:49 +0800 Subject: [PATCH 12/67] [bug] Fix private-network startup boundary Root cause: Non-loopback nodes advertised HTTPS while the runtime still served plaintext, and Tailscale ownership checks could block forever. Solution: Fail closed before startup side effects, bound and reap the direct Tailscale probe, and isolate validation with spawn_blocking. Risks: Tailscale CLI behavior still varies across supported OS releases. Dependency: Bootstrap step 3 at 1039cc6. Links: plan/01-v1-multi-host-node-bootstrap.md Post-mortem: Gate overlay listeners on real TLS and bound external tools. --- src/bootstrap/network.rs | 209 ++++++++++++++++++++++++++++++++++++-- src/node.rs | 28 ++++- tests/network_boundary.rs | 81 ++++++++++++++- 3 files changed, 304 insertions(+), 14 deletions(-) diff --git a/src/bootstrap/network.rs b/src/bootstrap/network.rs index 98d4587..7f9b29d 100644 --- a/src/bootstrap/network.rs +++ b/src/bootstrap/network.rs @@ -1,6 +1,10 @@ use std::{ + fmt::{Debug, Formatter}, net::{IpAddr, Ipv4Addr}, - process::{Command, Stdio}, + process::{Child, Command, Stdio}, + sync::Arc, + thread, + time::{Duration, Instant}, }; use ipnet::IpNet; @@ -25,19 +29,83 @@ pub trait AssignedAddressVerifier { fn is_assigned(&self, address: IpAddr) -> Result; } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TailscaleCommandError { + ExecutionFailed, + TimedOut, +} + +trait TailscaleCommandRunner: Send + Sync { + fn assert_address( + &self, + address: IpAddr, + timeout: Duration, + ) -> Result; +} + #[derive(Debug, Clone, Copy, Default)] -pub struct TailscaleAddressVerifier; +struct DirectTailscaleCommandRunner; -impl AssignedAddressVerifier for TailscaleAddressVerifier { - fn is_assigned(&self, address: IpAddr) -> Result { - let status = Command::new("tailscale") +impl TailscaleCommandRunner for DirectTailscaleCommandRunner { + fn assert_address( + &self, + address: IpAddr, + timeout: Duration, + ) -> Result { + let deadline = command_deadline(timeout)?; + let mut child = Command::new("tailscale") .arg("ip") .arg(format!("--assert={address}")) + .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .status() - .map_err(|_| unsupported_boundary())?; - Ok(status.success()) + .spawn() + .map_err(|_| TailscaleCommandError::ExecutionFailed)?; + wait_for_child(&mut child, deadline) + } +} + +const DEFAULT_TAILSCALE_COMMAND_TIMEOUT: Duration = Duration::from_secs(2); +const CHILD_POLL_INTERVAL: Duration = Duration::from_millis(10); + +#[derive(Clone)] +pub struct TailscaleAddressVerifier { + runner: Arc, + timeout: Duration, +} + +impl Debug for TailscaleAddressVerifier { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TailscaleAddressVerifier") + .field("timeout", &self.timeout) + .finish_non_exhaustive() + } +} + +impl Default for TailscaleAddressVerifier { + fn default() -> Self { + Self::with_runner( + DirectTailscaleCommandRunner, + DEFAULT_TAILSCALE_COMMAND_TIMEOUT, + ) + } +} + +impl TailscaleAddressVerifier { + fn with_runner(runner: impl TailscaleCommandRunner + 'static, timeout: Duration) -> Self { + Self { + runner: Arc::new(runner), + timeout, + } + } +} + +impl AssignedAddressVerifier for TailscaleAddressVerifier { + fn is_assigned(&self, address: IpAddr) -> Result { + self.runner + .assert_address(address, self.timeout) + .map_err(|_| unsupported_boundary()) } } @@ -51,7 +119,7 @@ impl NetworkBoundary { } pub fn validate_bind(&self) -> Result<(), RuntimeError> { - self.validate_bind_with(&TailscaleAddressVerifier) + self.validate_bind_with(&TailscaleAddressVerifier::default()) } pub fn validate_bind_with( @@ -113,6 +181,41 @@ impl NetworkBoundary { } } +fn command_deadline(timeout: Duration) -> Result { + Instant::now() + .checked_add(timeout) + .ok_or(TailscaleCommandError::ExecutionFailed) +} + +fn wait_for_child(child: &mut Child, deadline: Instant) -> Result { + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status.success()), + Ok(None) if Instant::now() >= deadline => { + terminate_and_reap(child)?; + return Err(TailscaleCommandError::TimedOut); + } + Ok(None) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + thread::sleep(remaining.min(CHILD_POLL_INTERVAL)); + } + Err(_) => { + terminate_and_reap(child)?; + return Err(TailscaleCommandError::ExecutionFailed); + } + } + } +} + +fn terminate_and_reap(child: &mut Child) -> Result<(), TailscaleCommandError> { + let kill_failed = child.kill().is_err(); + let wait_failed = child.wait().is_err(); + if kill_failed || wait_failed { + return Err(TailscaleCommandError::ExecutionFailed); + } + Ok(()) +} + fn authority_contains_userinfo(endpoint: &str) -> bool { endpoint .split_once("://") @@ -156,3 +259,91 @@ fn is_private_unicast(address: IpAddr) -> bool { fn unsupported_boundary() -> RuntimeError { RuntimeError::Protocol(ProtocolError::UnsupportedNetworkBoundary) } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + #[derive(Debug, Clone)] + struct FakeTailscaleCommandRunner { + outcome: Result, + observed_timeout: Arc>>, + } + + impl TailscaleCommandRunner for FakeTailscaleCommandRunner { + fn assert_address( + &self, + _address: IpAddr, + timeout: Duration, + ) -> Result { + *self + .observed_timeout + .lock() + .expect("test timeout lock must be available") = Some(timeout); + self.outcome + } + } + + #[test] + fn tailscale_command_timeout_and_execution_failure_are_sanitized() { + for command_error in [ + TailscaleCommandError::TimedOut, + TailscaleCommandError::ExecutionFailed, + ] { + let observed_timeout = Arc::new(Mutex::new(None)); + let verifier = TailscaleAddressVerifier::with_runner( + FakeTailscaleCommandRunner { + outcome: Err(command_error), + observed_timeout: Arc::clone(&observed_timeout), + }, + Duration::from_millis(25), + ); + let boundary = tailscale_boundary(); + + assert_eq!( + boundary.validate_bind_with(&verifier), + Err(unsupported_boundary()) + ); + assert_eq!( + *observed_timeout + .lock() + .expect("test timeout lock must be available"), + Some(Duration::from_millis(25)) + ); + } + } + + #[test] + fn tailscale_command_rejection_uses_the_same_boundary_error() { + let verifier = TailscaleAddressVerifier::with_runner( + FakeTailscaleCommandRunner { + outcome: Ok(false), + observed_timeout: Arc::new(Mutex::new(None)), + }, + Duration::from_millis(25), + ); + + assert_eq!( + tailscale_boundary().validate_bind_with(&verifier), + Err(unsupported_boundary()) + ); + } + + #[test] + fn excessive_command_timeout_is_rejected_without_panicking() { + assert_eq!( + command_deadline(Duration::MAX), + Err(TailscaleCommandError::ExecutionFailed) + ); + } + + fn tailscale_boundary() -> NetworkBoundary { + NetworkBoundary { + kind: OverlayKind::Tailscale, + bind_ip: IpAddr::V4(Ipv4Addr::new(100, 100, 10, 20)), + allowed_cidrs: Vec::new(), + } + } +} diff --git a/src/node.rs b/src/node.rs index cebea3e..b2c361a 100644 --- a/src/node.rs +++ b/src/node.rs @@ -14,10 +14,13 @@ use tokio::net::TcpListener; use crate::{ adapters::LlmDecisionAdapter, bootstrap::network::NetworkBoundary, - protocol::{CapabilityId, CapabilityManifest, CredentialChain, NodeRole, SideEffectProfile}, + protocol::{ + CapabilityId, CapabilityManifest, CredentialChain, NodeRole, ProtocolError, + SideEffectProfile, + }, runtime::{ ArtifactAccessService, ArtifactStore, ContractRecorder, DirectoryRegistry, NodeIdentity, - ProviderService, RequesterService, read_signing_key, + ProviderService, RequesterService, RuntimeError, read_signing_key, }, transport::{PeerClient, directory_router, provider_router, requester_router}, }; @@ -65,13 +68,14 @@ pub struct ReadyState { } pub async fn run(options: NodeOptions) -> Result { + ensure_transport_available(&options.network)?; + validate_network_boundary(options.network.clone()).await?; fs::create_dir_all(&options.state_dir).map_err(sanitized)?; let now = unix_ms(); let identity = load_identity(&options, now)?; if identity.role() != options.profile.role() { return Err("CredentialRoleMismatch".to_owned()); } - options.network.validate_bind().map_err(sanitized)?; let listener = TcpListener::bind(SocketAddr::new(options.network.bind_ip, 0)) .await .map_err(sanitized)?; @@ -181,6 +185,24 @@ pub async fn run(options: NodeOptions) -> Result { Ok(ready) } +async fn validate_network_boundary(boundary: NetworkBoundary) -> Result<(), String> { + tokio::task::spawn_blocking(move || boundary.validate_bind()) + .await + .map_err(|_| { + sanitized(RuntimeError::Protocol( + ProtocolError::UnsupportedNetworkBoundary, + )) + })? + .map_err(sanitized) +} + +fn ensure_transport_available(boundary: &NetworkBoundary) -> Result<(), String> { + if !boundary.bind_ip.is_loopback() { + return Err("NonLoopbackTlsUnavailable".to_owned()); + } + Ok(()) +} + fn load_identity(options: &NodeOptions, now: u64) -> Result { let signing_key = read_signing_key(&options.key_file).map_err(sanitized)?; let credential: CredentialChain = diff --git a/tests/network_boundary.rs b/tests/network_boundary.rs index cf455e9..d84fe02 100644 --- a/tests/network_boundary.rs +++ b/tests/network_boundary.rs @@ -12,6 +12,7 @@ use std::{ use agenet::{ bootstrap::network::{AssignedAddressVerifier, NetworkBoundary, OverlayKind}, + node::{self, NodeOptions, NodeProfile}, protocol::{NodeRole, ProtocolError, WireEnvelope}, runtime::RuntimeError, transport::{PeerClient, TransportError}, @@ -254,7 +255,32 @@ fn loopback_peer_urls_retain_http_for_ipv4_and_ipv6() { } #[tokio::test] -async fn peer_client_rejects_an_invalid_endpoint_before_request_accounting() { +async fn peer_client_rejects_outside_ip_before_reaching_a_listener() { + let reached_listener = Arc::new(AtomicUsize::new(0)); + let listener_counter = Arc::clone(&reached_listener); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener must bind"); + let endpoint = format!( + "http://{}", + listener + .local_addr() + .expect("test listener address must be available") + ); + let server = tokio::spawn(async move { + axum::serve( + listener, + axum::Router::new().fallback(move || { + let counter = Arc::clone(&listener_counter); + async move { + counter.fetch_add(1, Ordering::SeqCst); + axum::http::StatusCode::OK + } + }), + ) + .await + .expect("test server must run"); + }); let root = SigningKey::from_bytes(&[80; 32]); let node = SigningKey::from_bytes(&[81; 32]); let credential = @@ -271,7 +297,7 @@ async fn peer_client_rejects_an_invalid_endpoint_before_request_accounting() { let result = client .post_signed::( - "https://peer.example:443", + &endpoint, "/effect", &envelope, "response.v1", @@ -281,6 +307,57 @@ async fn peer_client_rejects_an_invalid_endpoint_before_request_accounting() { assert_eq!(result, Err(TransportError::InvalidEndpoint)); assert_eq!(client.stats().requests, 0); + assert_eq!(reached_listener.load(Ordering::SeqCst), 0); + server.abort(); +} + +#[tokio::test] +async fn node_rejects_non_loopback_before_startup_side_effects() { + let root = tempfile::tempdir().expect("test directory must be created"); + let state_dir = root.path().join("state"); + let ready_file = root.path().join("ready.json"); + let result = node::run(NodeOptions { + profile: NodeProfile::Directory, + state_dir: state_dir.clone(), + key_file: root.path().join("missing.key"), + credential_file: root.path().join("missing-credential.json"), + root_public_key_file: root.path().join("missing-root.pub"), + ready_file: ready_file.clone(), + directory_seed: None, + control_token_file: None, + network: boundary(OverlayKind::WireGuard, "10.23.0.7", &["10.23.0.0/24"]), + }) + .await; + + assert_eq!(result.err(), Some("NonLoopbackTlsUnavailable".to_owned())); + assert!(!state_dir.exists()); + assert!(!ready_file.exists()); +} + +#[tokio::test] +async fn node_validates_loopback_boundary_before_startup_side_effects() { + let root = tempfile::tempdir().expect("test directory must be created"); + let state_dir = root.path().join("state"); + let ready_file = root.path().join("ready.json"); + let result = node::run(NodeOptions { + profile: NodeProfile::Directory, + state_dir: state_dir.clone(), + key_file: root.path().join("missing.key"), + credential_file: root.path().join("missing-credential.json"), + root_public_key_file: root.path().join("missing-root.pub"), + ready_file: ready_file.clone(), + directory_seed: None, + control_token_file: None, + network: boundary(OverlayKind::Loopback, "127.0.0.1", &["10.23.0.0/24"]), + }) + .await; + + assert_eq!( + result.err(), + Some("Protocol(UnsupportedNetworkBoundary)".to_owned()) + ); + assert!(!state_dir.exists()); + assert!(!ready_file.exists()); } #[tokio::test] From a6ed86a7d98704194c37dc68742337586e133ecb Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 16:25:33 +0800 Subject: [PATCH 13/67] [feat][Bootstrap][4/14] Add encrypted PKI Root cause: NA Solution: Store the Domain Root in a passphrase-encrypted administrative keystore and generate a scoped internal TLS Authority. Risks: Losing the Root passphrase prevents administrative recovery. Dependency: Bootstrap step 3. Links: docs/superpowers/specs/ 2026-08-14-node-bootstrap-and-pages-design.md --- Cargo.lock | 2 + Cargo.toml | 4 +- src/bootstrap/keystore.rs | 190 ++++++++++++++++++++++++++ src/bootstrap/mod.rs | 25 ++++ src/bootstrap/pki.rs | 252 ++++++++++++++++++++++++++++++++++ src/runtime/key_store.rs | 87 +++++++++--- src/runtime/mod.rs | 2 +- tests/admin_keystore.rs | 130 ++++++++++++++++++ tests/pki.rs | 279 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 949 insertions(+), 22 deletions(-) create mode 100644 src/bootstrap/keystore.rs create mode 100644 src/bootstrap/pki.rs create mode 100644 tests/admin_keystore.rs create mode 100644 tests/pki.rs diff --git a/Cargo.lock b/Cargo.lock index 5f51cd7..83ae024 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,7 @@ dependencies = [ "hmac 0.13.0", "http-body-util", "ipnet", + "libc", "plist", "proptest", "rcgen", @@ -121,6 +122,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "x509-parser", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 3cfec76..9a960c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,8 @@ ed25519-dalek = { version = "=3.0.0", features = ["rand_core"] } getrandom = "=0.4.3" hmac = { version = "=0.13.0", features = ["zeroize"] } ipnet = { version = "=2.12.1", features = ["serde"] } -rcgen = { version = "=0.14.9", default-features = false, features = ["aws_lc_rs", "pem", "zeroize"] } +libc = "=0.2.189" +rcgen = { version = "=0.14.9", default-features = false, features = ["aws_lc_rs", "pem", "x509-parser", "zeroize"] } reqwest = { version = "=0.13.4", features = ["json", "query"] } rpassword = "=7.5.4" rustls = "=0.23.43" @@ -49,6 +50,7 @@ http-body-util = "=0.1.5" proptest = "=1.11.0" tempfile = "=3.27.0" tower = "=0.5.3" +x509-parser = "=0.18.1" [profile.release] strip = true diff --git a/src/bootstrap/keystore.rs b/src/bootstrap/keystore.rs new file mode 100644 index 0000000..1a01286 --- /dev/null +++ b/src/bootstrap/keystore.rs @@ -0,0 +1,190 @@ +use std::{ + fmt::{Debug, Formatter}, + fs::OpenOptions, + io::{Read, Write}, + iter, + os::unix::fs::OpenOptionsExt, + path::Path, +}; + +use age::{Decryptor, Encryptor, secrecy::SecretString}; +use ed25519_dalek::SigningKey; +use zeroize::{Zeroize, Zeroizing}; + +use crate::{protocol::DomainId, runtime::key_store::atomic_write_owner_only}; + +use super::BootstrapError; + +const HEADER: &[u8] = b"AGENET-ROOT-KEYSTORE\0\x01"; +const MAX_PLAINTEXT_BYTES: usize = 512; + +pub struct DomainRootMaterial { + pub domain_id: DomainId, + pub signing_key: SigningKey, + pub created_at_ms: i64, +} + +impl Debug for DomainRootMaterial { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DomainRootMaterial") + .field("domain_id", &self.domain_id) + .field("signing_key", &"[REDACTED]") + .field("created_at_ms", &self.created_at_ms) + .finish() + } +} + +impl Zeroize for DomainRootMaterial { + fn zeroize(&mut self) { + self.signing_key = SigningKey::from_bytes(&[0; 32]); + self.created_at_ms.zeroize(); + } +} + +pub trait RootKeystore { + fn create( + path: &Path, + material: &DomainRootMaterial, + passphrase: SecretString, + ) -> Result<(), BootstrapError>; + + fn unlock( + path: &Path, + passphrase: SecretString, + ) -> Result, BootstrapError>; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct AgeRootKeystore; + +impl AgeRootKeystore { + pub const MAX_FILE_BYTES: usize = 64 * 1024; +} + +impl RootKeystore for AgeRootKeystore { + fn create( + path: &Path, + material: &DomainRootMaterial, + passphrase: SecretString, + ) -> Result<(), BootstrapError> { + let plaintext = encode_material(material)?; + let encryptor = Encryptor::with_user_passphrase(passphrase); + let mut ciphertext = Vec::new(); + ciphertext.extend_from_slice(HEADER); + { + let mut writer = encryptor + .wrap_output(&mut ciphertext) + .map_err(|_| BootstrapError::StorageFailed)?; + writer + .write_all(&plaintext) + .map_err(|_| BootstrapError::StorageFailed)?; + writer.finish().map_err(|_| BootstrapError::StorageFailed)?; + } + if ciphertext.len() > Self::MAX_FILE_BYTES { + return Err(BootstrapError::StorageFailed); + } + atomic_write_owner_only(path, &ciphertext, false).map_err(|_| BootstrapError::StorageFailed) + } + + fn unlock( + path: &Path, + passphrase: SecretString, + ) -> Result, BootstrapError> { + let bytes = read_regular_bounded(path)?; + let ciphertext = bytes + .strip_prefix(HEADER) + .ok_or(BootstrapError::InvalidKeystore)?; + let decryptor = Decryptor::new(ciphertext).map_err(|_| BootstrapError::InvalidKeystore)?; + let identity = age::scrypt::Identity::new(passphrase); + let mut reader = decryptor + .decrypt(iter::once(&identity as _)) + .map_err(|_| BootstrapError::InvalidKeystore)?; + let mut plaintext = Zeroizing::new(Vec::new()); + reader + .by_ref() + .take((MAX_PLAINTEXT_BYTES + 1) as u64) + .read_to_end(&mut plaintext) + .map_err(|_| BootstrapError::InvalidKeystore)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(BootstrapError::InvalidKeystore); + } + decode_material(&plaintext).map(Zeroizing::new) + } +} + +pub fn prompt_root_passphrase(prompt: &str) -> Result { + let value = + rpassword::prompt_password(prompt).map_err(|_| BootstrapError::PassphraseUnavailable)?; + if value.is_empty() { + return Err(BootstrapError::PassphraseUnavailable); + } + Ok(SecretString::from(value)) +} + +fn encode_material(material: &DomainRootMaterial) -> Result>, BootstrapError> { + let domain = material.domain_id.as_str().as_bytes(); + let domain_len = u16::try_from(domain.len()).map_err(|_| BootstrapError::StorageFailed)?; + let mut bytes = Zeroizing::new(Vec::with_capacity(2 + domain.len() + 8 + 32)); + bytes.extend_from_slice(&domain_len.to_be_bytes()); + bytes.extend_from_slice(domain); + bytes.extend_from_slice(&material.created_at_ms.to_be_bytes()); + let signing_key_bytes = Zeroizing::new(material.signing_key.to_bytes()); + bytes.extend_from_slice(&signing_key_bytes[..]); + Ok(bytes) +} + +fn decode_material(bytes: &[u8]) -> Result { + let domain_len_bytes: [u8; 2] = bytes + .get(..2) + .and_then(|value| value.try_into().ok()) + .ok_or(BootstrapError::InvalidKeystore)?; + let domain_len = usize::from(u16::from_be_bytes(domain_len_bytes)); + let expected = 2_usize + .checked_add(domain_len) + .and_then(|value| value.checked_add(8 + 32)) + .ok_or(BootstrapError::InvalidKeystore)?; + if bytes.len() != expected { + return Err(BootstrapError::InvalidKeystore); + } + let domain_end = 2 + domain_len; + let domain = + std::str::from_utf8(&bytes[2..domain_end]).map_err(|_| BootstrapError::InvalidKeystore)?; + let created_at_ms = i64::from_be_bytes( + bytes[domain_end..domain_end + 8] + .try_into() + .map_err(|_| BootstrapError::InvalidKeystore)?, + ); + let secret = Zeroizing::new( + bytes[domain_end + 8..] + .try_into() + .map_err(|_| BootstrapError::InvalidKeystore)?, + ); + Ok(DomainRootMaterial { + domain_id: DomainId::new(domain).map_err(|_| BootstrapError::InvalidKeystore)?, + signing_key: SigningKey::from_bytes(&secret), + created_at_ms, + }) +} + +fn read_regular_bounded(path: &Path) -> Result, BootstrapError> { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + .map_err(|_| BootstrapError::InvalidKeystore)?; + let metadata = file + .metadata() + .map_err(|_| BootstrapError::InvalidKeystore)?; + if !metadata.file_type().is_file() || metadata.len() > AgeRootKeystore::MAX_FILE_BYTES as u64 { + return Err(BootstrapError::InvalidKeystore); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take((AgeRootKeystore::MAX_FILE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| BootstrapError::InvalidKeystore)?; + if bytes.len() > AgeRootKeystore::MAX_FILE_BYTES { + return Err(BootstrapError::InvalidKeystore); + } + Ok(bytes) +} diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 96d3a5f..2835851 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -1,3 +1,28 @@ //! Bootstrap orchestration boundary for the v0.2 multi-host preview. +mod keystore; pub mod network; +mod pki; + +use std::fmt::{Display, Formatter}; + +pub use keystore::{AgeRootKeystore, DomainRootMaterial, RootKeystore, prompt_root_passphrase}; +pub use pki::{ + AGENET_NODE_ID_OID, AuthorityPki, IssuedClientCertificate, IssuedServerIdentity, NodeTlsCsr, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootstrapError { + InvalidKeystore, + StorageFailed, + PassphraseUnavailable, + InvalidPki, +} + +impl Display for BootstrapError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for BootstrapError {} diff --git a/src/bootstrap/pki.rs b/src/bootstrap/pki.rs new file mode 100644 index 0000000..c09d3d2 --- /dev/null +++ b/src/bootstrap/pki.rs @@ -0,0 +1,252 @@ +use std::{ + fmt::{Debug, Formatter}, + net::IpAddr, + path::Path, +}; + +use rcgen::{ + BasicConstraints, CertificateParams, CertificateSigningRequestParams, CertifiedIssuer, + CustomExtension, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType, +}; +use sha2::{Digest, Sha256}; +use time::OffsetDateTime; +use zeroize::Zeroizing; + +use crate::{protocol::NodeId, runtime::key_store::atomic_write_owner_only}; + +use super::BootstrapError; + +/// UUID-derived private OID for the Authority-written AgenNet NodeId binding. +pub const AGENET_NODE_ID_OID: &str = "2.25.9029276719620050359"; +const AGENET_NODE_ID_OID_COMPONENTS: &[u64] = &[2, 25, 9029276719620050359]; + +pub struct AuthorityPki { + pub ca_cert_pem: Zeroizing, + pub ca_key_pem: Zeroizing, + pub fingerprint_sha256: String, + issuer: CertifiedIssuer<'static, KeyPair>, + not_before_ms: i64, + not_after_ms: i64, +} + +impl Debug for AuthorityPki { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AuthorityPki") + .field("ca_cert_pem", &"[CERTIFICATE]") + .field("ca_key_pem", &"[REDACTED]") + .field("fingerprint_sha256", &self.fingerprint_sha256) + .finish_non_exhaustive() + } +} + +pub struct IssuedServerIdentity { + pub cert_pem: String, + pub private_key_pem: Zeroizing, +} + +impl Debug for IssuedServerIdentity { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("IssuedServerIdentity") + .field("cert_pem", &"[CERTIFICATE]") + .field("private_key_pem", &"[REDACTED]") + .finish() + } +} + +impl IssuedServerIdentity { + pub fn persist_private_key(&self, path: &Path) -> Result<(), BootstrapError> { + persist_private_key(path, &self.private_key_pem) + } +} + +pub struct IssuedClientCertificate { + pub cert_pem: String, +} + +impl Debug for IssuedClientCertificate { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("IssuedClientCertificate") + .field("cert_pem", &"[CERTIFICATE]") + .finish() + } +} + +pub struct NodeTlsCsr { + pub csr_pem: String, + pub private_key_pem: Zeroizing, +} + +impl Debug for NodeTlsCsr { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NodeTlsCsr") + .field("csr_pem", &"[CSR]") + .field("private_key_pem", &"[REDACTED]") + .finish() + } +} + +impl AuthorityPki { + pub fn generate(not_before_ms: i64, not_after_ms: i64) -> Result { + let (not_before, not_after) = valid_range(not_before_ms, not_after_ms)?; + let key = KeyPair::generate().map_err(|_| BootstrapError::InvalidPki)?; + let ca_key_pem = Zeroizing::new(key.serialize_pem()); + let mut params = CertificateParams::default(); + params.not_before = not_before; + params.not_after = not_after; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params + .distinguished_name + .push(DnType::CommonName, "AgenNet Internal Authority CA"); + let issuer = + CertifiedIssuer::self_signed(params, key).map_err(|_| BootstrapError::InvalidPki)?; + let ca_cert_pem = Zeroizing::new(issuer.pem()); + let fingerprint_sha256 = Self::fingerprint_der(issuer.der()); + Ok(Self { + ca_cert_pem, + ca_key_pem, + fingerprint_sha256, + issuer, + not_before_ms, + not_after_ms, + }) + } + + pub fn fingerprint_der(der: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = Sha256::digest(der); + let mut encoded = String::with_capacity(64); + for byte in digest { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded + } + + pub fn issue_server( + &self, + overlay_ip: IpAddr, + not_before_ms: i64, + not_after_ms: i64, + ) -> Result { + let (not_before, not_after) = self.valid_leaf_range(not_before_ms, not_after_ms)?; + let key = KeyPair::generate().map_err(|_| BootstrapError::InvalidPki)?; + let private_key_pem = Zeroizing::new(key.serialize_pem()); + let mut params = CertificateParams::default(); + params.not_before = not_before; + params.not_after = not_after; + params.subject_alt_names = vec![SanType::IpAddress(overlay_ip)]; + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let cert = params + .signed_by(&key, &self.issuer) + .map_err(|_| BootstrapError::InvalidPki)?; + Ok(IssuedServerIdentity { + cert_pem: cert.pem(), + private_key_pem, + }) + } + + pub fn issue_client( + &self, + csr_pem: &str, + node_id: &NodeId, + not_before_ms: i64, + not_after_ms: i64, + ) -> Result { + let (not_before, not_after) = self.valid_leaf_range(not_before_ms, not_after_ms)?; + let parsed = CertificateSigningRequestParams::from_pem(csr_pem) + .map_err(|_| BootstrapError::InvalidPki)?; + let mut params = CertificateParams::default(); + params.not_before = not_before; + params.not_after = not_after; + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + params.custom_extensions = vec![CustomExtension::from_oid_content( + AGENET_NODE_ID_OID_COMPONENTS, + der_utf8_string(node_id.as_str())?, + )]; + let request = CertificateSigningRequestParams { + params, + public_key: parsed.public_key, + }; + let cert = request + .signed_by(&self.issuer) + .map_err(|_| BootstrapError::InvalidPki)?; + Ok(IssuedClientCertificate { + cert_pem: cert.pem(), + }) + } + + pub fn persist_ca_key(&self, path: &Path) -> Result<(), BootstrapError> { + atomic_write_owner_only(path, self.ca_key_pem.as_bytes(), false) + .map_err(|_| BootstrapError::StorageFailed) + } + + fn valid_leaf_range( + &self, + not_before_ms: i64, + not_after_ms: i64, + ) -> Result<(OffsetDateTime, OffsetDateTime), BootstrapError> { + if not_before_ms < self.not_before_ms || not_after_ms > self.not_after_ms { + return Err(BootstrapError::InvalidPki); + } + valid_range(not_before_ms, not_after_ms) + } +} + +impl NodeTlsCsr { + pub fn generate() -> Result { + let key = KeyPair::generate().map_err(|_| BootstrapError::InvalidPki)?; + let request = CertificateParams::default() + .serialize_request(&key) + .map_err(|_| BootstrapError::InvalidPki)?; + Ok(Self { + csr_pem: request.pem().map_err(|_| BootstrapError::InvalidPki)?, + private_key_pem: Zeroizing::new(key.serialize_pem()), + }) + } + + pub fn persist_private_key(&self, path: &Path) -> Result<(), BootstrapError> { + persist_private_key(path, &self.private_key_pem) + } +} + +fn persist_private_key(path: &Path, pem: &str) -> Result<(), BootstrapError> { + atomic_write_owner_only(path, pem.as_bytes(), false).map_err(|_| BootstrapError::StorageFailed) +} + +fn valid_range( + not_before_ms: i64, + not_after_ms: i64, +) -> Result<(OffsetDateTime, OffsetDateTime), BootstrapError> { + if not_before_ms >= not_after_ms { + return Err(BootstrapError::InvalidPki); + } + let not_before = + OffsetDateTime::from_unix_timestamp_nanos(i128::from(not_before_ms) * 1_000_000) + .map_err(|_| BootstrapError::InvalidPki)?; + let not_after = OffsetDateTime::from_unix_timestamp_nanos(i128::from(not_after_ms) * 1_000_000) + .map_err(|_| BootstrapError::InvalidPki)?; + Ok((not_before, not_after)) +} + +fn der_utf8_string(value: &str) -> Result, BootstrapError> { + let length = value.len(); + let mut encoded = Vec::with_capacity(length + 4); + encoded.push(0x0c); + match length { + 0..=127 => encoded.push(length as u8), + 128..=255 => encoded.extend_from_slice(&[0x81, length as u8]), + 256..=65_535 => encoded.extend_from_slice(&[0x82, (length >> 8) as u8, length as u8]), + _ => return Err(BootstrapError::InvalidPki), + } + encoded.extend_from_slice(value.as_bytes()); + Ok(encoded) +} diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index 7d999c0..1668836 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -1,37 +1,84 @@ use std::{ - fs::{self, OpenOptions}, + fs::{self, File, OpenOptions}, io::Write, - os::unix::fs::{OpenOptionsExt, PermissionsExt}, - path::Path, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, }; use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::SigningKey; +use zeroize::Zeroizing; use super::RuntimeError; pub fn write_signing_key(path: &Path, key: &SigningKey) -> Result<(), RuntimeError> { + let key_bytes = Zeroizing::new(key.to_bytes()); + let mut encoded = Zeroizing::new(STANDARD.encode(&key_bytes[..])); + encoded.push('\n'); + atomic_write_owner_only(path, encoded.as_bytes(), true) +} + +pub fn read_signing_key(path: &Path) -> Result { + let encoded = Zeroizing::new(fs::read_to_string(path)?); + let bytes = Zeroizing::new( + STANDARD + .decode(encoded.trim()) + .map_err(|_| RuntimeError::InvalidPrivateKey)?, + ); + let secret = Zeroizing::new( + bytes + .as_slice() + .try_into() + .map_err(|_| RuntimeError::InvalidPrivateKey)?, + ); + Ok(SigningKey::from_bytes(&secret)) +} + +pub(crate) fn atomic_write_owner_only( + path: &Path, + bytes: &[u8], + replace: bool, +) -> Result<(), RuntimeError> { + let parent = path.parent().ok_or(RuntimeError::Io)?; + let file_name = path.file_name().ok_or(RuntimeError::Io)?; + let temp_path = parent.join(format!( + ".{}.{}.tmp", + file_name.to_string_lossy(), + uuid::Uuid::new_v4() + )); + let result = write_and_publish(&temp_path, path, bytes, replace); + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +fn write_and_publish( + temp_path: &Path, + path: &Path, + bytes: &[u8], + replace: bool, +) -> Result<(), RuntimeError> { let mut file = OpenOptions::new() - .create(true) - .truncate(true) + .create_new(true) .write(true) .mode(0o600) - .open(path)?; - fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; - file.write_all(STANDARD.encode(key.to_bytes()).as_bytes())?; - file.write_all(b"\n")?; + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(temp_path)?; + file.write_all(bytes)?; file.flush()?; - file.sync_data()?; - Ok(()) + file.sync_all()?; + drop(file); + if replace { + fs::rename(temp_path, path)?; + } else { + fs::hard_link(temp_path, path)?; + fs::remove_file(temp_path)?; + } + sync_directory(path.parent().ok_or(RuntimeError::Io)?) } -pub fn read_signing_key(path: &Path) -> Result { - let encoded = fs::read_to_string(path)?; - let bytes = STANDARD - .decode(encoded.trim()) - .map_err(|_| RuntimeError::InvalidPrivateKey)?; - let secret: [u8; 32] = bytes - .try_into() - .map_err(|_| RuntimeError::InvalidPrivateKey)?; - Ok(SigningKey::from_bytes(&secret)) +fn sync_directory(path: &Path) -> Result<(), RuntimeError> { + File::open(PathBuf::from(path))?.sync_all()?; + Ok(()) } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 078ec07..62b7fc5 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -3,7 +3,7 @@ mod artifact_access; mod directory; mod error; mod identity; -mod key_store; +pub(crate) mod key_store; mod provider; mod recorder; mod requester; diff --git a/tests/admin_keystore.rs b/tests/admin_keystore.rs new file mode 100644 index 0000000..ad086f8 --- /dev/null +++ b/tests/admin_keystore.rs @@ -0,0 +1,130 @@ +use std::{fs, os::unix::fs::PermissionsExt, process::Command}; + +use age::secrecy::SecretString; +use agenet::{ + bootstrap::{AgeRootKeystore, BootstrapError, DomainRootMaterial, RootKeystore}, + protocol::DomainId, +}; +use ed25519_dalek::SigningKey; +use tempfile::TempDir; +use zeroize::Zeroize; + +const SENTINEL: &str = "task4-sentinel-passphrase-never-leak"; + +fn material() -> DomainRootMaterial { + DomainRootMaterial { + domain_id: DomainId::new("domain:task4").expect("valid domain"), + signing_key: SigningKey::from_bytes(&[42; 32]), + created_at_ms: 1_800_000_000_000, + } +} + +fn passphrase(value: &str) -> SecretString { + SecretString::from(value.to_owned()) +} + +#[test] +fn root_keystore_round_trips_with_owner_only_atomic_creation() { + let temp = TempDir::new().expect("temporary directory"); + let path = temp.path().join("root.age"); + + AgeRootKeystore::create(&path, &material(), passphrase(SENTINEL)).expect("create keystore"); + let unlocked = AgeRootKeystore::unlock(&path, passphrase(SENTINEL)).expect("unlock keystore"); + + assert_eq!(unlocked.domain_id.as_str(), "domain:task4"); + assert_eq!(unlocked.signing_key.to_bytes(), [42; 32]); + assert_eq!(unlocked.created_at_ms, 1_800_000_000_000); + assert_eq!( + fs::metadata(&path).expect("metadata").permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_dir(temp.path()).expect("read parent").count(), 1); + assert_eq!( + AgeRootKeystore::create(&path, &material(), passphrase(SENTINEL)), + Err(BootstrapError::StorageFailed) + ); +} + +#[test] +fn root_keystore_failures_are_payload_free_and_uniform() { + let temp = TempDir::new().expect("temporary directory"); + let path = temp.path().join("root.age"); + AgeRootKeystore::create(&path, &material(), passphrase(SENTINEL)).expect("create keystore"); + + let wrong = + AgeRootKeystore::unlock(&path, passphrase("wrong")).expect_err("wrong passphrase rejected"); + let mut bytes = fs::read(&path).expect("ciphertext"); + bytes.truncate(bytes.len() / 2); + fs::write(&path, bytes).expect("truncate fixture"); + let truncated = + AgeRootKeystore::unlock(&path, passphrase(SENTINEL)).expect_err("truncation rejected"); + + assert_eq!(wrong, BootstrapError::InvalidKeystore); + assert_eq!(truncated, BootstrapError::InvalidKeystore); + let rendered = format!("{wrong:?} {wrong} {truncated:?} {truncated}"); + assert!(!rendered.contains(SENTINEL)); + assert!(!rendered.contains(path.to_string_lossy().as_ref())); +} + +#[test] +fn root_keystore_rejects_unknown_version_oversize_nonregular_and_symlink() { + let temp = TempDir::new().expect("temporary directory"); + let unknown = temp.path().join("unknown.age"); + fs::write(&unknown, b"AGENET-ROOT-KEYSTORE\0\x02junk").expect("unknown version fixture"); + assert_eq!( + AgeRootKeystore::unlock(&unknown, passphrase(SENTINEL)).expect_err("unknown rejected"), + BootstrapError::InvalidKeystore + ); + + let oversized = temp.path().join("oversized.age"); + fs::write(&oversized, vec![0_u8; AgeRootKeystore::MAX_FILE_BYTES + 1]) + .expect("oversize fixture"); + assert_eq!( + AgeRootKeystore::unlock(&oversized, passphrase(SENTINEL)).expect_err("oversize rejected"), + BootstrapError::InvalidKeystore + ); + assert_eq!( + AgeRootKeystore::unlock(temp.path(), passphrase(SENTINEL)).expect_err("directory rejected"), + BootstrapError::InvalidKeystore + ); + let fifo = temp.path().join("fifo"); + assert!( + Command::new("mkfifo") + .arg(&fifo) + .status() + .expect("run mkfifo") + .success() + ); + assert_eq!( + AgeRootKeystore::unlock(&fifo, passphrase(SENTINEL)).expect_err("FIFO rejected"), + BootstrapError::InvalidKeystore + ); + + let real = temp.path().join("real.age"); + AgeRootKeystore::create(&real, &material(), passphrase(SENTINEL)).expect("real keystore"); + let link = temp.path().join("link.age"); + std::os::unix::fs::symlink(&real, &link).expect("symlink fixture"); + assert_eq!( + AgeRootKeystore::unlock(&link, passphrase(SENTINEL)).expect_err("symlink rejected"), + BootstrapError::InvalidKeystore + ); + assert_eq!( + AgeRootKeystore::create(&link, &material(), passphrase(SENTINEL)), + Err(BootstrapError::StorageFailed) + ); +} + +#[test] +fn domain_root_debug_is_redacted_and_zeroize_boundary_is_available() { + let mut root_material = material(); + let rendered = format!("{root_material:?}"); + assert!(!rendered.contains(&base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + [42; 32] + ))); + root_material.zeroize(); + assert_eq!(root_material.signing_key.to_bytes(), [0; 32]); + assert_eq!(root_material.created_at_ms, 0); + assert_eq!(root_material.domain_id.as_str(), "domain:task4"); + let _: zeroize::Zeroizing = zeroize::Zeroizing::new(material()); +} diff --git a/tests/pki.rs b/tests/pki.rs new file mode 100644 index 0000000..b5c3439 --- /dev/null +++ b/tests/pki.rs @@ -0,0 +1,279 @@ +use std::{ + fs, + net::{IpAddr, Ipv4Addr}, + os::unix::fs::PermissionsExt, +}; + +use agenet::{ + bootstrap::{AGENET_NODE_ID_OID, AuthorityPki, BootstrapError, NodeTlsCsr}, + protocol::NodeId, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use rcgen::{CertificateParams, CustomExtension, KeyPair}; +use tempfile::TempDir; +use x509_parser::{extensions::GeneralName, prelude::*}; + +const START_MS: i64 = 1_800_000_000_000; +const CA_END_MS: i64 = START_MS + 86_400_000; + +fn parse_cert(pem: &str) -> X509Certificate<'_> { + let (_, pem) = parse_x509_pem(pem.as_bytes()).expect("PEM certificate"); + let contents = Box::leak(pem.contents.into_boxed_slice()); + let (_, cert) = X509Certificate::from_der(contents).expect("DER certificate"); + cert +} + +fn parse_csr(pem: &str) -> X509CertificationRequest<'_> { + let (_, pem) = parse_x509_pem(pem.as_bytes()).expect("PEM CSR"); + let contents = Box::leak(pem.contents.into_boxed_slice()); + let (_, csr) = X509CertificationRequest::from_der(contents).expect("DER CSR"); + csr +} + +fn tamper_csr_signature(pem: &str) -> String { + let body: String = pem + .lines() + .filter(|line| !line.starts_with("-----")) + .collect(); + let mut der = STANDARD.decode(body).expect("CSR base64"); + *der.last_mut().expect("CSR signature byte") ^= 1; + let encoded = STANDARD.encode(der); + let lines = encoded + .as_bytes() + .chunks(64) + .map(|chunk| std::str::from_utf8(chunk).expect("base64")) + .collect::>() + .join("\n"); + format!("-----BEGIN CERTIFICATE REQUEST-----\n{lines}\n-----END CERTIFICATE REQUEST-----\n") +} + +#[test] +fn authority_ca_has_ca_constraints_key_usage_and_der_fingerprint() { + let pki = AuthorityPki::generate(START_MS, CA_END_MS).expect("generate Authority PKI"); + let ca = parse_cert(&pki.ca_cert_pem); + + assert!( + ca.basic_constraints() + .expect("constraints parse") + .expect("constraints") + .value + .ca + ); + let usage = ca + .key_usage() + .expect("key usage parse") + .expect("key usage") + .value; + assert!(usage.key_cert_sign()); + assert!(usage.crl_sign()); + assert_eq!(pki.fingerprint_sha256.len(), 64); + assert!( + pki.fingerprint_sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + ); + assert_eq!( + pki.fingerprint_sha256, + AuthorityPki::fingerprint_der(ca.as_raw()) + ); + assert!(!format!("{pki:?}").contains("PRIVATE KEY")); +} + +#[test] +fn server_certificate_has_only_exact_ip_san_and_server_auth() { + let pki = AuthorityPki::generate(START_MS, CA_END_MS).expect("generate Authority PKI"); + let ip = IpAddr::V4(Ipv4Addr::new(100, 100, 10, 20)); + let leaf = pki + .issue_server(ip, START_MS + 1_000, CA_END_MS - 1_000) + .expect("server cert"); + let cert = parse_cert(&leaf.cert_pem); + let ca = parse_cert(&pki.ca_cert_pem); + let san = cert + .subject_alternative_name() + .expect("SAN parse") + .expect("SAN"); + + assert_eq!( + san.value.general_names, + vec![GeneralName::IPAddress(&[100, 100, 10, 20])] + ); + let eku = cert + .extended_key_usage() + .expect("EKU parse") + .expect("EKU") + .value; + assert!(eku.server_auth); + assert!(!eku.client_auth); + assert!( + !cert + .basic_constraints() + .expect("constraints parse") + .expect("constraints") + .value + .ca + ); + cert.verify_signature(Some(ca.public_key())) + .expect("Authority signature"); + assert!(cert.validity().not_after.timestamp() <= ca.validity().not_after.timestamp()); +} + +#[test] +fn client_certificate_binds_trusted_node_id_and_csr_public_key() { + let pki = AuthorityPki::generate(START_MS, CA_END_MS).expect("generate Authority PKI"); + let csr = NodeTlsCsr::generate().expect("generate node CSR"); + let node_id = NodeId::new("node:trusted").expect("node id"); + let leaf = pki + .issue_client(&csr.csr_pem, &node_id, START_MS + 1_000, CA_END_MS - 1_000) + .expect("client cert"); + let cert = parse_cert(&leaf.cert_pem); + let ca = parse_cert(&pki.ca_cert_pem); + let parsed_csr = parse_csr(&csr.csr_pem); + let extension = cert + .extensions() + .iter() + .find(|ext| ext.oid.to_id_string() == AGENET_NODE_ID_OID) + .expect("identity extension"); + + assert_eq!(extension.value, b"\x0c\x0cnode:trusted"); + let eku = cert + .extended_key_usage() + .expect("EKU parse") + .expect("EKU") + .value; + assert!(eku.client_auth); + assert!(!eku.server_auth); + assert!( + cert.subject_alternative_name() + .expect("SAN parse") + .is_none() + ); + assert!( + !cert + .basic_constraints() + .expect("constraints parse") + .expect("constraints") + .value + .ca + ); + assert_eq!( + cert.public_key().raw, + parsed_csr.certification_request_info.subject_pki.raw + ); + cert.verify_signature(Some(ca.public_key())) + .expect("Authority signature"); + assert!(cert.validity().not_after.timestamp() <= ca.validity().not_after.timestamp()); + assert!(!format!("{csr:?} {leaf:?}").contains("PRIVATE KEY")); + + let key = KeyPair::generate().expect("CA-requesting CSR key"); + let mut params = CertificateParams::default(); + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca_request = params + .serialize_request(&key) + .expect("CA-requesting CSR") + .pem() + .expect("CSR PEM"); + let issued = pki + .issue_client(&ca_request, &node_id, START_MS + 1_000, CA_END_MS - 1_000) + .expect("safe client cert"); + let issued = parse_cert(&issued.cert_pem); + assert!( + !issued + .basic_constraints() + .expect("constraints parse") + .expect("constraints") + .value + .ca + ); +} + +#[test] +fn pki_rejects_invalid_validity_tampered_csr_and_unsupported_extension() { + let pki = AuthorityPki::generate(START_MS, CA_END_MS).expect("generate Authority PKI"); + let node_id = NodeId::new("node:trusted").expect("node id"); + assert_eq!( + pki.issue_server(IpAddr::V4(Ipv4Addr::LOCALHOST), START_MS, CA_END_MS + 1) + .expect_err("leaf beyond CA rejected"), + BootstrapError::InvalidPki + ); + assert_eq!( + pki.issue_server(IpAddr::V4(Ipv4Addr::LOCALHOST), CA_END_MS, START_MS) + .expect_err("reversed range rejected"), + BootstrapError::InvalidPki + ); + assert_eq!( + AuthorityPki::generate(i64::MIN, i64::MAX).expect_err("unrepresentable range rejected"), + BootstrapError::InvalidPki + ); + + let csr = NodeTlsCsr::generate().expect("generate node CSR"); + let tampered = tamper_csr_signature(&csr.csr_pem); + assert!(parse_csr(&tampered).verify_signature().is_err()); + assert_eq!( + pki.issue_client(&tampered, &node_id, START_MS, CA_END_MS) + .expect_err("tampered CSR rejected"), + BootstrapError::InvalidPki + ); + + let key = KeyPair::generate().expect("CSR key"); + let mut params = CertificateParams::default(); + let mut extension = CustomExtension::from_oid_content(&[1, 2, 3, 4], vec![5, 0]); + extension.set_criticality(true); + params.custom_extensions.push(extension); + let unsupported = params + .serialize_request(&key) + .expect("unsupported CSR") + .pem() + .expect("CSR PEM"); + assert_eq!( + pki.issue_client(&unsupported, &node_id, START_MS, CA_END_MS) + .expect_err("unsupported extension rejected"), + BootstrapError::InvalidPki + ); +} + +#[test] +fn private_material_persistence_is_atomic_owner_only_and_non_overwriting() { + let temp = TempDir::new().expect("temporary directory"); + let pki = AuthorityPki::generate(START_MS, CA_END_MS).expect("generate Authority PKI"); + let path = temp.path().join("authority-ca.key"); + + pki.persist_ca_key(&path).expect("persist key"); + assert_eq!( + fs::metadata(&path).expect("metadata").permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_dir(temp.path()).expect("parent").count(), 1); + assert_eq!( + pki.persist_ca_key(&path), + Err(BootstrapError::StorageFailed) + ); + + let server = pki + .issue_server(IpAddr::V4(Ipv4Addr::LOCALHOST), START_MS, CA_END_MS) + .expect("server identity"); + let server_path = temp.path().join("server.key"); + server + .persist_private_key(&server_path) + .expect("persist server key"); + assert_eq!( + fs::metadata(&server_path) + .expect("server metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + + let node = NodeTlsCsr::generate().expect("node CSR"); + let node_path = temp.path().join("node.key"); + node.persist_private_key(&node_path) + .expect("persist node key"); + assert_eq!( + fs::metadata(&node_path) + .expect("node metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); +} From 719209cfc1a10033ce91159897265c733171266f Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 17:04:02 +0800 Subject: [PATCH 14/67] [bug] Fix encrypted PKI safety bounds Root cause: PKI keys and keystore costs relied on unsafe defaults. Solution: Zeroize rcgen keys, bound scrypt work, and normalize paths. Risks: Fixed N=2^17 raises interactive administrative unlock cost. Post-mortem: Security review exposed secret copies and unbounded inputs. Dependency: Bootstrap step 4. Links: docs/superpowers/specs/ 2026-08-14-node-bootstrap-and-pages-design.md --- src/bootstrap/keystore.rs | 17 ++++- src/bootstrap/pki.rs | 144 ++++++++++++++++++++++++++++++++++---- src/runtime/key_store.rs | 16 ++++- tests/admin_keystore.rs | 75 +++++++++++++++++++- tests/pki.rs | 84 +++++++++++++++++++++- tests/runtime_storage.rs | 45 +++++++++++- 6 files changed, 359 insertions(+), 22 deletions(-) diff --git a/src/bootstrap/keystore.rs b/src/bootstrap/keystore.rs index 1a01286..14eea0e 100644 --- a/src/bootstrap/keystore.rs +++ b/src/bootstrap/keystore.rs @@ -1,6 +1,6 @@ use std::{ fmt::{Debug, Formatter}, - fs::OpenOptions, + fs::{self, OpenOptions}, io::{Read, Write}, iter, os::unix::fs::OpenOptionsExt, @@ -17,6 +17,8 @@ use super::BootstrapError; const HEADER: &[u8] = b"AGENET-ROOT-KEYSTORE\0\x01"; const MAX_PLAINTEXT_BYTES: usize = 512; +// Version 1 fixes N=2^17 so unlock can reject larger attacker-controlled costs. +const V1_SCRYPT_LOG_N: u8 = 17; pub struct DomainRootMaterial { pub domain_id: DomainId, @@ -68,8 +70,16 @@ impl RootKeystore for AgeRootKeystore { material: &DomainRootMaterial, passphrase: SecretString, ) -> Result<(), BootstrapError> { + match fs::symlink_metadata(path) { + Ok(_) => return Err(BootstrapError::StorageFailed), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(BootstrapError::StorageFailed), + } let plaintext = encode_material(material)?; - let encryptor = Encryptor::with_user_passphrase(passphrase); + let mut recipient = age::scrypt::Recipient::new(passphrase); + recipient.set_work_factor(V1_SCRYPT_LOG_N); + let encryptor = Encryptor::with_recipients(iter::once(&recipient as &dyn age::Recipient)) + .map_err(|_| BootstrapError::StorageFailed)?; let mut ciphertext = Vec::new(); ciphertext.extend_from_slice(HEADER); { @@ -96,7 +106,8 @@ impl RootKeystore for AgeRootKeystore { .strip_prefix(HEADER) .ok_or(BootstrapError::InvalidKeystore)?; let decryptor = Decryptor::new(ciphertext).map_err(|_| BootstrapError::InvalidKeystore)?; - let identity = age::scrypt::Identity::new(passphrase); + let mut identity = age::scrypt::Identity::new(passphrase); + identity.set_max_work_factor(V1_SCRYPT_LOG_N); let mut reader = decryptor .decrypt(iter::once(&identity as _)) .map_err(|_| BootstrapError::InvalidKeystore)?; diff --git a/src/bootstrap/pki.rs b/src/bootstrap/pki.rs index c09d3d2..dc7e18c 100644 --- a/src/bootstrap/pki.rs +++ b/src/bootstrap/pki.rs @@ -4,19 +4,26 @@ use std::{ path::Path, }; +#[cfg(test)] +use std::sync::{Arc, atomic::AtomicUsize}; + +use base64::{Engine, engine::general_purpose::STANDARD}; use rcgen::{ BasicConstraints, CertificateParams, CertificateSigningRequestParams, CertifiedIssuer, - CustomExtension, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType, + CustomExtension, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, + PublicKeyData, SanType, SignatureAlgorithm, SigningKey, }; use sha2::{Digest, Sha256}; use time::OffsetDateTime; -use zeroize::Zeroizing; +use zeroize::{Zeroize, Zeroizing}; use crate::{protocol::NodeId, runtime::key_store::atomic_write_owner_only}; use super::BootstrapError; -/// UUID-derived private OID for the Authority-written AgenNet NodeId binding. +/// Developer Preview experimental `2.25` syntax identifier for NodeId binding. +/// +/// This value has no registered namespace or global-uniqueness provenance. pub const AGENET_NODE_ID_OID: &str = "2.25.9029276719620050359"; const AGENET_NODE_ID_OID_COMPONENTS: &[u64] = &[2, 25, 9029276719620050359]; @@ -24,11 +31,84 @@ pub struct AuthorityPki { pub ca_cert_pem: Zeroizing, pub ca_key_pem: Zeroizing, pub fingerprint_sha256: String, - issuer: CertifiedIssuer<'static, KeyPair>, + issuer: CertifiedIssuer<'static, ZeroizingKeyPair>, not_before_ms: i64, not_after_ms: i64, } +struct ZeroizingKeyPair { + inner: KeyPair, + zeroized: bool, + #[cfg(test)] + zeroize_counter: Option>, +} + +impl ZeroizingKeyPair { + fn generate() -> Result { + Ok(Self { + inner: KeyPair::generate()?, + zeroized: false, + #[cfg(test)] + zeroize_counter: None, + }) + } + + fn private_key_pem(&self) -> Zeroizing { + let der = self.inner.serialized_der(); + let mut pem = Zeroizing::new(String::with_capacity(der.len() * 2)); + pem.push_str("-----BEGIN PRIVATE KEY-----\n"); + for chunk in der.chunks(48) { + STANDARD.encode_string(chunk, &mut pem); + pem.push('\n'); + } + pem.push_str("-----END PRIVATE KEY-----\n"); + pem + } + + #[cfg(test)] + fn generate_with_counter(counter: Arc) -> Result { + let mut key = Self::generate()?; + key.zeroize_counter = Some(counter); + Ok(key) + } +} + +impl PublicKeyData for ZeroizingKeyPair { + fn der_bytes(&self) -> &[u8] { + self.inner.der_bytes() + } + + fn algorithm(&self) -> &'static SignatureAlgorithm { + self.inner.algorithm() + } +} + +impl SigningKey for ZeroizingKeyPair { + fn sign(&self, message: &[u8]) -> Result, rcgen::Error> { + self.inner.sign(message) + } +} + +impl Zeroize for ZeroizingKeyPair { + fn zeroize(&mut self) { + if self.zeroized { + return; + } + self.inner.zeroize(); + self.zeroized = true; + #[cfg(test)] + if let Some(counter) = &self.zeroize_counter { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } +} + +impl Drop for ZeroizingKeyPair { + fn drop(&mut self) { + self.zeroize(); + } +} + impl Debug for AuthorityPki { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { formatter @@ -92,8 +172,8 @@ impl Debug for NodeTlsCsr { impl AuthorityPki { pub fn generate(not_before_ms: i64, not_after_ms: i64) -> Result { let (not_before, not_after) = valid_range(not_before_ms, not_after_ms)?; - let key = KeyPair::generate().map_err(|_| BootstrapError::InvalidPki)?; - let ca_key_pem = Zeroizing::new(key.serialize_pem()); + let key = ZeroizingKeyPair::generate().map_err(|_| BootstrapError::InvalidPki)?; + let ca_key_pem = key.private_key_pem(); let mut params = CertificateParams::default(); params.not_before = not_before; params.not_after = not_after; @@ -134,8 +214,8 @@ impl AuthorityPki { not_after_ms: i64, ) -> Result { let (not_before, not_after) = self.valid_leaf_range(not_before_ms, not_after_ms)?; - let key = KeyPair::generate().map_err(|_| BootstrapError::InvalidPki)?; - let private_key_pem = Zeroizing::new(key.serialize_pem()); + let key = ZeroizingKeyPair::generate().map_err(|_| BootstrapError::InvalidPki)?; + let private_key_pem = key.private_key_pem(); let mut params = CertificateParams::default(); params.not_before = not_before; params.not_after = not_after; @@ -168,10 +248,12 @@ impl AuthorityPki { params.is_ca = IsCa::ExplicitNoCa; params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; - params.custom_extensions = vec![CustomExtension::from_oid_content( + let mut node_id_extension = CustomExtension::from_oid_content( AGENET_NODE_ID_OID_COMPONENTS, der_utf8_string(node_id.as_str())?, - )]; + ); + node_id_extension.set_criticality(false); + params.custom_extensions = vec![node_id_extension]; let request = CertificateSigningRequestParams { params, public_key: parsed.public_key, @@ -203,13 +285,13 @@ impl AuthorityPki { impl NodeTlsCsr { pub fn generate() -> Result { - let key = KeyPair::generate().map_err(|_| BootstrapError::InvalidPki)?; + let key = ZeroizingKeyPair::generate().map_err(|_| BootstrapError::InvalidPki)?; let request = CertificateParams::default() .serialize_request(&key) .map_err(|_| BootstrapError::InvalidPki)?; Ok(Self { csr_pem: request.pem().map_err(|_| BootstrapError::InvalidPki)?, - private_key_pem: Zeroizing::new(key.serialize_pem()), + private_key_pem: key.private_key_pem(), }) } @@ -234,6 +316,9 @@ fn valid_range( .map_err(|_| BootstrapError::InvalidPki)?; let not_after = OffsetDateTime::from_unix_timestamp_nanos(i128::from(not_after_ms) * 1_000_000) .map_err(|_| BootstrapError::InvalidPki)?; + if not_before.unix_timestamp() >= not_after.unix_timestamp() { + return Err(BootstrapError::InvalidPki); + } Ok((not_before, not_after)) } @@ -250,3 +335,38 @@ fn der_utf8_string(value: &str) -> Result, BootstrapError> { encoded.extend_from_slice(value.as_bytes()); Ok(encoded) } + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use zeroize::Zeroize; + + use super::ZeroizingKeyPair; + + #[test] + fn zeroizing_key_pair_drop_invokes_zeroize_once() { + let counter = Arc::new(AtomicUsize::new(0)); + { + let _key = ZeroizingKeyPair::generate_with_counter(Arc::clone(&counter)) + .expect("generate zeroizing key"); + } + assert_eq!(counter.load(Ordering::SeqCst), 1); + } + + #[test] + fn explicit_zeroize_is_idempotent_with_drop() { + let counter = Arc::new(AtomicUsize::new(0)); + let mut key = ZeroizingKeyPair::generate_with_counter(Arc::clone(&counter)) + .expect("generate zeroizing key"); + + key.zeroize(); + assert_eq!(counter.load(Ordering::SeqCst), 1); + assert!(key.inner.serialized_der().is_empty()); + drop(key); + assert_eq!(counter.load(Ordering::SeqCst), 1); + } +} diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index 1668836..6d487f7 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -39,14 +39,15 @@ pub(crate) fn atomic_write_owner_only( bytes: &[u8], replace: bool, ) -> Result<(), RuntimeError> { - let parent = path.parent().ok_or(RuntimeError::Io)?; + let parent = normalized_parent(path)?; let file_name = path.file_name().ok_or(RuntimeError::Io)?; + let final_path = parent.join(file_name); let temp_path = parent.join(format!( ".{}.{}.tmp", file_name.to_string_lossy(), uuid::Uuid::new_v4() )); - let result = write_and_publish(&temp_path, path, bytes, replace); + let result = write_and_publish(parent, &temp_path, &final_path, bytes, replace); if result.is_err() { let _ = fs::remove_file(&temp_path); } @@ -54,6 +55,7 @@ pub(crate) fn atomic_write_owner_only( } fn write_and_publish( + parent: &Path, temp_path: &Path, path: &Path, bytes: &[u8], @@ -75,7 +77,15 @@ fn write_and_publish( fs::hard_link(temp_path, path)?; fs::remove_file(temp_path)?; } - sync_directory(path.parent().ok_or(RuntimeError::Io)?) + sync_directory(parent) +} + +fn normalized_parent(path: &Path) -> Result<&Path, RuntimeError> { + let parent = path.parent().ok_or(RuntimeError::Io)?; + if parent.as_os_str().is_empty() { + return Ok(Path::new(".")); + } + Ok(parent) } fn sync_directory(path: &Path) -> Result<(), RuntimeError> { diff --git a/tests/admin_keystore.rs b/tests/admin_keystore.rs index ad086f8..d0ec029 100644 --- a/tests/admin_keystore.rs +++ b/tests/admin_keystore.rs @@ -1,4 +1,11 @@ -use std::{fs, os::unix::fs::PermissionsExt, process::Command}; +use std::{ + env, fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::Command, + sync::Mutex, + time::{Duration, Instant}, +}; use age::secrecy::SecretString; use agenet::{ @@ -10,6 +17,23 @@ use tempfile::TempDir; use zeroize::Zeroize; const SENTINEL: &str = "task4-sentinel-passphrase-never-leak"; +static ADMIN_TEST_LOCK: Mutex<()> = Mutex::new(()); + +struct CurrentDirGuard(PathBuf); + +impl CurrentDirGuard { + fn enter(path: &Path) -> Self { + let original = env::current_dir().expect("current directory"); + env::set_current_dir(path).expect("enter temporary directory"); + Self(original) + } +} + +impl Drop for CurrentDirGuard { + fn drop(&mut self) { + env::set_current_dir(&self.0).expect("restore current directory"); + } +} fn material() -> DomainRootMaterial { DomainRootMaterial { @@ -25,6 +49,7 @@ fn passphrase(value: &str) -> SecretString { #[test] fn root_keystore_round_trips_with_owner_only_atomic_creation() { + let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); let temp = TempDir::new().expect("temporary directory"); let path = temp.path().join("root.age"); @@ -47,6 +72,7 @@ fn root_keystore_round_trips_with_owner_only_atomic_creation() { #[test] fn root_keystore_failures_are_payload_free_and_uniform() { + let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); let temp = TempDir::new().expect("temporary directory"); let path = temp.path().join("root.age"); AgeRootKeystore::create(&path, &material(), passphrase(SENTINEL)).expect("create keystore"); @@ -68,6 +94,7 @@ fn root_keystore_failures_are_payload_free_and_uniform() { #[test] fn root_keystore_rejects_unknown_version_oversize_nonregular_and_symlink() { + let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); let temp = TempDir::new().expect("temporary directory"); let unknown = temp.path().join("unknown.age"); fs::write(&unknown, b"AGENET-ROOT-KEYSTORE\0\x02junk").expect("unknown version fixture"); @@ -101,7 +128,7 @@ fn root_keystore_rejects_unknown_version_oversize_nonregular_and_symlink() { ); let real = temp.path().join("real.age"); - AgeRootKeystore::create(&real, &material(), passphrase(SENTINEL)).expect("real keystore"); + fs::write(&real, b"symlink target need not be a keystore").expect("real file"); let link = temp.path().join("link.age"); std::os::unix::fs::symlink(&real, &link).expect("symlink fixture"); assert_eq!( @@ -128,3 +155,47 @@ fn domain_root_debug_is_redacted_and_zeroize_boundary_is_available() { assert_eq!(root_material.domain_id.as_str(), "domain:task4"); let _: zeroize::Zeroizing = zeroize::Zeroizing::new(material()); } + +#[test] +fn bare_relative_keystore_path_is_atomic_owner_only_and_non_overwriting() { + let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); + let temp = TempDir::new().expect("temporary directory"); + let _cwd = CurrentDirGuard::enter(temp.path()); + let path = Path::new("root.age"); + + AgeRootKeystore::create(path, &material(), passphrase(SENTINEL)) + .expect("create bare relative keystore"); + assert_eq!( + fs::metadata(path).expect("metadata").permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_dir(".").expect("directory entries").count(), 1); + assert_eq!( + AgeRootKeystore::create(path, &material(), passphrase(SENTINEL)), + Err(BootstrapError::StorageFailed) + ); + assert_eq!(fs::read_dir(".").expect("directory entries").count(), 1); + AgeRootKeystore::unlock(path, passphrase(SENTINEL)).expect("unlock relative keystore"); +} + +#[test] +fn keystore_v1_rejects_higher_scrypt_factor_before_running_kdf() { + let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); + let temp = TempDir::new().expect("temporary directory"); + let path = temp.path().join("root.age"); + AgeRootKeystore::create(&path, &material(), passphrase(SENTINEL)).expect("create keystore"); + let mut bytes = fs::read(&path).expect("ciphertext"); + let factor = bytes + .windows(4) + .position(|window| window == b" 17\n") + .expect("v1 work factor is fixed at 17"); + bytes[factor + 2] = b'8'; + fs::write(&path, bytes).expect("malicious higher-factor fixture"); + + let started = Instant::now(); + assert_eq!( + AgeRootKeystore::unlock(&path, passphrase(SENTINEL)).expect_err("higher factor rejected"), + BootstrapError::InvalidKeystore + ); + assert!(started.elapsed() < Duration::from_millis(750)); +} diff --git a/tests/pki.rs b/tests/pki.rs index b5c3439..663fde6 100644 --- a/tests/pki.rs +++ b/tests/pki.rs @@ -1,5 +1,6 @@ use std::{ fs, + io::Cursor, net::{IpAddr, Ipv4Addr}, os::unix::fs::PermissionsExt, }; @@ -9,7 +10,7 @@ use agenet::{ protocol::NodeId, }; use base64::{Engine, engine::general_purpose::STANDARD}; -use rcgen::{CertificateParams, CustomExtension, KeyPair}; +use rcgen::{CertificateParams, CustomExtension, KeyPair, PublicKeyData}; use tempfile::TempDir; use x509_parser::{extensions::GeneralName, prelude::*}; @@ -66,6 +67,21 @@ fn authority_ca_has_ca_constraints_key_usage_and_der_fingerprint() { .value; assert!(usage.key_cert_sign()); assert!(usage.crl_sign()); + assert_eq!( + ca.basic_constraints() + .expect("constraints parse") + .expect("constraints") + .value + .path_len_constraint, + Some(0) + ); + ca.verify_signature(None).expect("self-signed CA signature"); + KeyPair::from_pem(&pki.ca_key_pem).expect("rcgen parses CA private key"); + assert!( + rustls_pemfile::private_key(&mut Cursor::new(pki.ca_key_pem.as_bytes())) + .expect("rustls parses CA private key") + .is_some() + ); assert_eq!(pki.fingerprint_sha256.len(), 64); assert!( pki.fingerprint_sha256 @@ -88,6 +104,7 @@ fn server_certificate_has_only_exact_ip_san_and_server_auth() { .expect("server cert"); let cert = parse_cert(&leaf.cert_pem); let ca = parse_cert(&pki.ca_cert_pem); + let server_key = KeyPair::from_pem(&leaf.private_key_pem).expect("server private key"); let san = cert .subject_alternative_name() .expect("SAN parse") @@ -115,6 +132,12 @@ fn server_certificate_has_only_exact_ip_san_and_server_auth() { cert.verify_signature(Some(ca.public_key())) .expect("Authority signature"); assert!(cert.validity().not_after.timestamp() <= ca.validity().not_after.timestamp()); + assert_eq!(cert.public_key().raw, server_key.subject_public_key_info()); + assert!( + rustls_pemfile::private_key(&mut Cursor::new(leaf.private_key_pem.as_bytes())) + .expect("rustls parses server private key") + .is_some() + ); } #[test] @@ -128,6 +151,7 @@ fn client_certificate_binds_trusted_node_id_and_csr_public_key() { let cert = parse_cert(&leaf.cert_pem); let ca = parse_cert(&pki.ca_cert_pem); let parsed_csr = parse_csr(&csr.csr_pem); + let node_key = KeyPair::from_pem(&csr.private_key_pem).expect("node private key"); let extension = cert .extensions() .iter() @@ -135,6 +159,7 @@ fn client_certificate_binds_trusted_node_id_and_csr_public_key() { .expect("identity extension"); assert_eq!(extension.value, b"\x0c\x0cnode:trusted"); + assert!(!extension.critical); let eku = cert .extended_key_usage() .expect("EKU parse") @@ -159,6 +184,12 @@ fn client_certificate_binds_trusted_node_id_and_csr_public_key() { cert.public_key().raw, parsed_csr.certification_request_info.subject_pki.raw ); + assert_eq!(cert.public_key().raw, node_key.subject_public_key_info()); + assert!( + rustls_pemfile::private_key(&mut Cursor::new(csr.private_key_pem.as_bytes())) + .expect("rustls parses node private key") + .is_some() + ); cert.verify_signature(Some(ca.public_key())) .expect("Authority signature"); assert!(cert.validity().not_after.timestamp() <= ca.validity().not_after.timestamp()); @@ -184,6 +215,38 @@ fn client_certificate_binds_trusted_node_id_and_csr_public_key() { .value .ca ); + + let key = KeyPair::generate().expect("SAN-requesting CSR key"); + let mut params = CertificateParams::new(vec!["untrusted.example".to_owned()]) + .expect("SAN-requesting params"); + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + let untrusted_request = params + .serialize_request(&key) + .expect("SAN-requesting CSR") + .pem() + .expect("CSR PEM"); + let issued = pki + .issue_client( + &untrusted_request, + &node_id, + START_MS + 1_000, + CA_END_MS - 1_000, + ) + .expect("Authority-scoped client cert"); + let issued = parse_cert(&issued.cert_pem); + assert!( + issued + .subject_alternative_name() + .expect("SAN parse") + .is_none() + ); + let eku = issued + .extended_key_usage() + .expect("EKU parse") + .expect("EKU") + .value; + assert!(eku.client_auth); + assert!(!eku.server_auth); } #[test] @@ -231,6 +294,25 @@ fn pki_rejects_invalid_validity_tampered_csr_and_unsupported_extension() { ); } +#[test] +fn pki_validity_fails_closed_at_x509_second_precision() { + assert_eq!( + AuthorityPki::generate(0, 999).expect_err("same-second CA rejected"), + BootstrapError::InvalidPki + ); + AuthorityPki::generate(0, 1_000).expect("one-second CA accepted"); + AuthorityPki::generate(-1_000, 0).expect("negative timestamp range accepted"); + + let pki = AuthorityPki::generate(0, 2_000).expect("two-second CA"); + pki.issue_server(IpAddr::V4(Ipv4Addr::LOCALHOST), 999, 1_000) + .expect("cross-second leaf accepted"); + assert_eq!( + pki.issue_server(IpAddr::V4(Ipv4Addr::LOCALHOST), 1_000, 1_999) + .expect_err("same-second leaf rejected"), + BootstrapError::InvalidPki + ); +} + #[test] fn private_material_persistence_is_atomic_owner_only_and_non_overwriting() { let temp = TempDir::new().expect("temporary directory"); diff --git a/tests/runtime_storage.rs b/tests/runtime_storage.rs index ae2002a..df1bc24 100644 --- a/tests/runtime_storage.rs +++ b/tests/runtime_storage.rs @@ -1,6 +1,11 @@ mod common; -use std::{fs, os::unix::fs::PermissionsExt}; +use std::{ + env, fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + sync::Mutex, +}; use agenet::{ protocol::{ @@ -14,6 +19,23 @@ use ed25519_dalek::SigningKey; use tempfile::TempDir; const NOW: u64 = 1_800_000_000; +static CURRENT_DIR_LOCK: Mutex<()> = Mutex::new(()); + +struct CurrentDirGuard(PathBuf); + +impl CurrentDirGuard { + fn enter(path: &Path) -> Self { + let original = env::current_dir().expect("current directory"); + env::set_current_dir(path).expect("enter temporary directory"); + Self(original) + } +} + +impl Drop for CurrentDirGuard { + fn drop(&mut self) { + env::set_current_dir(&self.0).expect("restore current directory"); + } +} fn signing_key(byte: u8) -> SigningKey { SigningKey::from_bytes(&[byte; 32]) @@ -43,6 +65,27 @@ fn signing_keys_are_stored_with_owner_only_permissions() { assert_eq!(read_signing_key(&path).unwrap().to_bytes(), key.to_bytes()); } +#[test] +fn bare_relative_signing_key_path_atomically_replaces_existing_key() { + let _lock = CURRENT_DIR_LOCK.lock().expect("current-directory lock"); + let temp = TempDir::new().expect("temporary directory"); + let _cwd = CurrentDirGuard::enter(temp.path()); + let path = Path::new("identity.key"); + + write_signing_key(path, &signing_key(7)).expect("initial relative write"); + write_signing_key(path, &signing_key(8)).expect("replacement relative write"); + + assert_eq!( + read_signing_key(path).expect("read replacement").to_bytes(), + [8; 32] + ); + assert_eq!( + fs::metadata(path).expect("metadata").permissions().mode() & 0o777, + 0o600 + ); + assert_eq!(fs::read_dir(".").expect("directory entries").count(), 1); +} + #[test] fn artifact_store_is_content_addressed_and_detects_tampering() { let temp = TempDir::new().unwrap(); From 6a644420d552b4ce66e6135bc176995f0287a5a1 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 17:43:10 +0800 Subject: [PATCH 15/67] [bug] Preserve legacy root keystores Root cause: Fixed-cost writes reused the v1 header and default unlock rejected persisted v1 keystores emitted with age dynamic factors. Solution: Emit fixed factor 17 as v2, retain bounded v1 recovery, and add typed opt-in offline migration policies with real fixtures. Risks: Extended legacy migration can consume about 512 MiB to 1 GiB; it is explicit, offline-only, and never used by the default unlock path. Dependency: age 0.12.1 work-factor enforcement and commit a6ed86a. Links: task-4-report.md and the node bootstrap design specification. Post-mortem: Persistent-format semantics require a version bump and a real prior-version fixture before tightening any reader policy. --- src/bootstrap/keystore.rs | 204 +++++++++++++++--- src/bootstrap/mod.rs | 5 +- tests/admin_keystore.rs | 128 +++++++++-- .../task4/legacy-v1-a6ed86a-factor19.age.b64 | 1 + .../fixtures/task4/legacy-v1-a6ed86a.age.b64 | 1 + .../task4/v2-excessive-factor18.age.b64 | 1 + 6 files changed, 300 insertions(+), 40 deletions(-) create mode 100644 tests/fixtures/task4/legacy-v1-a6ed86a-factor19.age.b64 create mode 100644 tests/fixtures/task4/legacy-v1-a6ed86a.age.b64 create mode 100644 tests/fixtures/task4/v2-excessive-factor18.age.b64 diff --git a/src/bootstrap/keystore.rs b/src/bootstrap/keystore.rs index 14eea0e..09a3be5 100644 --- a/src/bootstrap/keystore.rs +++ b/src/bootstrap/keystore.rs @@ -15,10 +15,14 @@ use crate::{protocol::DomainId, runtime::key_store::atomic_write_owner_only}; use super::BootstrapError; -const HEADER: &[u8] = b"AGENET-ROOT-KEYSTORE\0\x01"; +const V1_HEADER: &[u8] = b"AGENET-ROOT-KEYSTORE\0\x01"; +const V2_HEADER: &[u8] = b"AGENET-ROOT-KEYSTORE\0\x02"; const MAX_PLAINTEXT_BYTES: usize = 512; -// Version 1 fixes N=2^17 so unlock can reject larger attacker-controlled costs. -const V1_SCRYPT_LOG_N: u8 = 17; +// V1 was emitted with age's machine-dependent default. This bounded exception only +// supports the verified Developer Preview fixture needed for explicit migration. +const LEGACY_V1_MAX_SCRYPT_LOG_N: u8 = 18; +// V2 fixes N=2^17 so unlock can reject larger attacker-controlled costs. +const V2_SCRYPT_LOG_N: u8 = 17; pub struct DomainRootMaterial { pub domain_id: DomainId, @@ -60,8 +64,91 @@ pub trait RootKeystore { #[derive(Debug, Clone, Copy, Default)] pub struct AgeRootKeystore; +/// AgenNet outer keystore version, independent of age's inner format version. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RootKeystoreFormatVersion { + /// Developer Preview format that requires an explicit migration decision. + LegacyV1, + /// Current fixed-cost format emitted by [`RootKeystore::create`]. + V2, +} + +/// Explicit resource ceiling for controlled, offline legacy-v1 recovery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegacyV1MigrationPolicy { + /// Recovers the verified Developer Preview factor-18 format. + VerifiedFactor18, + /// Extends recovery to factor 19, which can require roughly 512 MiB. + /// + /// Use only in a resource-controlled offline administrative process. This + /// option is not suitable for a daemon or an attacker-reachable request. + ExtendedFactor19, + /// Extends recovery to factor 20, which can require roughly 1 GiB. + /// + /// This is an emergency recovery ceiling for a resource-controlled offline + /// administrative process, never a daemon or request-path policy. + ExtendedFactor20, +} + +impl LegacyV1MigrationPolicy { + fn max_scrypt_log_n(self) -> u8 { + match self { + Self::VerifiedFactor18 => LEGACY_V1_MAX_SCRYPT_LOG_N, + Self::ExtendedFactor19 => 19, + Self::ExtendedFactor20 => 20, + } + } +} + +/// Material recovered for an explicit, caller-managed keystore migration. +pub struct UnlockedRootKeystore { + /// Zeroizing root material; never written back by the unlock operation. + pub material: Zeroizing, + /// Source format the caller must migrate explicitly. + pub format_version: RootKeystoreFormatVersion, +} + +impl Debug for UnlockedRootKeystore { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("UnlockedRootKeystore") + .field("material", &"[REDACTED]") + .field("format_version", &self.format_version) + .finish() + } +} + impl AgeRootKeystore { pub const MAX_FILE_BYTES: usize = 64 * 1024; + + /// Detects whether a valid outer header requires explicit legacy migration. + pub fn format_version(path: &Path) -> Result { + let bytes = read_regular_bounded(path)?; + detect_format(&bytes).map(|(version, _)| version) + } + + /// Unlocks legacy v1 solely for an explicit offline migration workflow. + /// + /// Unlike [`RootKeystore::unlock`], callers may opt into the substantially + /// more expensive factor-19 or emergency factor-20 ceiling. This method + /// rejects v2 before age processing and never rewrites or silently migrates + /// the source file. + pub fn unlock_legacy_v1_for_migration( + path: &Path, + passphrase: SecretString, + policy: LegacyV1MigrationPolicy, + ) -> Result { + let bytes = read_regular_bounded(path)?; + let (format_version, _) = detect_format(&bytes)?; + if format_version != RootKeystoreFormatVersion::LegacyV1 { + return Err(BootstrapError::InvalidKeystore); + } + let material = unlock_bytes(&bytes, passphrase, policy.max_scrypt_log_n())?; + Ok(UnlockedRootKeystore { + material, + format_version, + }) + } } impl RootKeystore for AgeRootKeystore { @@ -77,11 +164,11 @@ impl RootKeystore for AgeRootKeystore { } let plaintext = encode_material(material)?; let mut recipient = age::scrypt::Recipient::new(passphrase); - recipient.set_work_factor(V1_SCRYPT_LOG_N); + recipient.set_work_factor(V2_SCRYPT_LOG_N); let encryptor = Encryptor::with_recipients(iter::once(&recipient as &dyn age::Recipient)) .map_err(|_| BootstrapError::StorageFailed)?; let mut ciphertext = Vec::new(); - ciphertext.extend_from_slice(HEADER); + ciphertext.extend_from_slice(V2_HEADER); { let mut writer = encryptor .wrap_output(&mut ciphertext) @@ -102,26 +189,46 @@ impl RootKeystore for AgeRootKeystore { passphrase: SecretString, ) -> Result, BootstrapError> { let bytes = read_regular_bounded(path)?; - let ciphertext = bytes - .strip_prefix(HEADER) - .ok_or(BootstrapError::InvalidKeystore)?; - let decryptor = Decryptor::new(ciphertext).map_err(|_| BootstrapError::InvalidKeystore)?; - let mut identity = age::scrypt::Identity::new(passphrase); - identity.set_max_work_factor(V1_SCRYPT_LOG_N); - let mut reader = decryptor - .decrypt(iter::once(&identity as _)) - .map_err(|_| BootstrapError::InvalidKeystore)?; - let mut plaintext = Zeroizing::new(Vec::new()); - reader - .by_ref() - .take((MAX_PLAINTEXT_BYTES + 1) as u64) - .read_to_end(&mut plaintext) - .map_err(|_| BootstrapError::InvalidKeystore)?; - if plaintext.len() > MAX_PLAINTEXT_BYTES { - return Err(BootstrapError::InvalidKeystore); - } - decode_material(&plaintext).map(Zeroizing::new) + let (_, max_scrypt_log_n) = detect_format(&bytes)?; + unlock_bytes(&bytes, passphrase, max_scrypt_log_n) + } +} + +fn unlock_bytes( + bytes: &[u8], + passphrase: SecretString, + max_scrypt_log_n: u8, +) -> Result, BootstrapError> { + let ciphertext = &bytes[V1_HEADER.len()..]; + let decryptor = Decryptor::new(ciphertext).map_err(|_| BootstrapError::InvalidKeystore)?; + let mut identity = age::scrypt::Identity::new(passphrase); + identity.set_max_work_factor(max_scrypt_log_n); + let mut reader = decryptor + .decrypt(iter::once(&identity as _)) + .map_err(|_| BootstrapError::InvalidKeystore)?; + let mut plaintext = Zeroizing::new(Vec::new()); + reader + .by_ref() + .take((MAX_PLAINTEXT_BYTES + 1) as u64) + .read_to_end(&mut plaintext) + .map_err(|_| BootstrapError::InvalidKeystore)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(BootstrapError::InvalidKeystore); + } + decode_material(&plaintext).map(Zeroizing::new) +} + +fn detect_format(bytes: &[u8]) -> Result<(RootKeystoreFormatVersion, u8), BootstrapError> { + if bytes.starts_with(V1_HEADER) { + return Ok(( + RootKeystoreFormatVersion::LegacyV1, + LEGACY_V1_MAX_SCRYPT_LOG_N, + )); + } + if bytes.starts_with(V2_HEADER) { + return Ok((RootKeystoreFormatVersion::V2, V2_SCRYPT_LOG_N)); } + Err(BootstrapError::InvalidKeystore) } pub fn prompt_root_passphrase(prompt: &str) -> Result { @@ -199,3 +306,52 @@ fn read_regular_bounded(path: &Path) -> Result, BootstrapError> { } Ok(bytes) } + +#[cfg(test)] +mod tests { + use super::{ + BootstrapError, LEGACY_V1_MAX_SCRYPT_LOG_N, RootKeystoreFormatVersion, V2_SCRYPT_LOG_N, + detect_format, + }; + + #[test] + fn format_dispatch_table_uses_bounded_version_policies() { + let cases = [ + ( + b"AGENET-ROOT-KEYSTORE\0\x01payload".as_slice(), + Ok(( + RootKeystoreFormatVersion::LegacyV1, + LEGACY_V1_MAX_SCRYPT_LOG_N, + )), + ), + ( + b"AGENET-ROOT-KEYSTORE\0\x02payload".as_slice(), + Ok((RootKeystoreFormatVersion::V2, V2_SCRYPT_LOG_N)), + ), + ( + b"AGENET-ROOT-KEYSTORE\0\x03payload".as_slice(), + Err(BootstrapError::InvalidKeystore), + ), + ( + b"wrong-magic\x01payload".as_slice(), + Err(BootstrapError::InvalidKeystore), + ), + ]; + + for (bytes, expected) in cases { + assert_eq!(detect_format(bytes), expected); + } + assert_eq!( + super::LegacyV1MigrationPolicy::VerifiedFactor18.max_scrypt_log_n(), + 18 + ); + assert_eq!( + super::LegacyV1MigrationPolicy::ExtendedFactor19.max_scrypt_log_n(), + 19 + ); + assert_eq!( + super::LegacyV1MigrationPolicy::ExtendedFactor20.max_scrypt_log_n(), + 20 + ); + } +} diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 2835851..b7d9395 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -6,7 +6,10 @@ mod pki; use std::fmt::{Display, Formatter}; -pub use keystore::{AgeRootKeystore, DomainRootMaterial, RootKeystore, prompt_root_passphrase}; +pub use keystore::{ + AgeRootKeystore, DomainRootMaterial, LegacyV1MigrationPolicy, RootKeystore, + RootKeystoreFormatVersion, UnlockedRootKeystore, prompt_root_passphrase, +}; pub use pki::{ AGENET_NODE_ID_OID, AuthorityPki, IssuedClientCertificate, IssuedServerIdentity, NodeTlsCsr, }; diff --git a/tests/admin_keystore.rs b/tests/admin_keystore.rs index d0ec029..3801d36 100644 --- a/tests/admin_keystore.rs +++ b/tests/admin_keystore.rs @@ -4,14 +4,17 @@ use std::{ path::{Path, PathBuf}, process::Command, sync::Mutex, - time::{Duration, Instant}, }; use age::secrecy::SecretString; use agenet::{ - bootstrap::{AgeRootKeystore, BootstrapError, DomainRootMaterial, RootKeystore}, + bootstrap::{ + AgeRootKeystore, BootstrapError, DomainRootMaterial, LegacyV1MigrationPolicy, RootKeystore, + RootKeystoreFormatVersion, + }, protocol::DomainId, }; +use base64::Engine; use ed25519_dalek::SigningKey; use tempfile::TempDir; use zeroize::Zeroize; @@ -47,6 +50,80 @@ fn passphrase(value: &str) -> SecretString { SecretString::from(value.to_owned()) } +fn fixture_bytes(encoded: &str) -> Vec { + base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .expect("valid base64 fixture") +} + +#[test] +fn legacy_v1_factor18_fixture_from_a6ed86a_remains_unlockable() { + let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); + let temp = TempDir::new().expect("temporary directory"); + let path = temp.path().join("legacy-v1.age"); + fs::write( + &path, + fixture_bytes(include_str!("fixtures/task4/legacy-v1-a6ed86a.age.b64")), + ) + .expect("write public legacy fixture"); + + let unlocked = AgeRootKeystore::unlock(&path, passphrase("public-legacy-v1-test-passphrase")) + .expect("unlock pre-fix legacy v1 fixture"); + assert_eq!(unlocked.domain_id.as_str(), "domain:legacy-v1-fixture"); + assert_eq!(unlocked.signing_key.to_bytes(), [0x5a; 32]); + assert_eq!(unlocked.created_at_ms, 1_700_000_000_123); + assert_eq!( + AgeRootKeystore::format_version(&path), + Ok(RootKeystoreFormatVersion::LegacyV1) + ); +} + +#[test] +fn legacy_v1_policy_rejects_unverified_factor19_fixture() { + let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); + let temp = TempDir::new().expect("temporary directory"); + let path = temp.path().join("legacy-v1-factor19.age"); + fs::write( + &path, + fixture_bytes(include_str!( + "fixtures/task4/legacy-v1-a6ed86a-factor19.age.b64" + )), + ) + .expect("write public out-of-policy fixture"); + let original = fs::read(&path).expect("read original migration fixture"); + + assert_eq!( + AgeRootKeystore::format_version(&path), + Ok(RootKeystoreFormatVersion::LegacyV1) + ); + assert_eq!( + AgeRootKeystore::unlock(&path, passphrase("public-legacy-v1-test-passphrase")) + .expect_err("unverified factor above legacy migration policy rejected"), + BootstrapError::InvalidKeystore + ); + + let recovered = AgeRootKeystore::unlock_legacy_v1_for_migration( + &path, + passphrase("public-legacy-v1-test-passphrase"), + LegacyV1MigrationPolicy::ExtendedFactor19, + ) + .expect("explicit controlled migration recovers factor 19 fixture"); + assert_eq!( + recovered.format_version, + RootKeystoreFormatVersion::LegacyV1 + ); + assert_eq!( + recovered.material.domain_id.as_str(), + "domain:legacy-v1-fixture" + ); + assert_eq!(recovered.material.signing_key.to_bytes(), [0x5a; 32]); + assert_eq!(recovered.material.created_at_ms, 1_700_000_000_123); + assert_eq!(fs::read(&path).expect("read migration source"), original); + let rendered = format!("{recovered:?}"); + assert!(!rendered.contains("public-legacy-v1-test-passphrase")); + assert!(!rendered.contains(&base64::engine::general_purpose::STANDARD.encode([0x5a; 32]))); +} + #[test] fn root_keystore_round_trips_with_owner_only_atomic_creation() { let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); @@ -54,6 +131,23 @@ fn root_keystore_round_trips_with_owner_only_atomic_creation() { let path = temp.path().join("root.age"); AgeRootKeystore::create(&path, &material(), passphrase(SENTINEL)).expect("create keystore"); + assert_eq!( + AgeRootKeystore::format_version(&path), + Ok(RootKeystoreFormatVersion::V2) + ); + let original = fs::read(&path).expect("read v2 keystore"); + assert!(matches!( + AgeRootKeystore::unlock_legacy_v1_for_migration( + &path, + passphrase(SENTINEL), + LegacyV1MigrationPolicy::ExtendedFactor19, + ), + Err(BootstrapError::InvalidKeystore) + )); + assert_eq!( + fs::read(&path).expect("read unchanged v2 keystore"), + original + ); let unlocked = AgeRootKeystore::unlock(&path, passphrase(SENTINEL)).expect("unlock keystore"); assert_eq!(unlocked.domain_id.as_str(), "domain:task4"); @@ -97,11 +191,15 @@ fn root_keystore_rejects_unknown_version_oversize_nonregular_and_symlink() { let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); let temp = TempDir::new().expect("temporary directory"); let unknown = temp.path().join("unknown.age"); - fs::write(&unknown, b"AGENET-ROOT-KEYSTORE\0\x02junk").expect("unknown version fixture"); + fs::write(&unknown, b"AGENET-ROOT-KEYSTORE\0\x03junk").expect("unknown version fixture"); assert_eq!( AgeRootKeystore::unlock(&unknown, passphrase(SENTINEL)).expect_err("unknown rejected"), BootstrapError::InvalidKeystore ); + assert_eq!( + AgeRootKeystore::format_version(&unknown), + Err(BootstrapError::InvalidKeystore) + ); let oversized = temp.path().join("oversized.age"); fs::write(&oversized, vec![0_u8; AgeRootKeystore::MAX_FILE_BYTES + 1]) @@ -179,23 +277,23 @@ fn bare_relative_keystore_path_is_atomic_owner_only_and_non_overwriting() { } #[test] -fn keystore_v1_rejects_higher_scrypt_factor_before_running_kdf() { +fn keystore_v2_rejects_excessive_scrypt_factor_by_version_policy() { let _lock = ADMIN_TEST_LOCK.lock().expect("admin test lock"); let temp = TempDir::new().expect("temporary directory"); let path = temp.path().join("root.age"); - AgeRootKeystore::create(&path, &material(), passphrase(SENTINEL)).expect("create keystore"); - let mut bytes = fs::read(&path).expect("ciphertext"); - let factor = bytes - .windows(4) - .position(|window| window == b" 17\n") - .expect("v1 work factor is fixed at 17"); - bytes[factor + 2] = b'8'; - fs::write(&path, bytes).expect("malicious higher-factor fixture"); + fs::write( + &path, + fixture_bytes(include_str!("fixtures/task4/v2-excessive-factor18.age.b64")), + ) + .expect("write public malicious fixture"); - let started = Instant::now(); assert_eq!( - AgeRootKeystore::unlock(&path, passphrase(SENTINEL)).expect_err("higher factor rejected"), + AgeRootKeystore::format_version(&path), + Ok(RootKeystoreFormatVersion::V2) + ); + assert_eq!( + AgeRootKeystore::unlock(&path, passphrase("public-v2-malicious-test-passphrase")) + .expect_err("factor above v2 policy rejected"), BootstrapError::InvalidKeystore ); - assert!(started.elapsed() < Duration::from_millis(750)); } diff --git a/tests/fixtures/task4/legacy-v1-a6ed86a-factor19.age.b64 b/tests/fixtures/task4/legacy-v1-a6ed86a-factor19.age.b64 new file mode 100644 index 0000000..2f30929 --- /dev/null +++ b/tests/fixtures/task4/legacy-v1-a6ed86a-factor19.age.b64 @@ -0,0 +1 @@ +QUdFTkVULVJPT1QtS0VZU1RPUkUAAWFnZS1lbmNyeXB0aW9uLm9yZy92MQotPiBzY3J5cHQgS2ZUTE5ndG1ldWVodnYxWVdRYmV3ZyAxOQpWSGI0RXNKTlNJek9MTDVUV2N6ZEpxS2NPYWpJWCtPQXZPVGVBMGxuYnIwCi0tLSBuU0l5MjRKazBZcFl5K1F5NU9KbjBja0xoNzZiTXRpOS9FQmljNTllKzdBCtLRGvXFQC0xBKFBELfo9xEk9K3Oq866ikoSEClC0nXBMXfL/wawbmtKyzR4k8VEQbt+OnrsLOv3EEAXtchDo0CN2n4Y5wICdEtVncHfq30JlDhcuGr6R9XsBjourdf+8MR0 diff --git a/tests/fixtures/task4/legacy-v1-a6ed86a.age.b64 b/tests/fixtures/task4/legacy-v1-a6ed86a.age.b64 new file mode 100644 index 0000000..e8e6c89 --- /dev/null +++ b/tests/fixtures/task4/legacy-v1-a6ed86a.age.b64 @@ -0,0 +1 @@ +QUdFTkVULVJPT1QtS0VZU1RPUkUAAWFnZS1lbmNyeXB0aW9uLm9yZy92MQotPiBzY3J5cHQgUk5qYXNYdGpaSWkyRnhCdjBoNVMyUSAxOApROGZQMDU4bkJRcVBydWhaN1RFTG5LQ2FJME9Tb1RpbUdHRVR2NVErRnpZCi0tLSBtejdWQ3RGcThLWTJGYkNFMWMvNkVxQXVPb2VSay9ESGU4a0tQMGpGNUh3CjfYDRi2I3Q0OtNy1UsbzHLNz21KhYA1TEmtCpquzz+zGfpKRAU/VFfoFXeN5O299BmJeEFQErukufjTnjiT97sgwv7gnAXeZpLvkYWpigbg0tngYlokG9wo1Oynzj1H99uK diff --git a/tests/fixtures/task4/v2-excessive-factor18.age.b64 b/tests/fixtures/task4/v2-excessive-factor18.age.b64 new file mode 100644 index 0000000..55333cb --- /dev/null +++ b/tests/fixtures/task4/v2-excessive-factor18.age.b64 @@ -0,0 +1 @@ +QUdFTkVULVJPT1QtS0VZU1RPUkUAAmFnZS1lbmNyeXB0aW9uLm9yZy92MQotPiBzY3J5cHQgNzlZelhKVmVsSVBiVHorY2s5Ti9IUSAxOApxOENRdFVQME1aSDcyRUxhNXhhenVuQm9pUTBBVFNGODZLaUlsZ3pyd0hRCi0tLSBVYUp5RnBQL0srNnc4WGhKWDNZNExIUTU5WG5xaWxKNGdJaHRYK2lNMVRJCp4Hz/3vwGs7DxtokKuiu30Enx3fgrJqvrY/Z/JiH3Cbnd2eNgbZBVo3lKy7ENknmsxoMu0S3MklVAwF7hM37YyaYum917LqfJT4++/H60lVC3nDc7wQ6pWEVAus/3sX6vZo3Po5 From 26a01e9010f6d3c7da2dab36843e163372215f6e Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 18:38:54 +0800 Subject: [PATCH 16/67] [feat][Bootstrap][5/14] Add one-time invitations Root cause: NA Solution: Add durable HMAC invitations with expiration, lockout, reservation, consumption, replay, and idempotency. Risks: Durability still inherits the host volume's guarantees. Dependency: Bootstrap step 4. Links: plan/01-v1-multi-host-node-bootstrap.md --- ROADMAP.md | 11 + src/bootstrap/invitation.rs | 1339 +++++++++++++++++++++++++++++++++++ src/bootstrap/journal.rs | 195 +++++ src/bootstrap/mod.rs | 21 + src/protocol/mod.rs | 2 +- src/protocol/types.rs | 44 +- tests/invitation_store.rs | 740 +++++++++++++++++++ 7 files changed, 2350 insertions(+), 2 deletions(-) create mode 100644 src/bootstrap/invitation.rs create mode 100644 src/bootstrap/journal.rs create mode 100644 tests/invitation_store.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4742a34..7d0479f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,16 @@ # ROADMAP +## 2026-08-14 18:34 CST + +- **Change**: Implemented the Task 5 durable, one-time invitation store with 256-bit secret generation, HMAC-only persistence, an independently stored owner-only pepper, bounded expiry and lockout, two-phase reserve/consume/release, idempotent winning operations, and crash-safe replay. +- **Files**: `src/bootstrap/invitation.rs`, `src/bootstrap/journal.rs`, `src/bootstrap/mod.rs`, `src/protocol/types.rs`, `src/protocol/mod.rs`, `tests/invitation_store.rs`, `ROADMAP.md`; ignored Task 5 reports and evidence under `.superpowers/sdd/01-v1-multi-host-node-bootstrap/`. +- **Decision**: Keep the invitation handoff and journal as explicit provisional versioned formats; authenticate known records with constant-time HMAC verification and use the same verification path with a dummy digest for unknown IDs; append a length-delimited checksummed event and `sync_data` before applying its validated projection delta; fail closed after uncertain persistence. +- **Reason**: Enrollment needs a bounded, auditable one-time authorization primitive whose secret is never persisted in recoverable form and whose winner survives process restart without duplicating consumption. +- **Security boundary**: This store assumes one Authority process owns a state directory. Cross-process exclusive journal locking remains a Task 9 service-lifecycle requirement; Task 5 does not claim multi-process writers or implement Task 6 enrollment/CLI behavior. +- **Error record**: Technical blind spot — an intermediate design deduplicated failed authentication by untrusted `operation_id`, allowing an attacker to reuse one operation ID with changing wrong secrets and avoid the fifth-failure lockout. +- **Prevention**: Never deduplicate failed authentication solely by a caller-controlled operation ID. Every failed candidate now increments the durable counter, and a regression test verifies that five distinct wrong secrets sharing one operation ID reach `Locked`. +- **Verification**: Focused invitation and handoff tests, full all-target tests, formatting, and all-target/all-feature Clippy with warnings denied passed; exact commands and logs are retained in the ignored Task 5 evidence directory. + ## 2026-08-14 13:16 CST - **Change**: Converted the approved v0.2 design into three dependency-ordered, test-first implementation plans for multi-host bootstrap, installation surfaces, and the bilingual public site. diff --git a/src/bootstrap/invitation.rs b/src/bootstrap/invitation.rs new file mode 100644 index 0000000..6b8fe87 --- /dev/null +++ b/src/bootstrap/invitation.rs @@ -0,0 +1,1339 @@ +use std::{ + collections::{BTreeSet, HashMap, HashSet}, + fmt::{Debug, Formatter}, + fs::{self, File, OpenOptions}, + io::{IsTerminal, Read, Write}, + os::unix::fs::{FileTypeExt, OpenOptionsExt, PermissionsExt}, + path::Path, + sync::Mutex, +}; + +use age::secrecy::{ExposeSecret, SecretString}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, KeyInit, Mac}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use uuid::Uuid; +use zeroize::{Zeroize, Zeroizing}; + +use crate::protocol::{BootstrapProfile, CapabilityKind, DomainId, NodeId}; + +use super::{BootstrapError, journal::DurableJournal}; + +const PEPPER_FILE: &str = "invitation.pepper"; +const JOURNAL_FILE: &str = "invitation.journal"; +const PEPPER_BYTES: usize = 32; +const SECRET_BYTES: usize = 32; +const ENCODED_SECRET_BYTES: usize = 43; +const DEFAULT_TTL_MS: i64 = 10 * 60 * 1_000; +const MAXIMUM_ATTEMPTS: u8 = 5; +const MAX_INVITATIONS: usize = 10_000; +const MAX_DIRECTORY_SEEDS: usize = 16; +const MAX_CAPABILITIES: usize = 64; +const HANDOFF_FORMAT_V1: &str = "agenet.invitation-handoff.v1"; +const MAX_HANDOFF_BYTES: usize = 16 * 1024; + +type HmacSha256 = Hmac; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InvitationRecord { + pub invitation_id: Uuid, + pub secret_hmac_sha256: [u8; 32], + pub allowed_profile: BootstrapProfile, + pub capability_ceiling: BTreeSet, + pub expires_at_ms: i64, + pub failed_attempts: u8, + pub state: InvitationState, +} + +impl Debug for InvitationRecord { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("InvitationRecord") + .field("invitation_id", &self.invitation_id) + .field("secret_hmac_sha256", &"[REDACTED]") + .field("allowed_profile", &self.allowed_profile) + .field("capability_count", &self.capability_ceiling.len()) + .field("expires_at_ms", &self.expires_at_ms) + .field("failed_attempts", &self.failed_attempts) + .field("state", &self.state) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InvitationPublicClaims { + pub protocol_version: String, + pub domain_id: DomainId, + #[serde(with = "url_serde")] + pub authority_endpoint: Url, + #[serde(with = "url_vec_serde")] + pub directory_seeds: Vec, + pub root_sha256: String, + pub tls_ca_sha256: String, + pub allowed_profile: BootstrapProfile, + pub capability_ceiling: BTreeSet, + pub invitation_id: Uuid, + pub expires_at_ms: i64, + pub maximum_attempts: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InvitationSpec { + pub protocol_version: String, + pub domain_id: DomainId, + #[serde(with = "url_serde")] + pub authority_endpoint: Url, + #[serde(with = "url_vec_serde")] + pub directory_seeds: Vec, + pub root_sha256: String, + pub tls_ca_sha256: String, + pub allowed_profile: BootstrapProfile, + pub capability_ceiling: BTreeSet, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum InvitationState { + Available, + Reserved { + operation_id: Uuid, + reserved_at_ms: i64, + }, + Consumed { + node_id: NodeId, + consumed_at_ms: i64, + }, + Locked, + Expired, +} + +pub struct InvitationHandoff { + public_claims: InvitationPublicClaims, + secret: SecretString, +} + +impl InvitationHandoff { + pub fn public_claims(&self) -> &InvitationPublicClaims { + &self.public_claims + } + + /// Returns an opaque secret container for enrollment authentication. + /// Complete handoff materialization is restricted to this module's + /// explicit hidden-TTY display/input functions. + pub fn secret(&self) -> &SecretString { + &self.secret + } +} + +impl Debug for InvitationHandoff { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("InvitationHandoff") + .field("invitation_id", &self.public_claims.invitation_id) + .field("expires_at_ms", &self.public_claims.expires_at_ms) + .field("allowed_profile", &self.public_claims.allowed_profile) + .field("secret", &"[REDACTED]") + .finish() + } +} + +#[derive(Serialize)] +#[serde(deny_unknown_fields)] +struct InvitationHandoffRef<'a> { + format_version: &'static str, + public_claims: &'a InvitationPublicClaims, + secret: &'a str, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct InvitationHandoffOwned { + format_version: String, + public_claims: InvitationPublicClaims, + secret: ZeroizingSecretText, +} + +struct ZeroizingSecretText(String); + +impl ZeroizingSecretText { + fn into_secret(mut self) -> SecretString { + SecretString::from(std::mem::take(&mut self.0)) + } + + fn clear(&mut self) { + self.0.zeroize(); + } +} + +impl Debug for ZeroizingSecretText { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str("[REDACTED]") + } +} + +impl Drop for ZeroizingSecretText { + fn drop(&mut self) { + self.clear(); + } +} + +impl<'de> Deserialize<'de> for ZeroizingSecretText { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer).map(Self) + } +} + +/// Writes the complete versioned handoff only to the controlling terminal. +pub fn display_invitation_handoff_to_tty( + handoff: &InvitationHandoff, +) -> Result<(), BootstrapError> { + let mut terminal = open_controlling_tty_for_write()?; + let mut encoded = encode_handoff(handoff)?; + encoded.push(b'\n'); + terminal + .write_all(&encoded) + .and_then(|()| terminal.flush()) + .map_err(|_| BootstrapError::HandoffTtyUnavailable) +} + +fn encode_handoff(handoff: &InvitationHandoff) -> Result>, BootstrapError> { + validate_public_claims(&handoff.public_claims)?; + let wire = InvitationHandoffRef { + format_version: HANDOFF_FORMAT_V1, + public_claims: &handoff.public_claims, + secret: handoff.secret.expose_secret(), + }; + let encoded = Zeroizing::new( + serde_json::to_vec(&wire).map_err(|_| BootstrapError::InvalidInvitationClaims)?, + ); + if encoded.len() > MAX_HANDOFF_BYTES { + return Err(BootstrapError::ResourceLimitExceeded); + } + Ok(encoded) +} + +/// Reads a complete versioned handoff with terminal echo disabled. +pub fn read_invitation_handoff_from_tty() -> Result { + let encoded = Zeroizing::new( + rpassword::prompt_password("AgenNet invitation: ") + .map_err(|_| BootstrapError::HandoffTtyUnavailable)?, + ); + if encoded.len() > MAX_HANDOFF_BYTES { + return Err(BootstrapError::ResourceLimitExceeded); + } + decode_handoff(encoded.as_bytes()) +} + +fn decode_handoff(encoded: &[u8]) -> Result { + if encoded.len() > MAX_HANDOFF_BYTES { + return Err(BootstrapError::ResourceLimitExceeded); + } + let owned: InvitationHandoffOwned = + serde_json::from_slice(encoded).map_err(|_| BootstrapError::InvalidInvitationClaims)?; + if owned.format_version != HANDOFF_FORMAT_V1 { + return Err(BootstrapError::InvalidInvitationClaims); + } + validate_public_claims(&owned.public_claims)?; + let secret = owned.secret.into_secret(); + if !normalized_secret(&secret).1 { + return Err(BootstrapError::InvalidInvitationClaims); + } + Ok(InvitationHandoff { + public_claims: owned.public_claims, + secret, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConsumptionResult { + pub node_id: NodeId, + pub consumed_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReservationStatus { + Reserved, + Consumed(ConsumptionResult), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case", deny_unknown_fields)] +enum InvitationEvent { + Created { + record: InvitationRecord, + }, + AuthenticationFailed { + invitation_id: Uuid, + }, + Reserved { + invitation_id: Uuid, + operation_id: Uuid, + reserved_at_ms: i64, + }, + Released { + invitation_id: Uuid, + operation_id: Uuid, + }, + Consumed { + invitation_id: Uuid, + operation_id: Uuid, + result: ConsumptionResult, + }, + Expired { + invitation_id: Uuid, + expired_at_ms: i64, + }, +} + +#[derive(Default)] +struct Projection { + records: HashMap, + consumptions: HashMap<(Uuid, Uuid), ConsumptionResult>, + released_operations: HashSet<(Uuid, Uuid)>, +} + +enum ProjectionDelta { + Created(InvitationRecord), + RecordUpdated { + invitation_id: Uuid, + record: InvitationRecord, + }, + AuthenticationFailed { + invitation_id: Uuid, + record: InvitationRecord, + }, + Released { + invitation_id: Uuid, + operation_id: Uuid, + record: InvitationRecord, + }, + Consumed { + invitation_id: Uuid, + operation_id: Uuid, + record: InvitationRecord, + result: ConsumptionResult, + }, +} + +struct StoreState { + projection: Projection, + journal: DurableJournal, + persistence_failed: bool, +} + +pub struct InvitationStore { + pepper: Zeroizing<[u8; PEPPER_BYTES]>, + dummy_hmac: [u8; 32], + state: Mutex, +} + +impl InvitationStore { + pub fn open(state_directory: &Path) -> Result { + prepare_state_directory(state_directory)?; + let pepper_path = state_directory.join(PEPPER_FILE); + let journal_path = state_directory.join(JOURNAL_FILE); + if fs::symlink_metadata(&journal_path).is_ok() + && fs::symlink_metadata(&pepper_path).is_err() + { + return Err(BootstrapError::InvalidStatePath); + } + let pepper = load_or_create_pepper(&pepper_path)?; + let (journal, events) = DurableJournal::open(&journal_path)?; + let mut projection = Projection::default(); + for event in events { + let delta = validate_event(&projection, &event)?; + apply_delta(&mut projection, delta); + } + let dummy_hmac = compute_hmac(&pepper, Uuid::nil(), b"invalid-invitation")?; + Ok(Self { + pepper, + dummy_hmac, + state: Mutex::new(StoreState { + projection, + journal, + persistence_failed: false, + }), + }) + } + + pub fn create( + &self, + specification: InvitationSpec, + now_ms: i64, + ) -> Result { + if now_ms <= 0 { + return Err(BootstrapError::InvalidTimestamp); + } + validate_specification(&specification)?; + let expires_at_ms = now_ms + .checked_add(DEFAULT_TTL_MS) + .ok_or(BootstrapError::InvalidInvitationClaims)?; + let mut secret_bytes = Zeroizing::new([0u8; SECRET_BYTES]); + getrandom::fill(&mut secret_bytes[..]).map_err(|_| BootstrapError::SecretUnavailable)?; + let secret = SecretString::from(URL_SAFE_NO_PAD.encode(&secret_bytes[..])); + + let mut state = self.lock_state()?; + if state.projection.records.len() >= MAX_INVITATIONS { + return Err(BootstrapError::ResourceLimitExceeded); + } + let invitation_id = unique_invitation_id(&state.projection.records)?; + let secret_hmac_sha256 = compute_hmac( + &self.pepper, + invitation_id, + secret.expose_secret().as_bytes(), + )?; + let record = InvitationRecord { + invitation_id, + secret_hmac_sha256, + allowed_profile: specification.allowed_profile, + capability_ceiling: specification.capability_ceiling.clone(), + expires_at_ms, + failed_attempts: 0, + state: InvitationState::Available, + }; + append_event(&mut state, InvitationEvent::Created { record })?; + let public_claims = InvitationPublicClaims { + protocol_version: specification.protocol_version, + domain_id: specification.domain_id, + authority_endpoint: specification.authority_endpoint, + directory_seeds: specification.directory_seeds, + root_sha256: specification.root_sha256, + tls_ca_sha256: specification.tls_ca_sha256, + allowed_profile: specification.allowed_profile, + capability_ceiling: specification.capability_ceiling, + invitation_id, + expires_at_ms, + maximum_attempts: MAXIMUM_ATTEMPTS, + }; + Ok(InvitationHandoff { + public_claims, + secret, + }) + } + + pub fn reserve( + &self, + invitation_id: Uuid, + secret: &SecretString, + operation_id: Uuid, + now_ms: i64, + ) -> Result { + validate_operation_id(operation_id)?; + if now_ms <= 0 { + return Err(BootstrapError::InvalidTimestamp); + } + let mut state = self.lock_state()?; + let record = state.projection.records.get(&invitation_id); + let expected = record + .map(|record| &record.secret_hmac_sha256) + .unwrap_or(&self.dummy_hmac); + let (candidate, structurally_valid) = normalized_secret(secret); + let authenticated = + verify_hmac(&self.pepper, invitation_id, candidate, expected)? && structurally_valid; + if record.is_none() || !authenticated { + let should_record_failure = + record.is_some_and(|record| record.state == InvitationState::Available); + if should_record_failure { + append_event( + &mut state, + InvitationEvent::AuthenticationFailed { invitation_id }, + )?; + } + return Err(BootstrapError::InvalidInvitation); + } + let record = state + .projection + .records + .get(&invitation_id) + .ok_or(BootstrapError::InvalidInvitation)?; + if now_ms > record.expires_at_ms { + if !matches!(record.state, InvitationState::Expired) { + append_event( + &mut state, + InvitationEvent::Expired { + invitation_id, + expired_at_ms: now_ms, + }, + )?; + } + return Err(BootstrapError::InvitationExpired); + } + match &record.state { + InvitationState::Available => { + append_event( + &mut state, + InvitationEvent::Reserved { + invitation_id, + operation_id, + reserved_at_ms: now_ms, + }, + )?; + Ok(ReservationStatus::Reserved) + } + InvitationState::Reserved { + operation_id: existing, + .. + } if *existing == operation_id => Ok(ReservationStatus::Reserved), + InvitationState::Consumed { .. } => state + .projection + .consumptions + .get(&(invitation_id, operation_id)) + .cloned() + .map(ReservationStatus::Consumed) + .ok_or(BootstrapError::InvitationUnavailable), + InvitationState::Locked => Err(BootstrapError::InvitationLocked), + InvitationState::Expired => Err(BootstrapError::InvitationExpired), + InvitationState::Reserved { .. } => Err(BootstrapError::InvitationUnavailable), + } + } + + pub fn release(&self, invitation_id: Uuid, operation_id: Uuid) -> Result<(), BootstrapError> { + validate_operation_id(operation_id)?; + let mut state = self.lock_state()?; + let record = state + .projection + .records + .get(&invitation_id) + .ok_or(BootstrapError::ReservationMismatch)?; + match record.state { + InvitationState::Reserved { + operation_id: existing, + .. + } if existing == operation_id => append_event( + &mut state, + InvitationEvent::Released { + invitation_id, + operation_id, + }, + ), + InvitationState::Available + if state + .projection + .released_operations + .contains(&(invitation_id, operation_id)) => + { + Ok(()) + } + _ => Err(BootstrapError::ReservationMismatch), + } + } + + pub fn consume( + &self, + invitation_id: Uuid, + operation_id: Uuid, + node_id: NodeId, + consumed_at_ms: i64, + ) -> Result { + validate_operation_id(operation_id)?; + if consumed_at_ms <= 0 { + return Err(BootstrapError::InvalidTimestamp); + } + let mut state = self.lock_state()?; + if let Some(result) = state + .projection + .consumptions + .get(&(invitation_id, operation_id)) + { + return Ok(result.clone()); + } + let record = state + .projection + .records + .get(&invitation_id) + .ok_or(BootstrapError::ReservationMismatch)?; + let reserved_at_ms = match &record.state { + InvitationState::Reserved { + operation_id: existing, + reserved_at_ms, + } if *existing == operation_id => *reserved_at_ms, + InvitationState::Expired => return Err(BootstrapError::InvitationExpired), + _ => return Err(BootstrapError::ReservationMismatch), + }; + if consumed_at_ms < reserved_at_ms { + return Err(BootstrapError::InvalidTimestamp); + } + if consumed_at_ms > record.expires_at_ms { + append_event( + &mut state, + InvitationEvent::Expired { + invitation_id, + expired_at_ms: consumed_at_ms, + }, + )?; + return Err(BootstrapError::InvitationExpired); + } + let result = ConsumptionResult { + node_id, + consumed_at_ms, + }; + append_event( + &mut state, + InvitationEvent::Consumed { + invitation_id, + operation_id, + result: result.clone(), + }, + )?; + Ok(result) + } + + pub fn record(&self, invitation_id: Uuid) -> Result, BootstrapError> { + Ok(self + .lock_state()? + .projection + .records + .get(&invitation_id) + .cloned()) + } + + pub fn consumption( + &self, + invitation_id: Uuid, + operation_id: Uuid, + ) -> Result, BootstrapError> { + Ok(self + .lock_state()? + .projection + .consumptions + .get(&(invitation_id, operation_id)) + .cloned()) + } + + fn lock_state(&self) -> Result, BootstrapError> { + self.state.lock().map_err(|_| BootstrapError::StorageFailed) + } +} + +fn append_event(state: &mut StoreState, event: InvitationEvent) -> Result<(), BootstrapError> { + if state.persistence_failed { + return Err(BootstrapError::PersistenceUnavailable); + } + let delta = validate_event(&state.projection, &event)?; + if let Err(error) = state.journal.append(&event) { + state.persistence_failed = true; + return Err(error); + } + apply_delta(&mut state.projection, delta); + Ok(()) +} + +fn validate_event( + projection: &Projection, + event: &InvitationEvent, +) -> Result { + match event { + InvitationEvent::Created { record } => { + if record.invitation_id.is_nil() + || record.failed_attempts != 0 + || record.state != InvitationState::Available + || projection.records.len() >= MAX_INVITATIONS + || projection.records.contains_key(&record.invitation_id) + || record.expires_at_ms <= 0 + || record.capability_ceiling.len() > MAX_CAPABILITIES + { + return Err(BootstrapError::InvalidJournal); + } + Ok(ProjectionDelta::Created(record.clone())) + } + InvitationEvent::AuthenticationFailed { invitation_id } => { + if invitation_id.is_nil() { + return Err(BootstrapError::InvalidJournal); + } + let mut record = available_record(projection, invitation_id)?.clone(); + record.failed_attempts = record + .failed_attempts + .checked_add(1) + .ok_or(BootstrapError::InvalidJournal)?; + if record.failed_attempts >= MAXIMUM_ATTEMPTS { + record.failed_attempts = MAXIMUM_ATTEMPTS; + record.state = InvitationState::Locked; + } + Ok(ProjectionDelta::AuthenticationFailed { + invitation_id: *invitation_id, + record, + }) + } + InvitationEvent::Reserved { + invitation_id, + operation_id, + reserved_at_ms, + } => { + let mut record = available_record(projection, invitation_id)?.clone(); + if invitation_id.is_nil() + || operation_id.is_nil() + || *reserved_at_ms <= 0 + || *reserved_at_ms > record.expires_at_ms + { + return Err(BootstrapError::InvalidJournal); + } + record.state = InvitationState::Reserved { + operation_id: *operation_id, + reserved_at_ms: *reserved_at_ms, + }; + Ok(ProjectionDelta::RecordUpdated { + invitation_id: *invitation_id, + record, + }) + } + InvitationEvent::Released { + invitation_id, + operation_id, + } => { + if invitation_id.is_nil() || operation_id.is_nil() { + return Err(BootstrapError::InvalidJournal); + } + let mut record = projection + .records + .get(invitation_id) + .cloned() + .ok_or(BootstrapError::InvalidJournal)?; + if !matches!( + record.state, + InvitationState::Reserved { + operation_id: existing, + .. + } if existing == *operation_id + ) { + return Err(BootstrapError::InvalidJournal); + } + record.state = InvitationState::Available; + Ok(ProjectionDelta::Released { + invitation_id: *invitation_id, + operation_id: *operation_id, + record, + }) + } + InvitationEvent::Consumed { + invitation_id, + operation_id, + result, + } => { + if invitation_id.is_nil() + || operation_id.is_nil() + || result.consumed_at_ms <= 0 + || NodeId::new(result.node_id.as_str()).is_err() + { + return Err(BootstrapError::InvalidJournal); + } + let mut record = projection + .records + .get(invitation_id) + .cloned() + .ok_or(BootstrapError::InvalidJournal)?; + let reserved_at_ms = match &record.state { + InvitationState::Reserved { + operation_id: existing, + reserved_at_ms, + } if *existing == *operation_id => *reserved_at_ms, + _ => return Err(BootstrapError::InvalidJournal), + }; + if result.consumed_at_ms < reserved_at_ms + || result.consumed_at_ms > record.expires_at_ms + || projection + .consumptions + .contains_key(&(*invitation_id, *operation_id)) + { + return Err(BootstrapError::InvalidJournal); + } + record.state = InvitationState::Consumed { + node_id: result.node_id.clone(), + consumed_at_ms: result.consumed_at_ms, + }; + Ok(ProjectionDelta::Consumed { + invitation_id: *invitation_id, + operation_id: *operation_id, + record, + result: result.clone(), + }) + } + InvitationEvent::Expired { + invitation_id, + expired_at_ms, + } => { + if invitation_id.is_nil() { + return Err(BootstrapError::InvalidJournal); + } + let mut record = projection + .records + .get(invitation_id) + .cloned() + .ok_or(BootstrapError::InvalidJournal)?; + if *expired_at_ms <= record.expires_at_ms + || matches!( + record.state, + InvitationState::Consumed { .. } | InvitationState::Expired + ) + { + return Err(BootstrapError::InvalidJournal); + } + record.state = InvitationState::Expired; + Ok(ProjectionDelta::RecordUpdated { + invitation_id: *invitation_id, + record, + }) + } + } +} + +fn apply_delta(projection: &mut Projection, delta: ProjectionDelta) { + match delta { + ProjectionDelta::Created(record) => { + projection.records.insert(record.invitation_id, record); + } + ProjectionDelta::RecordUpdated { + invitation_id, + record, + } => { + projection.records.insert(invitation_id, record); + } + ProjectionDelta::AuthenticationFailed { + invitation_id, + record, + } => { + projection.records.insert(invitation_id, record); + } + ProjectionDelta::Released { + invitation_id, + operation_id, + record, + } => { + projection.records.insert(invitation_id, record); + projection + .released_operations + .insert((invitation_id, operation_id)); + } + ProjectionDelta::Consumed { + invitation_id, + operation_id, + record, + result, + } => { + projection.records.insert(invitation_id, record); + projection + .consumptions + .insert((invitation_id, operation_id), result); + } + } +} + +fn available_record<'a>( + projection: &'a Projection, + invitation_id: &Uuid, +) -> Result<&'a InvitationRecord, BootstrapError> { + let record = projection + .records + .get(invitation_id) + .ok_or(BootstrapError::InvalidJournal)?; + if record.state != InvitationState::Available || record.failed_attempts >= MAXIMUM_ATTEMPTS { + return Err(BootstrapError::InvalidJournal); + } + Ok(record) +} + +fn compute_hmac( + pepper: &[u8; PEPPER_BYTES], + invitation_id: Uuid, + secret: &[u8], +) -> Result<[u8; 32], BootstrapError> { + let mut mac = + HmacSha256::new_from_slice(pepper).map_err(|_| BootstrapError::SecretUnavailable)?; + mac.update(b"AGENET\0invitation-secret-v0.2\0"); + mac.update(invitation_id.as_bytes()); + mac.update(secret); + Ok(mac.finalize().into_bytes().into()) +} + +fn verify_hmac( + pepper: &[u8; PEPPER_BYTES], + invitation_id: Uuid, + secret: &[u8], + expected: &[u8; 32], +) -> Result { + let mut mac = + HmacSha256::new_from_slice(pepper).map_err(|_| BootstrapError::SecretUnavailable)?; + mac.update(b"AGENET\0invitation-secret-v0.2\0"); + mac.update(invitation_id.as_bytes()); + mac.update(secret); + Ok(mac.verify_slice(expected).is_ok()) +} + +fn normalized_secret(secret: &SecretString) -> (&[u8], bool) { + const DUMMY_SECRET: &[u8; ENCODED_SECRET_BYTES] = + b"invalid-invitation-secret-material-00000000"; + let candidate = secret.expose_secret().as_bytes(); + let structurally_valid = candidate.len() == ENCODED_SECRET_BYTES + && candidate + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')); + if structurally_valid { + (candidate, true) + } else { + (DUMMY_SECRET, false) + } +} + +fn validate_operation_id(operation_id: Uuid) -> Result<(), BootstrapError> { + if operation_id.is_nil() { + return Err(BootstrapError::InvalidOperationId); + } + Ok(()) +} + +fn unique_invitation_id(records: &HashMap) -> Result { + for _ in 0..8 { + let candidate = Uuid::new_v4(); + if !records.contains_key(&candidate) { + return Ok(candidate); + } + } + Err(BootstrapError::SecretUnavailable) +} + +fn validate_specification(specification: &InvitationSpec) -> Result<(), BootstrapError> { + DomainId::new(specification.domain_id.as_str()) + .map_err(|_| BootstrapError::InvalidInvitationClaims)?; + let valid_version = !specification.protocol_version.is_empty() + && specification.protocol_version.len() <= 64 + && specification + .protocol_version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')); + if !valid_version + || !valid_private_endpoint_shape(&specification.authority_endpoint) + || specification.directory_seeds.is_empty() + || specification.directory_seeds.len() > MAX_DIRECTORY_SEEDS + || specification + .directory_seeds + .iter() + .any(|seed| !valid_private_endpoint_shape(seed)) + || !valid_sha256(&specification.root_sha256) + || !valid_sha256(&specification.tls_ca_sha256) + || specification.capability_ceiling.len() > MAX_CAPABILITIES + { + return Err(BootstrapError::InvalidInvitationClaims); + } + Ok(()) +} + +fn validate_public_claims(claims: &InvitationPublicClaims) -> Result<(), BootstrapError> { + validate_specification(&InvitationSpec { + protocol_version: claims.protocol_version.clone(), + domain_id: claims.domain_id.clone(), + authority_endpoint: claims.authority_endpoint.clone(), + directory_seeds: claims.directory_seeds.clone(), + root_sha256: claims.root_sha256.clone(), + tls_ca_sha256: claims.tls_ca_sha256.clone(), + allowed_profile: claims.allowed_profile, + capability_ceiling: claims.capability_ceiling.clone(), + })?; + if claims.maximum_attempts != MAXIMUM_ATTEMPTS + || claims.invitation_id.is_nil() + || claims.expires_at_ms <= 0 + { + return Err(BootstrapError::InvalidInvitationClaims); + } + Ok(()) +} + +fn open_controlling_tty_for_write() -> Result { + let terminal = OpenOptions::new() + .write(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open("/dev/tty") + .map_err(|_| BootstrapError::HandoffTtyUnavailable)?; + let metadata = terminal + .metadata() + .map_err(|_| BootstrapError::HandoffTtyUnavailable)?; + if !metadata.file_type().is_char_device() || !terminal.is_terminal() { + return Err(BootstrapError::HandoffTtyUnavailable); + } + Ok(terminal) +} + +fn valid_private_endpoint_shape(url: &Url) -> bool { + url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + && url + .host_str() + .and_then(parse_ip_host) + .is_some_and(is_private_ip) +} + +fn parse_ip_host(host: &str) -> Option { + host.trim_start_matches('[') + .trim_end_matches(']') + .parse() + .ok() +} + +fn is_private_ip(address: std::net::IpAddr) -> bool { + match address { + std::net::IpAddr::V4(address) => { + let octets = address.octets(); + address.is_loopback() + || octets[0] == 10 + || (octets[0] == 172 && (16..=31).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 168) + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + } + std::net::IpAddr::V6(address) => { + let first = address.octets()[0]; + address.is_loopback() || first & 0xfe == 0xfc + } + } +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn prepare_state_directory(path: &Path) -> Result<(), BootstrapError> { + let mut created = false; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(BootstrapError::InvalidStatePath); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir_all(path).map_err(|_| BootstrapError::StorageFailed)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) + .map_err(|_| BootstrapError::StorageFailed)?; + created = true; + } + Err(_) => return Err(BootstrapError::StorageFailed), + } + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_DIRECTORY) + .open(path) + .map_err(|_| BootstrapError::InvalidStatePath)?; + let metadata = directory + .metadata() + .map_err(|_| BootstrapError::StorageFailed)?; + require_directory_security(&metadata)?; + if created { + directory + .sync_all() + .map_err(|_| BootstrapError::StorageFailed)?; + sync_parent(path)?; + } + Ok(()) +} + +fn load_or_create_pepper(path: &Path) -> Result, BootstrapError> { + validate_pepper_path(path)?; + if path.exists() { + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + .map_err(|_| BootstrapError::StorageFailed)?; + let metadata = file.metadata().map_err(|_| BootstrapError::StorageFailed)?; + require_owner_only_regular(&metadata)?; + if metadata.len() != PEPPER_BYTES as u64 { + return Err(BootstrapError::InvalidStatePath); + } + let mut bytes = Zeroizing::new([0u8; PEPPER_BYTES]); + file.read_exact(&mut bytes[..]) + .map_err(|_| BootstrapError::StorageFailed)?; + return Ok(bytes); + } + let mut pepper = Zeroizing::new([0u8; PEPPER_BYTES]); + getrandom::fill(&mut pepper[..]).map_err(|_| BootstrapError::SecretUnavailable)?; + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + .map_err(|_| BootstrapError::StorageFailed)?; + file.write_all(&pepper[..]) + .map_err(|_| BootstrapError::StorageFailed)?; + file.flush().map_err(|_| BootstrapError::StorageFailed)?; + file.sync_all().map_err(|_| BootstrapError::StorageFailed)?; + sync_parent(path)?; + Ok(pepper) +} + +fn validate_pepper_path(path: &Path) -> Result<(), BootstrapError> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(BootstrapError::InvalidStatePath) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(BootstrapError::StorageFailed), + } +} + +fn require_owner_only_regular(metadata: &fs::Metadata) -> Result<(), BootstrapError> { + use std::os::unix::fs::MetadataExt; + if !metadata.is_file() || metadata.uid() != effective_uid() || metadata.mode() & 0o077 != 0 { + return Err(BootstrapError::InvalidStatePath); + } + Ok(()) +} + +fn require_directory_security(metadata: &fs::Metadata) -> Result<(), BootstrapError> { + use std::os::unix::fs::MetadataExt; + if !metadata.is_dir() || metadata.uid() != effective_uid() || metadata.mode() & 0o777 != 0o700 { + return Err(BootstrapError::InvalidStatePath); + } + Ok(()) +} + +fn effective_uid() -> u32 { + // SAFETY: `geteuid` has no preconditions and does not dereference pointers. + unsafe { libc::geteuid() } +} + +fn sync_parent(path: &Path) -> Result<(), BootstrapError> { + let parent = normalized_parent(path)?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| BootstrapError::StorageFailed) +} + +fn normalized_parent(path: &Path) -> Result<&Path, BootstrapError> { + let parent = path.parent().ok_or(BootstrapError::StorageFailed)?; + if parent.as_os_str().is_empty() { + return Ok(Path::new(".")); + } + Ok(parent) +} + +mod url_serde { + use reqwest::Url; + use serde::{Deserialize, Deserializer, Serializer}; + + pub(super) fn serialize(url: &Url, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(url.as_str()) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Url::parse(&value).map_err(serde::de::Error::custom) + } +} + +mod url_vec_serde { + use reqwest::Url; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub(super) fn serialize(urls: &[Url], serializer: S) -> Result + where + S: Serializer, + { + urls.iter() + .map(Url::as_str) + .collect::>() + .serialize(serializer) + } + + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Vec::::deserialize(deserializer)? + .into_iter() + .map(|value| Url::parse(&value).map_err(serde::de::Error::custom)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(invitation_id: Uuid) -> InvitationRecord { + InvitationRecord { + invitation_id, + secret_hmac_sha256: [7; 32], + allowed_profile: BootstrapProfile::Base, + capability_ceiling: BTreeSet::new(), + expires_at_ms: 1_800_000_600_000, + failed_attempts: 0, + state: InvitationState::Available, + } + } + + #[test] + fn deserialized_secret_wrapper_redacts_and_zeroizes() { + let mut secret = serde_json::from_str::("\"sensitive-test-value\"") + .expect("secret wrapper deserializes"); + assert_eq!(format!("{secret:?}"), "[REDACTED]"); + secret.clear(); + assert!(secret.0.is_empty()); + } + + #[test] + fn journal_validation_rejects_nil_ids_invalid_times_and_invalid_node() { + let projection = Projection::default(); + assert!(matches!( + validate_event( + &projection, + &InvitationEvent::Created { + record: record(Uuid::nil()) + } + ), + Err(BootstrapError::InvalidJournal) + )); + + let invitation_id = Uuid::from_u128(10); + let mut projection = Projection::default(); + projection + .records + .insert(invitation_id, record(invitation_id)); + assert!(matches!( + validate_event( + &projection, + &InvitationEvent::Reserved { + invitation_id, + operation_id: Uuid::nil(), + reserved_at_ms: 1_800_000_000_000, + } + ), + Err(BootstrapError::InvalidJournal) + )); + assert!(matches!( + validate_event( + &projection, + &InvitationEvent::Reserved { + invitation_id, + operation_id: Uuid::from_u128(11), + reserved_at_ms: 1_800_000_600_001, + } + ), + Err(BootstrapError::InvalidJournal) + )); + + let operation_id = Uuid::from_u128(12); + projection + .records + .get_mut(&invitation_id) + .expect("record exists") + .state = InvitationState::Reserved { + operation_id, + reserved_at_ms: 1_800_000_000_000, + }; + let invalid_node: NodeId = + serde_json::from_str("\"invalid node\"").expect("legacy identifier shape decodes"); + assert!(matches!( + validate_event( + &projection, + &InvitationEvent::Consumed { + invitation_id, + operation_id, + result: ConsumptionResult { + node_id: invalid_node, + consumed_at_ms: 1_800_000_000_001, + }, + } + ), + Err(BootstrapError::InvalidJournal) + )); + } + + #[test] + fn versioned_handoff_codec_round_trips_without_debug_exposure() { + let handoff = InvitationHandoff { + public_claims: InvitationPublicClaims { + protocol_version: "agenet.enrollment.v0.2".to_owned(), + domain_id: DomainId::new("domain:codec").expect("test domain is valid"), + authority_endpoint: Url::parse("https://100.64.0.1:7443/") + .expect("test URL is valid"), + directory_seeds: vec![ + Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), + ], + root_sha256: "11".repeat(32), + tls_ca_sha256: "22".repeat(32), + allowed_profile: BootstrapProfile::Base, + capability_ceiling: BTreeSet::new(), + invitation_id: Uuid::from_u128(1), + expires_at_ms: 1_800_000_600_000, + maximum_attempts: 5, + }, + secret: SecretString::from("public-test-invitation-secret-material-0000".to_owned()), + }; + + let encoded = encode_handoff(&handoff).expect("handoff encodes"); + let decoded = decode_handoff(&encoded).expect("handoff decodes"); + assert_eq!(decoded.public_claims, handoff.public_claims); + assert!( + decoded.secret.expose_secret() == handoff.secret.expose_secret(), + "decoded secret must match without printing either value" + ); + assert!(!format!("{decoded:?}").contains(handoff.secret.expose_secret())); + } + + #[test] + fn versioned_handoff_codec_rejects_unknown_version_and_oversize() { + let handoff = InvitationHandoff { + public_claims: InvitationPublicClaims { + protocol_version: "agenet.enrollment.v0.2".to_owned(), + domain_id: DomainId::new("domain:codec-errors").expect("test domain is valid"), + authority_endpoint: Url::parse("https://100.64.0.1:7443/") + .expect("test URL is valid"), + directory_seeds: vec![ + Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), + ], + root_sha256: "11".repeat(32), + tls_ca_sha256: "22".repeat(32), + allowed_profile: BootstrapProfile::Base, + capability_ceiling: BTreeSet::new(), + invitation_id: Uuid::from_u128(2), + expires_at_ms: 1_800_000_600_000, + maximum_attempts: 5, + }, + secret: SecretString::from("public-test-invitation-secret-material-0000".to_owned()), + }; + let mut encoded = encode_handoff(&handoff).expect("handoff encodes"); + let version = encoded + .windows(HANDOFF_FORMAT_V1.len()) + .position(|window| window == HANDOFF_FORMAT_V1.as_bytes()) + .expect("format version is present"); + encoded[version] = b'x'; + assert!(matches!( + decode_handoff(&encoded), + Err(BootstrapError::InvalidInvitationClaims) + )); + + let oversized = vec![b'x'; MAX_HANDOFF_BYTES + 1]; + assert!(matches!( + decode_handoff(&oversized), + Err(BootstrapError::ResourceLimitExceeded) + )); + + let invalid_domain: DomainId = + serde_json::from_str("\"invalid domain\"").expect("legacy identifier shape decodes"); + let mut invalid_handoff = handoff; + invalid_handoff.public_claims.domain_id = invalid_domain; + assert!(matches!( + encode_handoff(&invalid_handoff), + Err(BootstrapError::InvalidInvitationClaims) + )); + } +} diff --git a/src/bootstrap/journal.rs b/src/bootstrap/journal.rs new file mode 100644 index 0000000..4a354fc --- /dev/null +++ b/src/bootstrap/journal.rs @@ -0,0 +1,195 @@ +use std::{ + fs::{File, OpenOptions}, + io::{Read, Seek, SeekFrom, Write}, + marker::PhantomData, + os::unix::fs::OpenOptionsExt, + path::Path, +}; + +use serde::{Serialize, de::DeserializeOwned}; +use sha2::{Digest, Sha256}; + +use super::BootstrapError; + +const HEADER: &[u8] = b"AGENET-INVITATION-JOURNAL\0\x01"; +const CHECKSUM_BYTES: usize = 32; +const MAX_RECORD_BYTES: usize = 64 * 1024; +const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024; +const MAX_RECORDS: usize = 100_000; + +pub(crate) struct DurableJournal { + file: File, + record_count: usize, + marker: PhantomData, +} + +impl DurableJournal +where + Entry: Serialize + DeserializeOwned, +{ + pub(crate) fn open(path: &Path) -> Result<(Self, Vec), BootstrapError> { + let (mut file, created) = open_journal_file(path)?; + let metadata = file.metadata().map_err(|_| BootstrapError::StorageFailed)?; + require_owner_only_regular(&metadata)?; + if created { + file.write_all(HEADER) + .map_err(|_| BootstrapError::StorageFailed)?; + persist(&mut file)?; + sync_parent(path)?; + } + let metadata = file.metadata().map_err(|_| BootstrapError::StorageFailed)?; + if metadata.len() > MAX_JOURNAL_BYTES { + return Err(BootstrapError::InvalidJournal); + } + require_owner_only_regular(&metadata)?; + let mut bytes = Vec::with_capacity( + usize::try_from(metadata.len()).map_err(|_| BootstrapError::InvalidJournal)?, + ); + file.seek(SeekFrom::Start(0)) + .map_err(|_| BootstrapError::StorageFailed)?; + file.read_to_end(&mut bytes) + .map_err(|_| BootstrapError::StorageFailed)?; + let entries = decode_entries::(&bytes)?; + let record_count = entries.len(); + Ok(( + Self { + file, + record_count, + marker: PhantomData, + }, + entries, + )) + } + + pub(crate) fn append(&mut self, entry: &Entry) -> Result<(), BootstrapError> { + if self.record_count >= MAX_RECORDS { + return Err(BootstrapError::ResourceLimitExceeded); + } + let payload = serde_json::to_vec(entry).map_err(|_| BootstrapError::InvalidJournal)?; + if payload.len() > MAX_RECORD_BYTES { + return Err(BootstrapError::ResourceLimitExceeded); + } + let projected = self + .file + .metadata() + .map_err(|_| BootstrapError::StorageFailed)? + .len() + .checked_add(4 + payload.len() as u64 + CHECKSUM_BYTES as u64) + .ok_or(BootstrapError::ResourceLimitExceeded)?; + if projected > MAX_JOURNAL_BYTES { + return Err(BootstrapError::ResourceLimitExceeded); + } + let length = u32::try_from(payload.len()) + .map_err(|_| BootstrapError::ResourceLimitExceeded)? + .to_be_bytes(); + let checksum = Sha256::digest(&payload); + self.file + .write_all(&length) + .and_then(|()| self.file.write_all(&payload)) + .and_then(|()| self.file.write_all(&checksum)) + .map_err(|_| BootstrapError::StorageFailed)?; + persist(&mut self.file)?; + self.record_count += 1; + Ok(()) + } +} + +fn open_journal_file(path: &Path) -> Result<(File, bool), BootstrapError> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(BootstrapError::InvalidStatePath) + } + Ok(_) => open_existing(path).map(|file| (file, false)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + open_new(path).map(|file| (file, true)) + } + Err(_) => Err(BootstrapError::StorageFailed), + } +} + +fn open_existing(path: &Path) -> Result { + OpenOptions::new() + .read(true) + .append(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + .map_err(|_| BootstrapError::StorageFailed) +} + +fn open_new(path: &Path) -> Result { + OpenOptions::new() + .create_new(true) + .read(true) + .append(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + .map_err(|_| BootstrapError::StorageFailed) +} + +fn decode_entries(bytes: &[u8]) -> Result, BootstrapError> { + if !bytes.starts_with(HEADER) { + return Err(BootstrapError::InvalidJournal); + } + let mut cursor = HEADER.len(); + let mut entries = Vec::new(); + while cursor < bytes.len() { + if entries.len() >= MAX_RECORDS || bytes.len() - cursor < 4 { + return Err(BootstrapError::InvalidJournal); + } + let length_bytes: [u8; 4] = bytes[cursor..cursor + 4] + .try_into() + .map_err(|_| BootstrapError::InvalidJournal)?; + let payload_length = u32::from_be_bytes(length_bytes) as usize; + if payload_length > MAX_RECORD_BYTES { + return Err(BootstrapError::InvalidJournal); + } + cursor += 4; + let record_length = payload_length + .checked_add(CHECKSUM_BYTES) + .ok_or(BootstrapError::InvalidJournal)?; + if bytes.len() - cursor < record_length { + return Err(BootstrapError::InvalidJournal); + } + let payload = &bytes[cursor..cursor + payload_length]; + cursor += payload_length; + let checksum = &bytes[cursor..cursor + CHECKSUM_BYTES]; + cursor += CHECKSUM_BYTES; + if Sha256::digest(payload).as_slice() != checksum { + return Err(BootstrapError::InvalidJournal); + } + entries.push(serde_json::from_slice(payload).map_err(|_| BootstrapError::InvalidJournal)?); + } + Ok(entries) +} + +#[cfg(unix)] +fn require_owner_only_regular(metadata: &std::fs::Metadata) -> Result<(), BootstrapError> { + use std::os::unix::fs::MetadataExt; + // SAFETY: `geteuid` has no preconditions and does not dereference pointers. + let effective_uid = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != effective_uid || metadata.mode() & 0o077 != 0 { + return Err(BootstrapError::InvalidStatePath); + } + Ok(()) +} + +fn persist(file: &mut File) -> Result<(), BootstrapError> { + file.flush().map_err(|_| BootstrapError::StorageFailed)?; + file.sync_data().map_err(|_| BootstrapError::StorageFailed) +} + +fn sync_parent(path: &Path) -> Result<(), BootstrapError> { + let parent = normalized_parent(path)?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| BootstrapError::StorageFailed) +} + +fn normalized_parent(path: &Path) -> Result<&Path, BootstrapError> { + let parent = path.parent().ok_or(BootstrapError::StorageFailed)?; + if parent.as_os_str().is_empty() { + return Ok(Path::new(".")); + } + Ok(parent) +} diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index b7d9395..29cb7c7 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -1,11 +1,18 @@ //! Bootstrap orchestration boundary for the v0.2 multi-host preview. +mod invitation; +mod journal; mod keystore; pub mod network; mod pki; use std::fmt::{Display, Formatter}; +pub use invitation::{ + ConsumptionResult, InvitationHandoff, InvitationPublicClaims, InvitationRecord, InvitationSpec, + InvitationState, InvitationStore, ReservationStatus, display_invitation_handoff_to_tty, + read_invitation_handoff_from_tty, +}; pub use keystore::{ AgeRootKeystore, DomainRootMaterial, LegacyV1MigrationPolicy, RootKeystore, RootKeystoreFormatVersion, UnlockedRootKeystore, prompt_root_passphrase, @@ -20,6 +27,20 @@ pub enum BootstrapError { StorageFailed, PassphraseUnavailable, InvalidPki, + InvalidInvitation, + InvalidInvitationClaims, + InvitationExpired, + InvitationLocked, + InvitationUnavailable, + ReservationMismatch, + InvalidJournal, + InvalidStatePath, + ResourceLimitExceeded, + SecretUnavailable, + PersistenceUnavailable, + HandoffTtyUnavailable, + InvalidOperationId, + InvalidTimestamp, } impl Display for BootstrapError { diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 5d49a01..d46b17c 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -19,7 +19,7 @@ pub use identity::{ pub use sealed_contract::{ContractOffer, SealedContract}; pub use types::{ AcceptanceProfile, ArtifactId, ArtifactPayload, ArtifactReadRequest, ArtifactRef, CandidateSet, - CapabilityId, CapabilityManifest, ContractDraft, ContractEvent, ContractId, + CapabilityId, CapabilityKind, CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractProposeRequest, ContractProposeResponse, ContractQuery, ContractState, DomainId, ErrorEnvelope, EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, NodeRole, RouteQuery, SideEffectProfile, SourceMetrics, diff --git a/src/protocol/types.rs b/src/protocol/types.rs index d918cb2..458c3d9 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use super::{ContractOffer, ProtocolError}; @@ -31,6 +31,48 @@ identifier!(ArtifactId); identifier!(IntentId); identifier!(ContractId); +/// Versioned capability name used at authorization boundaries. +/// +/// Existing v0.1 manifests intentionally retain their string wire fields. This +/// newtype is introduced for v0.2 policy sets without silently rewriting that +/// persistent format. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct CapabilityKind(String); + +impl CapabilityKind { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let mut characters = value.chars(); + let valid_first = characters + .next() + .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit()); + let valid_tail = characters.all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || matches!(character, '.' | '-' | '_') + }); + if !valid_first || !valid_tail || value.len() > 128 { + return Err(ProtocolError::InvalidIdentifier); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for CapabilityKind { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NodeRole { diff --git a/tests/invitation_store.rs b/tests/invitation_store.rs new file mode 100644 index 0000000..12aae9d --- /dev/null +++ b/tests/invitation_store.rs @@ -0,0 +1,740 @@ +use std::{ + collections::BTreeSet, + fs, + io::Write, + os::unix::fs::{MetadataExt, PermissionsExt, symlink}, + sync::{Arc, Barrier, Mutex}, + thread, +}; + +use age::secrecy::{ExposeSecret, SecretString}; +use agenet::{ + bootstrap::{ + BootstrapError, InvitationHandoff, InvitationSpec, InvitationState, InvitationStore, + ReservationStatus, display_invitation_handoff_to_tty, + }, + protocol::{BootstrapProfile, CapabilityKind, DomainId, NodeId}, +}; +use proptest::prelude::*; +use reqwest::Url; +use tempfile::TempDir; +use uuid::Uuid; +use zeroize::Zeroizing; + +const NOW_MS: i64 = 1_800_000_000_000; +const PEPPER_FILE: &str = "invitation.pepper"; +const JOURNAL_FILE: &str = "invitation.journal"; + +fn capability(value: &str) -> CapabilityKind { + CapabilityKind::new(value).expect("test capability is valid") +} + +fn spec() -> InvitationSpec { + InvitationSpec { + protocol_version: "agenet.enrollment.v0.2".to_owned(), + domain_id: DomainId::new("domain:test").expect("test domain is valid"), + authority_endpoint: Url::parse("https://100.64.0.1:7443").expect("test URL is valid"), + directory_seeds: vec![Url::parse("https://100.64.0.1:7444").expect("test URL is valid")], + root_sha256: "11".repeat(32), + tls_ca_sha256: "22".repeat(32), + allowed_profile: BootstrapProfile::Provider, + capability_ceiling: BTreeSet::from([capability("source.metrics.v1")]), + } +} + +fn create_store(temp: &TempDir) -> InvitationStore { + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("test state directory is owner-only"); + InvitationStore::open(temp.path()).expect("store opens") +} + +fn create_invitation(store: &InvitationStore) -> InvitationHandoff { + store.create(spec(), NOW_MS).expect("invitation is created") +} + +#[test] +fn capability_kind_is_validated_ordered_and_round_trips() { + let source = capability("source.metrics.v1"); + let verify = capability("source.metrics.verify.v1"); + assert!(source < verify); + assert_eq!( + serde_json::to_string(&source).expect("serialization succeeds"), + "\"source.metrics.v1\"" + ); + assert_eq!( + serde_json::from_str::("\"source.metrics.v1\"") + .expect("deserialization succeeds"), + source + ); + for invalid in ["", "source metrics", ".source", "Source.metrics.v1"] { + assert!( + CapabilityKind::new(invalid).is_err(), + "accepted {invalid:?}" + ); + } + let too_long = "a".repeat(129); + assert!(CapabilityKind::new(too_long.clone()).is_err()); + assert!(serde_json::from_str::(&format!("\"{too_long}\"")).is_err()); +} + +#[test] +fn create_uses_a_256_bit_secret_and_ten_minute_default_expiry() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let first = create_invitation(&store); + let second = create_invitation(&store); + + let first_bytes = Zeroizing::new( + base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + first.secret().expose_secret(), + ) + .expect("secret has URL-safe encoding"), + ); + assert_eq!(first_bytes.len(), 32); + assert!( + first.secret().expose_secret() != second.secret().expose_secret(), + "independent generated secrets collided" + ); + assert_eq!( + first.public_claims().expires_at_ms, + NOW_MS + 10 * 60 * 1_000 + ); + assert_eq!(first.public_claims().maximum_attempts, 5); +} + +#[test] +fn persistence_contains_only_hmac_and_owner_only_separate_pepper() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let secret = handoff.secret().expose_secret().as_bytes(); + let secret_raw = Zeroizing::new( + base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + handoff.secret().expose_secret(), + ) + .expect("secret has URL-safe encoding"), + ); + let record = store + .record(handoff.public_claims().invitation_id) + .expect("record lookup succeeds") + .expect("record exists"); + + let journal = fs::read(temp.path().join(JOURNAL_FILE)).expect("journal is readable"); + let pepper = fs::read(temp.path().join(PEPPER_FILE)).expect("pepper is readable"); + assert_eq!(pepper.len(), 32); + assert_eq!( + fs::metadata(temp.path().join(PEPPER_FILE)) + .expect("pepper metadata exists") + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(temp.path().join(JOURNAL_FILE)) + .expect("journal metadata exists") + .mode() + & 0o777, + 0o600 + ); + assert!(!journal.windows(secret.len()).any(|window| window == secret)); + assert!(!journal.windows(pepper.len()).any(|window| window == pepper)); + assert!( + record.secret_hmac_sha256.as_slice() != secret_raw.as_slice(), + "persisted value must not equal raw invitation secret" + ); +} + +#[test] +fn handoff_debug_redacts_the_secret() { + let temp = TempDir::new().expect("temporary directory is created"); + let handoff = create_invitation(&create_store(&temp)); + let debug = format!("{handoff:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains(handoff.secret().expose_secret())); + assert!(!debug.contains("domain:test")); + assert!(!debug.contains("100.64.0.1")); + assert!(!debug.contains(&"11".repeat(32))); + assert!(debug.contains(&handoff.public_claims().invitation_id.to_string())); + if !std::io::IsTerminal::is_terminal(&std::io::stderr()) { + assert_eq!( + display_invitation_handoff_to_tty(&handoff), + Err(BootstrapError::HandoffTtyUnavailable) + ); + } +} + +#[test] +fn record_debug_redacts_persisted_authenticator() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let record = store + .record(handoff.public_claims().invitation_id) + .expect("record lookup succeeds") + .expect("record exists"); + let authenticator_debug = format!("{:?}", record.secret_hmac_sha256); + let record_debug = format!("{record:?}"); + assert!(record_debug.contains("[REDACTED]")); + assert!(!record_debug.contains(&authenticator_debug)); +} + +#[test] +fn public_mutations_reject_invalid_timestamps_without_journal_errors() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + assert!(matches!( + store.create(spec(), 0), + Err(BootstrapError::InvalidTimestamp) + )); + let handoff = create_invitation(&store); + let id = handoff.public_claims().invitation_id; + let operation = Uuid::new_v4(); + assert_eq!( + store.reserve(id, handoff.secret(), operation, 0), + Err(BootstrapError::InvalidTimestamp) + ); + store + .reserve(id, handoff.secret(), operation, NOW_MS) + .expect("valid reservation succeeds"); + assert_eq!( + store.consume( + id, + operation, + NodeId::new("node:timestamp").expect("test node is valid"), + NOW_MS - 1, + ), + Err(BootstrapError::InvalidTimestamp) + ); +} + +#[test] +fn fifth_wrong_secret_locks_and_unknown_is_indistinguishable() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let wrong = SecretString::from("wrong-invitation-secret-material-0000000000".to_owned()); + let unknown = store + .reserve(Uuid::new_v4(), &wrong, Uuid::new_v4(), NOW_MS) + .expect_err("unknown invitation is rejected"); + + for expected_failures in 1..=5 { + let error = store + .reserve( + handoff.public_claims().invitation_id, + &wrong, + Uuid::new_v4(), + NOW_MS, + ) + .expect_err("wrong secret is rejected"); + assert_eq!(error, unknown); + let record = store + .record(handoff.public_claims().invitation_id) + .expect("record lookup succeeds") + .expect("record exists"); + assert_eq!(record.failed_attempts, expected_failures); + } + assert_eq!( + store + .record(handoff.public_claims().invitation_id) + .expect("record lookup succeeds") + .expect("record exists") + .state, + InvitationState::Locked + ); + assert_eq!(format!("{unknown:?}"), "InvalidInvitation"); + assert_eq!(unknown.to_string(), "InvalidInvitation"); +} + +#[test] +fn reserve_consume_and_release_are_operation_scoped_and_idempotent() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let invitation_id = handoff.public_claims().invitation_id; + let winner = Uuid::new_v4(); + let other = Uuid::new_v4(); + + assert_eq!( + store + .reserve(invitation_id, handoff.secret(), winner, NOW_MS) + .expect("first reservation wins"), + ReservationStatus::Reserved + ); + assert_eq!( + store + .reserve(invitation_id, handoff.secret(), winner, NOW_MS + 1) + .expect("winning operation is idempotent"), + ReservationStatus::Reserved + ); + let before_invalid_release = fs::metadata(temp.path().join(JOURNAL_FILE)) + .expect("journal metadata exists") + .len(); + assert_eq!( + store.release(invitation_id, other), + Err(BootstrapError::ReservationMismatch) + ); + assert_eq!( + fs::metadata(temp.path().join(JOURNAL_FILE)) + .expect("journal metadata exists") + .len(), + before_invalid_release, + "failed validation must not append" + ); + store + .release(invitation_id, winner) + .expect("owner releases reservation"); + let journal_path = temp.path().join(JOURNAL_FILE); + let released_length = fs::metadata(&journal_path) + .expect("journal metadata exists") + .len(); + store + .release(invitation_id, winner) + .expect("repeating the same release is idempotent"); + assert_eq!( + fs::metadata(&journal_path) + .expect("journal metadata exists") + .len(), + released_length + ); + store + .reserve(invitation_id, handoff.secret(), winner, NOW_MS + 2) + .expect("released operation can reserve again"); + + let node_id = NodeId::new("node:winner").expect("test node is valid"); + let first = store + .consume(invitation_id, winner, node_id.clone(), NOW_MS + 3) + .expect("reservation is consumed"); + let repeated = store + .consume(invitation_id, winner, node_id, NOW_MS + 4) + .expect("same operation recovers durable result"); + assert_eq!(first, repeated); + assert_eq!( + store.reserve(invitation_id, handoff.secret(), winner, NOW_MS + 5), + Ok(ReservationStatus::Consumed(first)) + ); + assert_eq!( + store.reserve(invitation_id, handoff.secret(), other, NOW_MS + 5), + Err(BootstrapError::InvitationUnavailable) + ); +} + +#[test] +fn same_operation_with_five_distinct_wrong_secrets_locks() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let operation = Uuid::new_v4(); + for attempt in 0..5 { + let mut candidate = "wrong-invitation-secret-material-0000000000".to_owned(); + candidate.replace_range(42..43, &attempt.to_string()); + let wrong = SecretString::from(candidate); + assert_eq!( + store.reserve( + handoff.public_claims().invitation_id, + &wrong, + operation, + NOW_MS, + ), + Err(BootstrapError::InvalidInvitation) + ); + } + let record = store + .record(handoff.public_claims().invitation_id) + .expect("record lookup succeeds") + .expect("record exists"); + assert_eq!(record.failed_attempts, 5); + assert_eq!(record.state, InvitationState::Locked); +} + +#[test] +fn nil_operation_id_is_rejected_without_state_change() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + assert_eq!( + store.reserve( + handoff.public_claims().invitation_id, + handoff.secret(), + Uuid::nil(), + NOW_MS, + ), + Err(BootstrapError::InvalidOperationId) + ); + assert_eq!( + store + .record(handoff.public_claims().invitation_id) + .expect("record lookup succeeds") + .expect("record exists") + .state, + InvitationState::Available + ); +} + +#[test] +fn expired_invitation_is_persisted_and_cannot_reserve() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let id = handoff.public_claims().invitation_id; + let result = store.reserve( + id, + handoff.secret(), + Uuid::new_v4(), + NOW_MS + 10 * 60 * 1_000 + 1, + ); + assert_eq!(result, Err(BootstrapError::InvitationExpired)); + drop(store); + let replayed = create_store(&temp); + assert_eq!( + replayed + .record(id) + .expect("record lookup succeeds") + .expect("record replays") + .state, + InvitationState::Expired + ); +} + +#[test] +fn one_thousand_concurrent_claims_have_exactly_one_winner() { + const THREADS: usize = 32; + const ATTEMPTS: usize = 1_024; + let temp = TempDir::new().expect("temporary directory is created"); + let store = Arc::new(create_store(&temp)); + let handoff = create_invitation(&store); + let id = handoff.public_claims().invitation_id; + let handoff = Arc::new(handoff); + let next = Arc::new(Mutex::new(0usize)); + let barrier = Arc::new(Barrier::new(THREADS)); + let mut threads = Vec::new(); + for _ in 0..THREADS { + let store = Arc::clone(&store); + let handoff = Arc::clone(&handoff); + let next = Arc::clone(&next); + let barrier = Arc::clone(&barrier); + threads.push(thread::spawn(move || { + barrier.wait(); + let mut winners = 0; + loop { + let attempt = { + let mut next = next.lock().expect("counter lock is healthy"); + if *next == ATTEMPTS { + break; + } + let current = *next; + *next += 1; + current + }; + let operation_id = Uuid::from_u128((attempt + 1) as u128); + if store + .reserve(id, handoff.secret(), operation_id, NOW_MS) + .is_ok() + { + winners += 1; + } + } + winners + })); + } + let winners: usize = threads + .into_iter() + .map(|handle| handle.join().expect("claim thread finishes")) + .sum(); + assert_eq!(winners, 1); +} + +#[test] +fn consume_rejects_and_persists_expiry_after_reservation() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let id = handoff.public_claims().invitation_id; + let operation = Uuid::new_v4(); + store + .reserve(id, handoff.secret(), operation, NOW_MS) + .expect("reservation succeeds before expiry"); + assert_eq!( + store.consume( + id, + operation, + NodeId::new("node:late").expect("test node is valid"), + handoff.public_claims().expires_at_ms + 1, + ), + Err(BootstrapError::InvitationExpired) + ); + drop(store); + assert_eq!( + create_store(&temp) + .record(id) + .expect("record lookup succeeds") + .expect("record exists") + .state, + InvitationState::Expired + ); +} + +#[test] +fn state_directory_and_pepper_reject_unsafe_metadata() { + let parent = TempDir::new().expect("temporary directory is created"); + let created = parent.path().join("created-securely"); + let created_store = InvitationStore::open(&created).expect("state directory is created"); + drop(created_store); + assert_eq!( + fs::metadata(&created) + .expect("state directory metadata exists") + .mode() + & 0o777, + 0o700 + ); + + let permissive = parent.path().join("permissive"); + fs::create_dir(&permissive).expect("directory is created"); + fs::set_permissions(&permissive, fs::Permissions::from_mode(0o755)) + .expect("permissions are changed"); + assert!(matches!( + InvitationStore::open(&permissive), + Err(BootstrapError::InvalidStatePath) + )); + + let oversized = parent.path().join("oversized-pepper"); + fs::create_dir(&oversized).expect("directory is created"); + fs::set_permissions(&oversized, fs::Permissions::from_mode(0o700)) + .expect("permissions are changed"); + fs::write(oversized.join(PEPPER_FILE), [7u8; 33]).expect("pepper is created"); + fs::set_permissions( + oversized.join(PEPPER_FILE), + fs::Permissions::from_mode(0o600), + ) + .expect("pepper permissions are changed"); + assert!(matches!( + InvitationStore::open(&oversized), + Err(BootstrapError::InvalidStatePath) + )); +} + +#[test] +fn missing_pepper_never_rotates_an_existing_journal() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let _handoff = create_invitation(&store); + drop(store); + fs::remove_file(temp.path().join(PEPPER_FILE)).expect("test removes the pepper"); + assert!(matches!( + InvitationStore::open(temp.path()), + Err(BootstrapError::InvalidStatePath) + )); + assert!(!temp.path().join(PEPPER_FILE).exists()); +} + +#[test] +fn public_claim_urls_reject_credentials_and_ambient_url_components() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + for invalid in [ + "https://user:pass@100.64.0.1:7443/", + "https://100.64.0.1:7443/enroll", + "https://100.64.0.1:7443/?token=value", + "https://100.64.0.1:7443/#fragment", + "https://authority.example:7443/", + "https://8.8.8.8:7443/", + ] { + let mut invalid_spec = spec(); + invalid_spec.authority_endpoint = Url::parse(invalid).expect("test URL parses"); + assert!( + matches!( + store.create(invalid_spec, NOW_MS), + Err(BootstrapError::InvalidInvitationClaims) + ), + "accepted {invalid}" + ); + } + let mut ipv6 = spec(); + ipv6.authority_endpoint = Url::parse("https://[fd00::1]:7443/").expect("test IPv6 URL parses"); + assert!(store.create(ipv6, NOW_MS).is_ok()); +} + +#[test] +fn bare_relative_state_directory_is_durable() { + let temp = TempDir::new().expect("temporary directory is created"); + let executable = std::env::current_exe().expect("test executable is known"); + let output = std::process::Command::new(executable) + .arg("--exact") + .arg("bare_relative_state_directory_child") + .arg("--nocapture") + .env("AGENET_BARE_RELATIVE_CHILD", "1") + .current_dir(temp.path()) + .output() + .expect("child test runs"); + assert!( + output.status.success(), + "child failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(temp.path().join("state/invitation.journal").is_file()); +} + +#[test] +fn bare_relative_state_directory_child() { + if std::env::var_os("AGENET_BARE_RELATIVE_CHILD").is_none() { + return; + } + let store = InvitationStore::open(std::path::Path::new("state")) + .expect("bare relative state directory opens"); + let _handoff = create_invitation(&store); +} + +#[test] +fn persistence_failure_poison_prevents_retrying_uncertain_mutations() { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let journal_path = temp.path().join(JOURNAL_FILE); + let original_length = fs::metadata(&journal_path) + .expect("journal metadata exists") + .len(); + fs::OpenOptions::new() + .write(true) + .open(&journal_path) + .expect("journal opens for sparse extension") + .set_len(64 * 1024 * 1024) + .expect("journal is extended to its resource limit"); + assert_eq!( + store.reserve( + handoff.public_claims().invitation_id, + handoff.secret(), + Uuid::new_v4(), + NOW_MS, + ), + Err(BootstrapError::ResourceLimitExceeded) + ); + assert_eq!( + store + .record(handoff.public_claims().invitation_id) + .expect("record lookup succeeds") + .expect("record exists") + .state, + InvitationState::Available, + "failed append must not change the projection" + ); + fs::OpenOptions::new() + .write(true) + .open(&journal_path) + .expect("journal reopens for truncation") + .set_len(original_length) + .expect("test restores journal length"); + assert_eq!( + store.reserve( + handoff.public_claims().invitation_id, + handoff.secret(), + Uuid::new_v4(), + NOW_MS, + ), + Err(BootstrapError::PersistenceUnavailable) + ); +} + +#[test] +fn replay_fails_closed_on_torn_corrupt_oversized_or_unsafe_state() { + let torn = TempDir::new().expect("temporary directory is created"); + let store = create_store(&torn); + let _handoff = create_invitation(&store); + drop(store); + fs::OpenOptions::new() + .append(true) + .open(torn.path().join(JOURNAL_FILE)) + .expect("journal opens") + .write_all(&[0, 0, 0]) + .expect("torn bytes append"); + assert!(matches!( + InvitationStore::open(torn.path()), + Err(BootstrapError::InvalidJournal) + )); + + let corrupt = TempDir::new().expect("temporary directory is created"); + let store = create_store(&corrupt); + let _handoff = create_invitation(&store); + drop(store); + let path = corrupt.path().join(JOURNAL_FILE); + let mut bytes = fs::read(&path).expect("journal is readable"); + let last = bytes.len() - 1; + bytes[last] ^= 1; + fs::write(&path, bytes).expect("journal is corrupted"); + assert!(matches!( + InvitationStore::open(corrupt.path()), + Err(BootstrapError::InvalidJournal) + )); + + let oversized = TempDir::new().expect("temporary directory is created"); + let store = create_store(&oversized); + drop(store); + fs::OpenOptions::new() + .append(true) + .open(oversized.path().join(JOURNAL_FILE)) + .expect("journal opens") + .write_all(&u32::MAX.to_be_bytes()) + .expect("oversized length appends"); + assert!(matches!( + InvitationStore::open(oversized.path()), + Err(BootstrapError::InvalidJournal) + )); + + let symlinked = TempDir::new().expect("temporary directory is created"); + fs::write(symlinked.path().join("target"), b"pepper").expect("target is created"); + symlink("target", symlinked.path().join(PEPPER_FILE)).expect("symlink is created"); + assert!(matches!( + InvitationStore::open(symlinked.path()), + Err(BootstrapError::InvalidStatePath) + )); +} + +proptest! { + #![proptest_config(ProptestConfig { + cases: 64, + failure_persistence: None, + .. ProptestConfig::default() + })] + #[test] + fn replay_preserves_transition_invariants(actions in prop::collection::vec(0u8..4, 0..40)) { + let temp = TempDir::new().expect("temporary directory is created"); + let mut store = create_store(&temp); + let handoff = create_invitation(&store); + let id = handoff.public_claims().invitation_id; + let secret = handoff.secret(); + let operation = Uuid::from_u128(7); + let node = NodeId::new("node:property").expect("test node is valid"); + + for (step, action) in actions.into_iter().enumerate() { + let now = NOW_MS + i64::try_from(step).expect("step fits i64"); + match action { + 0 => { let _ = store.reserve(id, secret, operation, now); } + 1 => { let _ = store.release(id, operation); } + 2 => { let _ = store.consume(id, operation, node.clone(), now); } + 3 => { + let wrong = SecretString::from("property-wrong-secret".to_owned()); + let _ = store.reserve(id, &wrong, Uuid::new_v4(), now); + } + _ => unreachable!("strategy generates 0..4"), + } + drop(store); + store = create_store(&temp); + let record = store + .record(id) + .expect("record lookup succeeds") + .expect("record survives replay"); + prop_assert!(record.failed_attempts <= 5); + if record.state == InvitationState::Locked { + prop_assert_ne!(&record.state, &InvitationState::Available); + } + let consumed = matches!(&record.state, InvitationState::Consumed { .. }); + prop_assert!( + !consumed + || store + .consumption(id, operation) + .expect("consumption lookup succeeds") + .is_some() + ); + } + } +} From 2a62e821f5340ab058efd5630422439f1cfeac47 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 19:06:10 +0800 Subject: [PATCH 17/67] [bug] Bind invitation handoff claims Root cause: Invitation authentication omitted mutable public claims. Solution: Bind deterministic claim digests locally and at reservation. Risks: Insecure invitation v1 formats are explicitly rejected. Dependency: Bootstrap step 5. Links: plan/01-v1-multi-host-node-bootstrap.md Post-mortem: Authenticate every trust-routing claim at both edges. --- ROADMAP.md | 10 + src/bootstrap/invitation.rs | 506 ++++++++++++++++++++++++++++++------ src/bootstrap/journal.rs | 12 +- src/bootstrap/mod.rs | 7 +- tests/invitation_store.rs | 223 ++++++++++------ 5 files changed, 581 insertions(+), 177 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7d0479f..ba1c063 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,15 @@ # ROADMAP +## 2026-08-14 19:12 CST + +- **Change**: Bound every one-time invitation public claim to both the Authority's persisted HMAC and a locally verifiable bearer-secret HMAC; replaced the public raw-secret accessor with opaque `InvitationAuthentication` and required reservation to present the complete claims boundary. +- **Files**: `src/bootstrap/invitation.rs`, `src/bootstrap/mod.rs`, `tests/invitation_store.rs`, `ROADMAP.md`; ignored review evidence and the amended Task 5 report under `.superpowers/sdd/01-v1-multi-host-node-bootstrap/`. +- **Decision**: Encode all claims with a deterministic domain-separated, u32-length-prefixed binary format, persist its SHA-256 digest, bind that digest into the pepper-keyed server HMAC, and carry a separate secret-keyed claims-integrity HMAC in the complete handoff. Verify the local tag before network use and verify the same incoming digest during Authority reservation. +- **Reason**: A bearer secret authorizes enrollment only for the exact Authority endpoint, trust fingerprints, domain, directory seeds, profile, capability ceiling, invitation ID, expiry, and protocol policy that the Authority issued. +- **Error record**: Technical blind spot — Task 5 initially authenticated only `invitation_id + secret`, so a party able to rewrite public handoff claims without reading the hidden secret could substitute an attacker endpoint and trust anchor. The same implementation also exposed a public `SecretString` accessor despite the intended TTY/enrollment-only boundary. +- **Prevention**: For every split public-claims/bearer protocol, enumerate and deterministically encode every claim, bind the digest at both local handoff and server authorization boundaries, and test independent mutation of every field plus tag and secret. Secret-bearing types must expose only opaque, redacted capabilities; raw materialization requires a crate-private serialization boundary. +- **Compatibility**: The insecure Task 5 v1 format existed only in the unmerged review commit and has no supported consumer. Corrected writes explicitly emit provisional v2 for both handoff and journal; v1 and unknown future versions return `UnsupportedInvitationFormat`. There is no automatic v1 journal migration because the missing claims digest cannot be reconstructed from its persisted record, and no fallback accepts the insecure handoff shape. + ## 2026-08-14 18:34 CST - **Change**: Implemented the Task 5 durable, one-time invitation store with 256-bit secret generation, HMAC-only persistence, an independently stored owner-only pepper, bounded expiry and lockout, two-phase reserve/consume/release, idempotent winning operations, and crash-safe replay. diff --git a/src/bootstrap/invitation.rs b/src/bootstrap/invitation.rs index 6b8fe87..f714899 100644 --- a/src/bootstrap/invitation.rs +++ b/src/bootstrap/invitation.rs @@ -13,7 +13,7 @@ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use hmac::{Hmac, KeyInit, Mac}; use reqwest::Url; use serde::{Deserialize, Serialize}; -use sha2::Sha256; +use sha2::{Digest, Sha256}; use uuid::Uuid; use zeroize::{Zeroize, Zeroizing}; @@ -31,7 +31,7 @@ const MAXIMUM_ATTEMPTS: u8 = 5; const MAX_INVITATIONS: usize = 10_000; const MAX_DIRECTORY_SEEDS: usize = 16; const MAX_CAPABILITIES: usize = 64; -const HANDOFF_FORMAT_V1: &str = "agenet.invitation-handoff.v1"; +const HANDOFF_FORMAT_V2: &str = "agenet.invitation-handoff.v2"; const MAX_HANDOFF_BYTES: usize = 16 * 1024; type HmacSha256 = Hmac; @@ -40,6 +40,7 @@ type HmacSha256 = Hmac; #[serde(deny_unknown_fields)] pub struct InvitationRecord { pub invitation_id: Uuid, + pub public_claims_sha256: [u8; 32], pub secret_hmac_sha256: [u8; 32], pub allowed_profile: BootstrapProfile, pub capability_ceiling: BTreeSet, @@ -53,6 +54,7 @@ impl Debug for InvitationRecord { formatter .debug_struct("InvitationRecord") .field("invitation_id", &self.invitation_id) + .field("public_claims_sha256", &"[REDACTED]") .field("secret_hmac_sha256", &"[REDACTED]") .field("allowed_profile", &self.allowed_profile) .field("capability_count", &self.capability_ceiling.len()) @@ -114,7 +116,7 @@ pub enum InvitationState { pub struct InvitationHandoff { public_claims: InvitationPublicClaims, - secret: SecretString, + authentication: InvitationAuthentication, } impl InvitationHandoff { @@ -122,12 +124,36 @@ impl InvitationHandoff { &self.public_claims } - /// Returns an opaque secret container for enrollment authentication. - /// Complete handoff materialization is restricted to this module's - /// explicit hidden-TTY display/input functions. - pub fn secret(&self) -> &SecretString { + /// Returns the opaque bearer proof used by the Authority reservation API. + pub fn authentication(&self) -> &InvitationAuthentication { + &self.authentication + } +} + +/// Opaque invitation bearer proof. Its secret is never publicly exposed. +pub struct InvitationAuthentication { + secret: SecretString, + claims_integrity_hmac_sha256: [u8; 32], +} + +impl InvitationAuthentication { + pub(crate) fn secret_for_request(&self) -> &SecretString { &self.secret } + + pub(crate) fn claims_integrity_hmac_for_request(&self) -> &[u8; 32] { + &self.claims_integrity_hmac_sha256 + } +} + +impl Debug for InvitationAuthentication { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("InvitationAuthentication") + .field("secret", &"[REDACTED]") + .field("claims_integrity_hmac_sha256", &"[REDACTED]") + .finish() + } } impl Debug for InvitationHandoff { @@ -148,6 +174,7 @@ struct InvitationHandoffRef<'a> { format_version: &'static str, public_claims: &'a InvitationPublicClaims, secret: &'a str, + claims_integrity_hmac_sha256_base64: String, } #[derive(Deserialize)] @@ -156,6 +183,12 @@ struct InvitationHandoffOwned { format_version: String, public_claims: InvitationPublicClaims, secret: ZeroizingSecretText, + claims_integrity_hmac_sha256_base64: String, +} + +#[derive(Deserialize)] +struct InvitationHandoffFormatProbe { + format_version: String, } struct ZeroizingSecretText(String); @@ -207,9 +240,11 @@ pub fn display_invitation_handoff_to_tty( fn encode_handoff(handoff: &InvitationHandoff) -> Result>, BootstrapError> { validate_public_claims(&handoff.public_claims)?; let wire = InvitationHandoffRef { - format_version: HANDOFF_FORMAT_V1, + format_version: HANDOFF_FORMAT_V2, public_claims: &handoff.public_claims, - secret: handoff.secret.expose_secret(), + secret: handoff.authentication.secret_for_request().expose_secret(), + claims_integrity_hmac_sha256_base64: URL_SAFE_NO_PAD + .encode(handoff.authentication.claims_integrity_hmac_for_request()), }; let encoded = Zeroizing::new( serde_json::to_vec(&wire).map_err(|_| BootstrapError::InvalidInvitationClaims)?, @@ -236,19 +271,31 @@ fn decode_handoff(encoded: &[u8]) -> Result { if encoded.len() > MAX_HANDOFF_BYTES { return Err(BootstrapError::ResourceLimitExceeded); } + let format: InvitationHandoffFormatProbe = + serde_json::from_slice(encoded).map_err(|_| BootstrapError::InvalidInvitationClaims)?; + if format.format_version != HANDOFF_FORMAT_V2 { + return Err(BootstrapError::UnsupportedInvitationFormat); + } let owned: InvitationHandoffOwned = serde_json::from_slice(encoded).map_err(|_| BootstrapError::InvalidInvitationClaims)?; - if owned.format_version != HANDOFF_FORMAT_V1 { - return Err(BootstrapError::InvalidInvitationClaims); + if owned.format_version != HANDOFF_FORMAT_V2 { + return Err(BootstrapError::UnsupportedInvitationFormat); } validate_public_claims(&owned.public_claims)?; let secret = owned.secret.into_secret(); - if !normalized_secret(&secret).1 { + let secret_bytes = decode_secret(&secret)?; + let claims_integrity_hmac_sha256 = + decode_sha256_base64(&owned.claims_integrity_hmac_sha256_base64)?; + let claims_sha256 = public_claims_sha256(&owned.public_claims)?; + if !verify_claims_integrity(&secret_bytes, &claims_sha256, &claims_integrity_hmac_sha256)? { return Err(BootstrapError::InvalidInvitationClaims); } Ok(InvitationHandoff { public_claims: owned.public_claims, - secret, + authentication: InvitationAuthentication { + secret, + claims_integrity_hmac_sha256, + }, }) } @@ -353,7 +400,7 @@ impl InvitationStore { let delta = validate_event(&projection, &event)?; apply_delta(&mut projection, delta); } - let dummy_hmac = compute_hmac(&pepper, Uuid::nil(), b"invalid-invitation")?; + let dummy_hmac = compute_hmac(&pepper, Uuid::nil(), &[0u8; 32], b"invalid-invitation")?; Ok(Self { pepper, dummy_hmac, @@ -386,21 +433,6 @@ impl InvitationStore { return Err(BootstrapError::ResourceLimitExceeded); } let invitation_id = unique_invitation_id(&state.projection.records)?; - let secret_hmac_sha256 = compute_hmac( - &self.pepper, - invitation_id, - secret.expose_secret().as_bytes(), - )?; - let record = InvitationRecord { - invitation_id, - secret_hmac_sha256, - allowed_profile: specification.allowed_profile, - capability_ceiling: specification.capability_ceiling.clone(), - expires_at_ms, - failed_attempts: 0, - state: InvitationState::Available, - }; - append_event(&mut state, InvitationEvent::Created { record })?; let public_claims = InvitationPublicClaims { protocol_version: specification.protocol_version, domain_id: specification.domain_id, @@ -414,16 +446,39 @@ impl InvitationStore { expires_at_ms, maximum_attempts: MAXIMUM_ATTEMPTS, }; + let public_claims_sha256 = public_claims_sha256(&public_claims)?; + let secret_hmac_sha256 = compute_hmac( + &self.pepper, + invitation_id, + &public_claims_sha256, + secret.expose_secret().as_bytes(), + )?; + let claims_integrity_hmac_sha256 = + compute_claims_integrity(&secret_bytes, &public_claims_sha256)?; + let record = InvitationRecord { + invitation_id, + public_claims_sha256, + secret_hmac_sha256, + allowed_profile: public_claims.allowed_profile, + capability_ceiling: public_claims.capability_ceiling.clone(), + expires_at_ms, + failed_attempts: 0, + state: InvitationState::Available, + }; + append_event(&mut state, InvitationEvent::Created { record })?; Ok(InvitationHandoff { public_claims, - secret, + authentication: InvitationAuthentication { + secret, + claims_integrity_hmac_sha256, + }, }) } pub fn reserve( &self, - invitation_id: Uuid, - secret: &SecretString, + public_claims: &InvitationPublicClaims, + authentication: &InvitationAuthentication, operation_id: Uuid, now_ms: i64, ) -> Result { @@ -431,14 +486,28 @@ impl InvitationStore { if now_ms <= 0 { return Err(BootstrapError::InvalidTimestamp); } + let claims_sha256 = + public_claims_sha256(public_claims).map_err(|_| BootstrapError::InvalidInvitation)?; + let invitation_id = public_claims.invitation_id; let mut state = self.lock_state()?; let record = state.projection.records.get(&invitation_id); let expected = record .map(|record| &record.secret_hmac_sha256) .unwrap_or(&self.dummy_hmac); - let (candidate, structurally_valid) = normalized_secret(secret); - let authenticated = - verify_hmac(&self.pepper, invitation_id, candidate, expected)? && structurally_valid; + let (candidate, structurally_valid) = normalized_secret(&authentication.secret); + let integrity_valid = verify_claims_integrity_from_encoded_secret( + &authentication.secret, + &claims_sha256, + &authentication.claims_integrity_hmac_sha256, + )?; + let authenticated = verify_hmac( + &self.pepper, + invitation_id, + &claims_sha256, + candidate, + expected, + )? && structurally_valid + && integrity_valid; if record.is_none() || !authenticated { let should_record_failure = record.is_some_and(|record| record.state == InvitationState::Available); @@ -634,6 +703,7 @@ fn validate_event( match event { InvitationEvent::Created { record } => { if record.invitation_id.is_nil() + || record.public_claims_sha256 == [0u8; 32] || record.failed_attempts != 0 || record.state != InvitationState::Available || projection.records.len() >= MAX_INVITATIONS @@ -843,12 +913,14 @@ fn available_record<'a>( fn compute_hmac( pepper: &[u8; PEPPER_BYTES], invitation_id: Uuid, + public_claims_sha256: &[u8; 32], secret: &[u8], ) -> Result<[u8; 32], BootstrapError> { let mut mac = HmacSha256::new_from_slice(pepper).map_err(|_| BootstrapError::SecretUnavailable)?; mac.update(b"AGENET\0invitation-secret-v0.2\0"); mac.update(invitation_id.as_bytes()); + mac.update(public_claims_sha256); mac.update(secret); Ok(mac.finalize().into_bytes().into()) } @@ -856,6 +928,7 @@ fn compute_hmac( fn verify_hmac( pepper: &[u8; PEPPER_BYTES], invitation_id: Uuid, + public_claims_sha256: &[u8; 32], secret: &[u8], expected: &[u8; 32], ) -> Result { @@ -863,6 +936,7 @@ fn verify_hmac( HmacSha256::new_from_slice(pepper).map_err(|_| BootstrapError::SecretUnavailable)?; mac.update(b"AGENET\0invitation-secret-v0.2\0"); mac.update(invitation_id.as_bytes()); + mac.update(public_claims_sha256); mac.update(secret); Ok(mac.verify_slice(expected).is_ok()) } @@ -882,6 +956,122 @@ fn normalized_secret(secret: &SecretString) -> (&[u8], bool) { } } +fn public_claims_sha256(claims: &InvitationPublicClaims) -> Result<[u8; 32], BootstrapError> { + Ok(Sha256::digest(encode_public_claims(claims)?).into()) +} + +fn encode_public_claims(claims: &InvitationPublicClaims) -> Result, BootstrapError> { + validate_public_claims(claims)?; + let mut encoded = Vec::with_capacity(512); + encoded.extend_from_slice(b"AGENET\0invitation-public-claims-v1\0"); + append_claim_field(&mut encoded, claims.protocol_version.as_bytes())?; + append_claim_field(&mut encoded, claims.domain_id.as_str().as_bytes())?; + append_claim_field(&mut encoded, claims.authority_endpoint.as_str().as_bytes())?; + + let mut directories = Vec::new(); + directories.extend_from_slice( + &u32::try_from(claims.directory_seeds.len()) + .map_err(|_| BootstrapError::ResourceLimitExceeded)? + .to_be_bytes(), + ); + for directory in &claims.directory_seeds { + append_claim_field(&mut directories, directory.as_str().as_bytes())?; + } + append_claim_field(&mut encoded, &directories)?; + append_claim_field(&mut encoded, claims.root_sha256.as_bytes())?; + append_claim_field(&mut encoded, claims.tls_ca_sha256.as_bytes())?; + append_claim_field( + &mut encoded, + &[bootstrap_profile_discriminant(claims.allowed_profile)], + )?; + + let mut capabilities = Vec::new(); + capabilities.extend_from_slice( + &u32::try_from(claims.capability_ceiling.len()) + .map_err(|_| BootstrapError::ResourceLimitExceeded)? + .to_be_bytes(), + ); + for capability in &claims.capability_ceiling { + append_claim_field(&mut capabilities, capability.as_str().as_bytes())?; + } + append_claim_field(&mut encoded, &capabilities)?; + append_claim_field(&mut encoded, claims.invitation_id.as_bytes())?; + append_claim_field(&mut encoded, &claims.expires_at_ms.to_be_bytes())?; + append_claim_field(&mut encoded, &[claims.maximum_attempts])?; + Ok(encoded) +} + +fn append_claim_field(encoded: &mut Vec, value: &[u8]) -> Result<(), BootstrapError> { + let length = u32::try_from(value.len()).map_err(|_| BootstrapError::ResourceLimitExceeded)?; + encoded.extend_from_slice(&length.to_be_bytes()); + encoded.extend_from_slice(value); + Ok(()) +} + +fn bootstrap_profile_discriminant(profile: BootstrapProfile) -> u8 { + match profile { + BootstrapProfile::Base => 0, + BootstrapProfile::Provider => 1, + BootstrapProfile::AgentCandidate => 2, + } +} + +fn compute_claims_integrity( + secret: &[u8; SECRET_BYTES], + public_claims_sha256: &[u8; 32], +) -> Result<[u8; 32], BootstrapError> { + let mut mac = + HmacSha256::new_from_slice(secret).map_err(|_| BootstrapError::SecretUnavailable)?; + mac.update(b"AGENET\0invitation-claims-integrity-v1\0"); + mac.update(public_claims_sha256); + Ok(mac.finalize().into_bytes().into()) +} + +fn verify_claims_integrity( + secret: &[u8; SECRET_BYTES], + public_claims_sha256: &[u8; 32], + expected: &[u8; 32], +) -> Result { + let mut mac = + HmacSha256::new_from_slice(secret).map_err(|_| BootstrapError::SecretUnavailable)?; + mac.update(b"AGENET\0invitation-claims-integrity-v1\0"); + mac.update(public_claims_sha256); + Ok(mac.verify_slice(expected).is_ok()) +} + +fn verify_claims_integrity_from_encoded_secret( + secret: &SecretString, + public_claims_sha256: &[u8; 32], + expected: &[u8; 32], +) -> Result { + let Ok(secret) = decode_secret(secret) else { + return Ok(false); + }; + verify_claims_integrity(&secret, public_claims_sha256, expected) +} + +fn decode_secret(secret: &SecretString) -> Result, BootstrapError> { + let mut decoded = Zeroizing::new([0u8; SECRET_BYTES]); + let length = URL_SAFE_NO_PAD + .decode_slice(secret.expose_secret().as_bytes(), &mut decoded[..]) + .map_err(|_| BootstrapError::InvalidInvitationClaims)?; + if length != SECRET_BYTES { + return Err(BootstrapError::InvalidInvitationClaims); + } + Ok(decoded) +} + +fn decode_sha256_base64(value: &str) -> Result<[u8; 32], BootstrapError> { + let mut decoded = [0u8; 32]; + let length = URL_SAFE_NO_PAD + .decode_slice(value.as_bytes(), &mut decoded) + .map_err(|_| BootstrapError::InvalidInvitationClaims)?; + if length != decoded.len() { + return Err(BootstrapError::InvalidInvitationClaims); + } + Ok(decoded) +} + fn validate_operation_id(operation_id: Uuid) -> Result<(), BootstrapError> { if operation_id.is_nil() { return Err(BootstrapError::InvalidOperationId); @@ -1171,6 +1361,7 @@ mod tests { fn record(invitation_id: Uuid) -> InvitationRecord { InvitationRecord { invitation_id, + public_claims_sha256: [8; 32], secret_hmac_sha256: [7; 32], allowed_profile: BootstrapProfile::Base, capability_ceiling: BTreeSet::new(), @@ -1180,6 +1371,36 @@ mod tests { } } + fn handoff(invitation_id: Uuid, domain: &str) -> InvitationHandoff { + let public_claims = InvitationPublicClaims { + protocol_version: "agenet.enrollment.v0.2".to_owned(), + domain_id: DomainId::new(domain).expect("test domain is valid"), + authority_endpoint: Url::parse("https://100.64.0.1:7443/").expect("test URL is valid"), + directory_seeds: vec![ + Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), + ], + root_sha256: "11".repeat(32), + tls_ca_sha256: "22".repeat(32), + allowed_profile: BootstrapProfile::Base, + capability_ceiling: BTreeSet::new(), + invitation_id, + expires_at_ms: 1_800_000_600_000, + maximum_attempts: 5, + }; + let secret_bytes = [9u8; SECRET_BYTES]; + let secret = SecretString::from(URL_SAFE_NO_PAD.encode(secret_bytes)); + let claims_sha256 = public_claims_sha256(&public_claims).expect("claims encode"); + let claims_integrity_hmac_sha256 = + compute_claims_integrity(&secret_bytes, &claims_sha256).expect("tag computes"); + InvitationHandoff { + public_claims, + authentication: InvitationAuthentication { + secret, + claims_integrity_hmac_sha256, + }, + } + } + #[test] fn deserialized_secret_wrapper_redacts_and_zeroizes() { let mut secret = serde_json::from_str::("\"sensitive-test-value\"") @@ -1189,6 +1410,64 @@ mod tests { assert!(secret.0.is_empty()); } + #[test] + fn created_secrets_are_independent_256_bit_values_and_never_persisted() { + let temp = tempfile::TempDir::new().expect("temporary directory is created"); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("state directory is owner-only"); + let store = InvitationStore::open(temp.path()).expect("store opens"); + let specification = || InvitationSpec { + protocol_version: "agenet.enrollment.v0.2".to_owned(), + domain_id: DomainId::new("domain:secret-proof").expect("test domain is valid"), + authority_endpoint: Url::parse("https://100.64.0.1:7443/").expect("test URL is valid"), + directory_seeds: vec![ + Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), + ], + root_sha256: "11".repeat(32), + tls_ca_sha256: "22".repeat(32), + allowed_profile: BootstrapProfile::Base, + capability_ceiling: BTreeSet::new(), + }; + let first = store + .create(specification(), 1_800_000_000_000) + .expect("first invitation is created"); + let second = store + .create(specification(), 1_800_000_000_001) + .expect("second invitation is created"); + let first_raw = decode_secret(&first.authentication.secret).expect("first secret decodes"); + let second_raw = + decode_secret(&second.authentication.secret).expect("second secret decodes"); + assert_eq!(first_raw.len(), SECRET_BYTES); + assert_eq!(second_raw.len(), SECRET_BYTES); + assert_ne!(&*first_raw, &*second_raw); + + let journal = fs::read(temp.path().join(JOURNAL_FILE)).expect("journal is readable"); + let pepper = fs::read(temp.path().join(PEPPER_FILE)).expect("pepper is readable"); + for encoded in [ + first.authentication.secret.expose_secret().as_bytes(), + second.authentication.secret.expose_secret().as_bytes(), + ] { + assert!( + !journal + .windows(encoded.len()) + .any(|window| window == encoded) + ); + } + for raw in [&*first_raw, &*second_raw] { + assert!(!journal.windows(raw.len()).any(|window| window == raw)); + } + assert!(!journal.windows(pepper.len()).any(|window| window == pepper)); + + let first_record = store + .record(first.public_claims.invitation_id) + .expect("record lookup succeeds") + .expect("record exists"); + assert_eq!( + first_record.public_claims_sha256, + public_claims_sha256(&first.public_claims).expect("claims hash") + ); + } + #[test] fn journal_validation_rejects_nil_ids_invalid_times_and_invalid_node() { let projection = Projection::default(); @@ -1259,67 +1538,52 @@ mod tests { #[test] fn versioned_handoff_codec_round_trips_without_debug_exposure() { - let handoff = InvitationHandoff { - public_claims: InvitationPublicClaims { - protocol_version: "agenet.enrollment.v0.2".to_owned(), - domain_id: DomainId::new("domain:codec").expect("test domain is valid"), - authority_endpoint: Url::parse("https://100.64.0.1:7443/") - .expect("test URL is valid"), - directory_seeds: vec![ - Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), - ], - root_sha256: "11".repeat(32), - tls_ca_sha256: "22".repeat(32), - allowed_profile: BootstrapProfile::Base, - capability_ceiling: BTreeSet::new(), - invitation_id: Uuid::from_u128(1), - expires_at_ms: 1_800_000_600_000, - maximum_attempts: 5, - }, - secret: SecretString::from("public-test-invitation-secret-material-0000".to_owned()), - }; + let handoff = handoff(Uuid::from_u128(1), "domain:codec"); let encoded = encode_handoff(&handoff).expect("handoff encodes"); + let format: InvitationHandoffFormatProbe = + serde_json::from_slice(&encoded).expect("format probe parses"); + assert_eq!(format.format_version, HANDOFF_FORMAT_V2); let decoded = decode_handoff(&encoded).expect("handoff decodes"); assert_eq!(decoded.public_claims, handoff.public_claims); assert!( - decoded.secret.expose_secret() == handoff.secret.expose_secret(), + decoded.authentication.secret.expose_secret() + == handoff.authentication.secret.expose_secret(), "decoded secret must match without printing either value" ); - assert!(!format!("{decoded:?}").contains(handoff.secret.expose_secret())); + assert!(!format!("{decoded:?}").contains(handoff.authentication.secret.expose_secret())); + assert_eq!( + decode_secret(&handoff.authentication.secret) + .expect("generated secret decodes") + .len(), + 32 + ); } #[test] - fn versioned_handoff_codec_rejects_unknown_version_and_oversize() { - let handoff = InvitationHandoff { - public_claims: InvitationPublicClaims { - protocol_version: "agenet.enrollment.v0.2".to_owned(), - domain_id: DomainId::new("domain:codec-errors").expect("test domain is valid"), - authority_endpoint: Url::parse("https://100.64.0.1:7443/") - .expect("test URL is valid"), - directory_seeds: vec![ - Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), - ], - root_sha256: "11".repeat(32), - tls_ca_sha256: "22".repeat(32), - allowed_profile: BootstrapProfile::Base, - capability_ceiling: BTreeSet::new(), - invitation_id: Uuid::from_u128(2), - expires_at_ms: 1_800_000_600_000, - maximum_attempts: 5, - }, - secret: SecretString::from("public-test-invitation-secret-material-0000".to_owned()), - }; - let mut encoded = encode_handoff(&handoff).expect("handoff encodes"); - let version = encoded - .windows(HANDOFF_FORMAT_V1.len()) - .position(|window| window == HANDOFF_FORMAT_V1.as_bytes()) - .expect("format version is present"); - encoded[version] = b'x'; - assert!(matches!( - decode_handoff(&encoded), - Err(BootstrapError::InvalidInvitationClaims) - )); + fn versioned_handoff_codec_rejects_legacy_future_and_oversize() { + let handoff = handoff(Uuid::from_u128(2), "domain:codec-errors"); + let encoded = encode_handoff(&handoff).expect("handoff encodes"); + let original: serde_json::Value = + serde_json::from_slice(&encoded).expect("handoff JSON parses"); + for version in [ + "agenet.invitation-handoff.v1", + "agenet.invitation-handoff.v3", + ] { + let mut unsupported = original.clone(); + unsupported["format_version"] = serde_json::json!(version); + if version.ends_with("v1") { + unsupported + .as_object_mut() + .expect("handoff is an object") + .remove("claims_integrity_hmac_sha256_base64"); + } + let bytes = serde_json::to_vec(&unsupported).expect("test handoff serializes"); + assert!(matches!( + decode_handoff(&bytes), + Err(BootstrapError::UnsupportedInvitationFormat) + )); + } let oversized = vec![b'x'; MAX_HANDOFF_BYTES + 1]; assert!(matches!( @@ -1336,4 +1600,74 @@ mod tests { Err(BootstrapError::InvalidInvitationClaims) )); } + + #[test] + fn versioned_handoff_rejects_each_tampered_security_claim() { + let handoff = handoff(Uuid::from_u128(3), "domain:claims-tamper"); + let encoded = encode_handoff(&handoff).expect("handoff encodes"); + assert!(decode_handoff(&encoded).is_ok()); + let original: serde_json::Value = + serde_json::from_slice(&encoded).expect("handoff JSON parses"); + let mutations: &[(&str, serde_json::Value)] = &[ + ( + "protocol_version", + serde_json::json!("agenet.enrollment.v0.3"), + ), + ("domain_id", serde_json::json!("domain:attacker")), + ( + "authority_endpoint", + serde_json::json!("https://100.64.0.99:7443/"), + ), + ( + "directory_seeds", + serde_json::json!(["https://100.64.0.99:7444/"]), + ), + ("root_sha256", serde_json::json!("33".repeat(32))), + ("tls_ca_sha256", serde_json::json!("44".repeat(32))), + ("allowed_profile", serde_json::json!("provider")), + ( + "capability_ceiling", + serde_json::json!(["source.metrics.v1"]), + ), + ( + "invitation_id", + serde_json::json!(Uuid::from_u128(99).to_string()), + ), + ("expires_at_ms", serde_json::json!(1_800_000_700_000_i64)), + ("maximum_attempts", serde_json::json!(4)), + ]; + for (field, replacement) in mutations { + let mut tampered = original.clone(); + tampered["public_claims"][field] = replacement.clone(); + let bytes = serde_json::to_vec(&tampered).expect("tampered JSON serializes"); + assert!( + matches!( + decode_handoff(&bytes), + Err(BootstrapError::InvalidInvitationClaims) + ), + "accepted tampered {field}" + ); + } + for (field, replacement) in [ + ( + "secret", + serde_json::json!(URL_SAFE_NO_PAD.encode([8u8; SECRET_BYTES])), + ), + ( + "claims_integrity_hmac_sha256_base64", + serde_json::json!(URL_SAFE_NO_PAD.encode([7u8; 32])), + ), + ] { + let mut tampered = original.clone(); + tampered[field] = replacement; + let bytes = serde_json::to_vec(&tampered).expect("tampered JSON serializes"); + assert!( + matches!( + decode_handoff(&bytes), + Err(BootstrapError::InvalidInvitationClaims) + ), + "accepted tampered {field}" + ); + } + } } diff --git a/src/bootstrap/journal.rs b/src/bootstrap/journal.rs index 4a354fc..9fb9342 100644 --- a/src/bootstrap/journal.rs +++ b/src/bootstrap/journal.rs @@ -11,7 +11,8 @@ use sha2::{Digest, Sha256}; use super::BootstrapError; -const HEADER: &[u8] = b"AGENET-INVITATION-JOURNAL\0\x01"; +const HEADER_PREFIX: &[u8] = b"AGENET-INVITATION-JOURNAL\0"; +const HEADER_V2: &[u8] = b"AGENET-INVITATION-JOURNAL\0\x02"; const CHECKSUM_BYTES: usize = 32; const MAX_RECORD_BYTES: usize = 64 * 1024; const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024; @@ -32,7 +33,7 @@ where let metadata = file.metadata().map_err(|_| BootstrapError::StorageFailed)?; require_owner_only_regular(&metadata)?; if created { - file.write_all(HEADER) + file.write_all(HEADER_V2) .map_err(|_| BootstrapError::StorageFailed)?; persist(&mut file)?; sync_parent(path)?; @@ -128,10 +129,13 @@ fn open_new(path: &Path) -> Result { } fn decode_entries(bytes: &[u8]) -> Result, BootstrapError> { - if !bytes.starts_with(HEADER) { + if bytes.starts_with(HEADER_PREFIX) && !bytes.starts_with(HEADER_V2) { + return Err(BootstrapError::UnsupportedInvitationFormat); + } + if !bytes.starts_with(HEADER_V2) { return Err(BootstrapError::InvalidJournal); } - let mut cursor = HEADER.len(); + let mut cursor = HEADER_V2.len(); let mut entries = Vec::new(); while cursor < bytes.len() { if entries.len() >= MAX_RECORDS || bytes.len() - cursor < 4 { diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 29cb7c7..04c587e 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -9,9 +9,9 @@ mod pki; use std::fmt::{Display, Formatter}; pub use invitation::{ - ConsumptionResult, InvitationHandoff, InvitationPublicClaims, InvitationRecord, InvitationSpec, - InvitationState, InvitationStore, ReservationStatus, display_invitation_handoff_to_tty, - read_invitation_handoff_from_tty, + ConsumptionResult, InvitationAuthentication, InvitationHandoff, InvitationPublicClaims, + InvitationRecord, InvitationSpec, InvitationState, InvitationStore, ReservationStatus, + display_invitation_handoff_to_tty, read_invitation_handoff_from_tty, }; pub use keystore::{ AgeRootKeystore, DomainRootMaterial, LegacyV1MigrationPolicy, RootKeystore, @@ -41,6 +41,7 @@ pub enum BootstrapError { HandoffTtyUnavailable, InvalidOperationId, InvalidTimestamp, + UnsupportedInvitationFormat, } impl Display for BootstrapError { diff --git a/tests/invitation_store.rs b/tests/invitation_store.rs index 12aae9d..c3f45b1 100644 --- a/tests/invitation_store.rs +++ b/tests/invitation_store.rs @@ -7,11 +7,10 @@ use std::{ thread, }; -use age::secrecy::{ExposeSecret, SecretString}; use agenet::{ bootstrap::{ - BootstrapError, InvitationHandoff, InvitationSpec, InvitationState, InvitationStore, - ReservationStatus, display_invitation_handoff_to_tty, + BootstrapError, InvitationHandoff, InvitationPublicClaims, InvitationSpec, InvitationState, + InvitationStore, ReservationStatus, display_invitation_handoff_to_tty, }, protocol::{BootstrapProfile, CapabilityKind, DomainId, NodeId}, }; @@ -19,12 +18,13 @@ use proptest::prelude::*; use reqwest::Url; use tempfile::TempDir; use uuid::Uuid; -use zeroize::Zeroizing; const NOW_MS: i64 = 1_800_000_000_000; const PEPPER_FILE: &str = "invitation.pepper"; const JOURNAL_FILE: &str = "invitation.journal"; +type ClaimsMutation = (&'static str, fn(&mut InvitationPublicClaims)); + fn capability(value: &str) -> CapabilityKind { CapabilityKind::new(value).expect("test capability is valid") } @@ -52,6 +52,20 @@ fn create_invitation(store: &InvitationStore) -> InvitationHandoff { store.create(spec(), NOW_MS).expect("invitation is created") } +fn reserve_handoff( + store: &InvitationStore, + handoff: &InvitationHandoff, + operation_id: Uuid, + now_ms: i64, +) -> Result { + store.reserve( + handoff.public_claims(), + handoff.authentication(), + operation_id, + now_ms, + ) +} + #[test] fn capability_kind_is_validated_ordered_and_round_trips() { let source = capability("source.metrics.v1"); @@ -78,23 +92,19 @@ fn capability_kind_is_validated_ordered_and_round_trips() { } #[test] -fn create_uses_a_256_bit_secret_and_ten_minute_default_expiry() { +fn create_uses_opaque_authentication_and_ten_minute_default_expiry() { let temp = TempDir::new().expect("temporary directory is created"); let store = create_store(&temp); let first = create_invitation(&store); let second = create_invitation(&store); - let first_bytes = Zeroizing::new( - base64::Engine::decode( - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - first.secret().expose_secret(), - ) - .expect("secret has URL-safe encoding"), + assert_eq!( + format!("{:?}", first.authentication()), + format!("{:?}", second.authentication()) ); - assert_eq!(first_bytes.len(), 32); - assert!( - first.secret().expose_secret() != second.secret().expose_secret(), - "independent generated secrets collided" + assert_eq!( + format!("{:?}", first.authentication()), + "InvitationAuthentication { secret: \"[REDACTED]\", claims_integrity_hmac_sha256: \"[REDACTED]\" }" ); assert_eq!( first.public_claims().expires_at_ms, @@ -108,14 +118,6 @@ fn persistence_contains_only_hmac_and_owner_only_separate_pepper() { let temp = TempDir::new().expect("temporary directory is created"); let store = create_store(&temp); let handoff = create_invitation(&store); - let secret = handoff.secret().expose_secret().as_bytes(); - let secret_raw = Zeroizing::new( - base64::Engine::decode( - &base64::engine::general_purpose::URL_SAFE_NO_PAD, - handoff.secret().expose_secret(), - ) - .expect("secret has URL-safe encoding"), - ); let record = store .record(handoff.public_claims().invitation_id) .expect("record lookup succeeds") @@ -138,12 +140,8 @@ fn persistence_contains_only_hmac_and_owner_only_separate_pepper() { & 0o777, 0o600 ); - assert!(!journal.windows(secret.len()).any(|window| window == secret)); assert!(!journal.windows(pepper.len()).any(|window| window == pepper)); - assert!( - record.secret_hmac_sha256.as_slice() != secret_raw.as_slice(), - "persisted value must not equal raw invitation secret" - ); + assert_ne!(record.public_claims_sha256, record.secret_hmac_sha256); } #[test] @@ -152,7 +150,6 @@ fn handoff_debug_redacts_the_secret() { let handoff = create_invitation(&create_store(&temp)); let debug = format!("{handoff:?}"); assert!(debug.contains("[REDACTED]")); - assert!(!debug.contains(handoff.secret().expose_secret())); assert!(!debug.contains("domain:test")); assert!(!debug.contains("100.64.0.1")); assert!(!debug.contains(&"11".repeat(32))); @@ -175,9 +172,65 @@ fn record_debug_redacts_persisted_authenticator() { .expect("record lookup succeeds") .expect("record exists"); let authenticator_debug = format!("{:?}", record.secret_hmac_sha256); + let claims_digest_debug = format!("{:?}", record.public_claims_sha256); let record_debug = format!("{record:?}"); assert!(record_debug.contains("[REDACTED]")); assert!(!record_debug.contains(&authenticator_debug)); + assert!(!record_debug.contains(&claims_digest_debug)); +} + +#[test] +fn every_tampered_security_claim_is_rejected_before_reservation() { + let mutations: &[ClaimsMutation] = &[ + ("protocol_version", |claims| { + claims.protocol_version = "agenet.enrollment.v0.3".to_owned(); + }), + ("domain_id", |claims| { + claims.domain_id = DomainId::new("domain:attacker").expect("test domain is valid"); + }), + ("authority_endpoint", |claims| { + claims.authority_endpoint = + Url::parse("https://100.64.0.99:7443/").expect("test URL is valid"); + }), + ("directory_seeds", |claims| { + claims.directory_seeds = + vec![Url::parse("https://100.64.0.99:7444/").expect("test URL is valid")]; + }), + ("root_sha256", |claims| claims.root_sha256 = "33".repeat(32)), + ("tls_ca_sha256", |claims| { + claims.tls_ca_sha256 = "44".repeat(32); + }), + ("allowed_profile", |claims| { + claims.allowed_profile = BootstrapProfile::AgentCandidate; + }), + ("capability_ceiling", |claims| { + claims + .capability_ceiling + .insert(capability("source.metrics.verify.v1")); + }), + ("invitation_id", |claims| { + claims.invitation_id = Uuid::new_v4(); + }), + ("expires_at_ms", |claims| claims.expires_at_ms += 1), + ("maximum_attempts", |claims| claims.maximum_attempts = 4), + ]; + for (name, mutate) in mutations { + let temp = TempDir::new().expect("temporary directory is created"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let mut tampered = handoff.public_claims().clone(); + mutate(&mut tampered); + assert_eq!( + store.reserve(&tampered, handoff.authentication(), Uuid::new_v4(), NOW_MS,), + Err(BootstrapError::InvalidInvitation), + "accepted tampered {name}" + ); + assert_eq!( + reserve_handoff(&store, &handoff, Uuid::new_v4(), NOW_MS), + Ok(ReservationStatus::Reserved), + "original failed after tampered {name}" + ); + } } #[test] @@ -192,12 +245,10 @@ fn public_mutations_reject_invalid_timestamps_without_journal_errors() { let id = handoff.public_claims().invitation_id; let operation = Uuid::new_v4(); assert_eq!( - store.reserve(id, handoff.secret(), operation, 0), + reserve_handoff(&store, &handoff, operation, 0), Err(BootstrapError::InvalidTimestamp) ); - store - .reserve(id, handoff.secret(), operation, NOW_MS) - .expect("valid reservation succeeds"); + reserve_handoff(&store, &handoff, operation, NOW_MS).expect("valid reservation succeeds"); assert_eq!( store.consume( id, @@ -214,16 +265,23 @@ fn fifth_wrong_secret_locks_and_unknown_is_indistinguishable() { let temp = TempDir::new().expect("temporary directory is created"); let store = create_store(&temp); let handoff = create_invitation(&store); - let wrong = SecretString::from("wrong-invitation-secret-material-0000000000".to_owned()); + let wrong = create_invitation(&store); + let mut unknown_claims = handoff.public_claims().clone(); + unknown_claims.invitation_id = Uuid::new_v4(); let unknown = store - .reserve(Uuid::new_v4(), &wrong, Uuid::new_v4(), NOW_MS) + .reserve( + &unknown_claims, + wrong.authentication(), + Uuid::new_v4(), + NOW_MS, + ) .expect_err("unknown invitation is rejected"); for expected_failures in 1..=5 { let error = store .reserve( - handoff.public_claims().invitation_id, - &wrong, + handoff.public_claims(), + wrong.authentication(), Uuid::new_v4(), NOW_MS, ) @@ -257,14 +315,11 @@ fn reserve_consume_and_release_are_operation_scoped_and_idempotent() { let other = Uuid::new_v4(); assert_eq!( - store - .reserve(invitation_id, handoff.secret(), winner, NOW_MS) - .expect("first reservation wins"), + reserve_handoff(&store, &handoff, winner, NOW_MS).expect("first reservation wins"), ReservationStatus::Reserved ); assert_eq!( - store - .reserve(invitation_id, handoff.secret(), winner, NOW_MS + 1) + reserve_handoff(&store, &handoff, winner, NOW_MS + 1) .expect("winning operation is idempotent"), ReservationStatus::Reserved ); @@ -298,8 +353,7 @@ fn reserve_consume_and_release_are_operation_scoped_and_idempotent() { .len(), released_length ); - store - .reserve(invitation_id, handoff.secret(), winner, NOW_MS + 2) + reserve_handoff(&store, &handoff, winner, NOW_MS + 2) .expect("released operation can reserve again"); let node_id = NodeId::new("node:winner").expect("test node is valid"); @@ -311,11 +365,11 @@ fn reserve_consume_and_release_are_operation_scoped_and_idempotent() { .expect("same operation recovers durable result"); assert_eq!(first, repeated); assert_eq!( - store.reserve(invitation_id, handoff.secret(), winner, NOW_MS + 5), + reserve_handoff(&store, &handoff, winner, NOW_MS + 5), Ok(ReservationStatus::Consumed(first)) ); assert_eq!( - store.reserve(invitation_id, handoff.secret(), other, NOW_MS + 5), + reserve_handoff(&store, &handoff, other, NOW_MS + 5), Err(BootstrapError::InvitationUnavailable) ); } @@ -326,14 +380,12 @@ fn same_operation_with_five_distinct_wrong_secrets_locks() { let store = create_store(&temp); let handoff = create_invitation(&store); let operation = Uuid::new_v4(); - for attempt in 0..5 { - let mut candidate = "wrong-invitation-secret-material-0000000000".to_owned(); - candidate.replace_range(42..43, &attempt.to_string()); - let wrong = SecretString::from(candidate); + for _ in 0..5 { + let wrong = create_invitation(&store); assert_eq!( store.reserve( - handoff.public_claims().invitation_id, - &wrong, + handoff.public_claims(), + wrong.authentication(), operation, NOW_MS, ), @@ -354,12 +406,7 @@ fn nil_operation_id_is_rejected_without_state_change() { let store = create_store(&temp); let handoff = create_invitation(&store); assert_eq!( - store.reserve( - handoff.public_claims().invitation_id, - handoff.secret(), - Uuid::nil(), - NOW_MS, - ), + reserve_handoff(&store, &handoff, Uuid::nil(), NOW_MS), Err(BootstrapError::InvalidOperationId) ); assert_eq!( @@ -378,9 +425,9 @@ fn expired_invitation_is_persisted_and_cannot_reserve() { let store = create_store(&temp); let handoff = create_invitation(&store); let id = handoff.public_claims().invitation_id; - let result = store.reserve( - id, - handoff.secret(), + let result = reserve_handoff( + &store, + &handoff, Uuid::new_v4(), NOW_MS + 10 * 60 * 1_000 + 1, ); @@ -404,7 +451,6 @@ fn one_thousand_concurrent_claims_have_exactly_one_winner() { let temp = TempDir::new().expect("temporary directory is created"); let store = Arc::new(create_store(&temp)); let handoff = create_invitation(&store); - let id = handoff.public_claims().invitation_id; let handoff = Arc::new(handoff); let next = Arc::new(Mutex::new(0usize)); let barrier = Arc::new(Barrier::new(THREADS)); @@ -428,10 +474,7 @@ fn one_thousand_concurrent_claims_have_exactly_one_winner() { current }; let operation_id = Uuid::from_u128((attempt + 1) as u128); - if store - .reserve(id, handoff.secret(), operation_id, NOW_MS) - .is_ok() - { + if reserve_handoff(&store, &handoff, operation_id, NOW_MS).is_ok() { winners += 1; } } @@ -452,8 +495,7 @@ fn consume_rejects_and_persists_expiry_after_reservation() { let handoff = create_invitation(&store); let id = handoff.public_claims().invitation_id; let operation = Uuid::new_v4(); - store - .reserve(id, handoff.secret(), operation, NOW_MS) + reserve_handoff(&store, &handoff, operation, NOW_MS) .expect("reservation succeeds before expiry"); assert_eq!( store.consume( @@ -601,12 +643,7 @@ fn persistence_failure_poison_prevents_retrying_uncertain_mutations() { .set_len(64 * 1024 * 1024) .expect("journal is extended to its resource limit"); assert_eq!( - store.reserve( - handoff.public_claims().invitation_id, - handoff.secret(), - Uuid::new_v4(), - NOW_MS, - ), + reserve_handoff(&store, &handoff, Uuid::new_v4(), NOW_MS), Err(BootstrapError::ResourceLimitExceeded) ); assert_eq!( @@ -625,12 +662,7 @@ fn persistence_failure_poison_prevents_retrying_uncertain_mutations() { .set_len(original_length) .expect("test restores journal length"); assert_eq!( - store.reserve( - handoff.public_claims().invitation_id, - handoff.secret(), - Uuid::new_v4(), - NOW_MS, - ), + reserve_handoff(&store, &handoff, Uuid::new_v4(), NOW_MS), Err(BootstrapError::PersistenceUnavailable) ); } @@ -689,6 +721,25 @@ fn replay_fails_closed_on_torn_corrupt_oversized_or_unsafe_state() { )); } +#[test] +fn journal_emits_v2_and_rejects_legacy_or_future_versions() { + const HEADER_PREFIX: &[u8] = b"AGENET-INVITATION-JOURNAL\0"; + for unsupported in [1u8, 3u8] { + let temp = TempDir::new().expect("temporary directory is created"); + drop(create_store(&temp)); + let path = temp.path().join(JOURNAL_FILE); + let mut bytes = fs::read(&path).expect("journal is readable"); + assert_eq!(&bytes[..HEADER_PREFIX.len()], HEADER_PREFIX); + assert_eq!(bytes[HEADER_PREFIX.len()], 2, "new journal must emit v2"); + bytes[HEADER_PREFIX.len()] = unsupported; + fs::write(&path, bytes).expect("test journal version is changed"); + assert!(matches!( + InvitationStore::open(temp.path()), + Err(BootstrapError::UnsupportedInvitationFormat) + )); + } +} + proptest! { #![proptest_config(ProptestConfig { cases: 64, @@ -700,20 +751,24 @@ proptest! { let temp = TempDir::new().expect("temporary directory is created"); let mut store = create_store(&temp); let handoff = create_invitation(&store); + let wrong = create_invitation(&store); let id = handoff.public_claims().invitation_id; - let secret = handoff.secret(); let operation = Uuid::from_u128(7); let node = NodeId::new("node:property").expect("test node is valid"); for (step, action) in actions.into_iter().enumerate() { let now = NOW_MS + i64::try_from(step).expect("step fits i64"); match action { - 0 => { let _ = store.reserve(id, secret, operation, now); } + 0 => { let _ = reserve_handoff(&store, &handoff, operation, now); } 1 => { let _ = store.release(id, operation); } 2 => { let _ = store.consume(id, operation, node.clone(), now); } 3 => { - let wrong = SecretString::from("property-wrong-secret".to_owned()); - let _ = store.reserve(id, &wrong, Uuid::new_v4(), now); + let _ = store.reserve( + handoff.public_claims(), + wrong.authentication(), + Uuid::new_v4(), + now, + ); } _ => unreachable!("strategy generates 0..4"), } From 3c1f92ad8f0d79dd2f5197651231c59af1efb90d Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 20:05:27 +0800 Subject: [PATCH 18/67] [feat][Bootstrap][6/14] Add pinned enrollment Root cause: NA Solution: Add fingerprint-pinned enrollment that signs a local CSR and recovers exact durable issuance results. Risks: Enrollment requires bounded clock skew and private-overlay reachability. Dependency: Bootstrap step 5. Links: plan/01-v1-multi-host-node-bootstrap.md --- Cargo.lock | 3 + Cargo.toml | 4 +- ROADMAP.md | 11 + src/bootstrap/enrollment.rs | 1156 ++++++++++++++++++++++++++++++++++ src/bootstrap/invitation.rs | 16 + src/bootstrap/mod.rs | 5 + src/protocol/authority.rs | 14 +- src/protocol/enrollment.rs | 162 +++++ src/protocol/error.rs | 4 + src/protocol/mod.rs | 6 + src/transport/enrollment.rs | 373 +++++++++++ src/transport/mod.rs | 4 + tests/enrollment_protocol.rs | 162 +++++ tests/http_enrollment.rs | 669 ++++++++++++++++++++ tests/invitation_store.rs | 22 +- 15 files changed, 2590 insertions(+), 21 deletions(-) create mode 100644 src/bootstrap/enrollment.rs create mode 100644 src/protocol/enrollment.rs create mode 100644 src/transport/enrollment.rs create mode 100644 tests/enrollment_protocol.rs create mode 100644 tests/http_enrollment.rs diff --git a/Cargo.lock b/Cargo.lock index 83ae024..b1b06df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,7 @@ dependencies = [ "http-body-util", "ipnet", "libc", + "pem", "plist", "proptest", "rcgen", @@ -121,6 +122,7 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", + "url", "uuid", "x509-parser", "zeroize", @@ -3375,6 +3377,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9a960c1..be239fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ getrandom = "=0.4.3" hmac = { version = "=0.13.0", features = ["zeroize"] } ipnet = { version = "=2.12.1", features = ["serde"] } libc = "=0.2.189" +pem = "=3.0.6" rcgen = { version = "=0.14.9", default-features = false, features = ["aws_lc_rs", "pem", "x509-parser", "zeroize"] } reqwest = { version = "=0.13.4", features = ["json", "query"] } rpassword = "=7.5.4" @@ -40,6 +41,8 @@ tokio = { version = "=1.53.1", features = ["full"] } tracing = "=0.1.44" tracing-subscriber = { version = "=0.3.20", features = ["env-filter", "fmt"] } uuid = { version = "=1.24.0", features = ["serde", "v4"] } +url = { version = "=2.5.8", features = ["serde"] } +x509-parser = "=0.18.1" zeroize = "=1.9.0" [target.'cfg(target_os = "macos")'.dependencies] @@ -50,7 +53,6 @@ http-body-util = "=0.1.5" proptest = "=1.11.0" tempfile = "=3.27.0" tower = "=0.5.3" -x509-parser = "=0.18.1" [profile.release] strip = true diff --git a/ROADMAP.md b/ROADMAP.md index ba1c063..13d3597 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,16 @@ # ROADMAP +## 2026-08-14 23:55 CST + +- **Change**: Implemented provisional v0.2 enrollment with exact-byte Ed25519 request proof, locally verified handoff claims, fingerprint-pinned WebPKI TLS, Authority-issued credentials and CSR certificates, and exact durable lost-response recovery. +- **Files**: `src/protocol/enrollment.rs`, `src/bootstrap/enrollment.rs`, `src/transport/enrollment.rs`, adjacent module/error/policy boundaries, `tests/enrollment_protocol.rs`, `tests/http_enrollment.rs`, `tests/invitation_store.rs`, `Cargo.toml`, and `Cargo.lock`. +- **Decision**: The joining node receives only the Authority HTTPS endpoint and CA DER fingerprint. The server presents leaf plus CA; the client selects the exact fingerprinted CA, validates its self-signature/CA constraints/validity, constructs an only-that-root WebPKI verifier, and then validates the leaf chain, signature, validity, and exact IP SAN. No system roots, proxy, redirect, DNS endpoint, or permissive TLS switch participate. +- **Recovery ordering**: Reserve invitation, validate and issue a candidate, atomically publish and sync an owner-only exact-result record, durably consume the invitation, then return. Recovery requires the exact invitation, operation ID, and signed-request digest and returns the byte-equivalent stored bundle. The result file and invitation journal are deliberately not described as one atomic transaction; post-publish retries reconcile consumption before returning and fail closed if reconciliation is not durable. +- **Version boundary**: Public enrollment claims and request-signature domain separation consistently use `agenet.enrollment.v0.2`; v0.1, v1, and unknown future versions fail before transport or reservation. +- **Error record**: Misunderstood requirement — an intermediate implementation treated invitation expiry as a credential/certificate lifetime ceiling. Invitation expiry is only the redemption deadline; issued identity expiry is bounded by Authority credential expiry and `maximum_node_lifetime_ms` (and the issuing CA validity), so a short-lived invitation does not create an immediately expiring node. +- **Prevention**: Name authorization deadlines separately from issued-resource validity, encode each bound in one policy helper, and retain a regression assertion that a valid node credential can expire after its invitation redemption deadline. Security-sensitive HTTP tests are split by scenario; proxy environment mutation runs only in an isolated one-test child process. +- **Boundary**: This verifies enrollment over real loopback TLS and crash-boundary recovery semantics. It does not claim multi-host reachability, peer mTLS lifecycle, revocation, service installation, or atomicity across independent persistence logs. + ## 2026-08-14 19:12 CST - **Change**: Bound every one-time invitation public claim to both the Authority's persisted HMAC and a locally verifiable bearer-secret HMAC; replaced the public raw-secret accessor with opaque `InvitationAuthentication` and required reservation to present the complete claims boundary. diff --git a/src/bootstrap/enrollment.rs b/src/bootstrap/enrollment.rs new file mode 100644 index 0000000..f40c868 --- /dev/null +++ b/src/bootstrap/enrollment.rs @@ -0,0 +1,1156 @@ +use std::{ + fmt::{Debug, Formatter}, + fs::{self, DirBuilder, File, OpenOptions}, + io::Read, + os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use age::secrecy::{ExposeSecret, SecretString}; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; +use x509_parser::prelude::FromDer; +use zeroize::{Zeroize, Zeroizing}; + +use crate::protocol::{ + BootstrapProfile, CredentialChain, EnrollmentBundle, EnrollmentRequestClaims, + NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, roles_for_bootstrap_profile, + sign_enrollment_claims, verify_credential_chain, verify_enrollment_claims, +}; +use crate::runtime::key_store::atomic_write_owner_only; + +use super::{ + AuthorityPki, BootstrapError, InvitationAuthentication, InvitationHandoff, + InvitationPublicClaims, InvitationStore, ReservationStatus, +}; + +const ENROLLMENT_WIRE_VERSION: &str = "agenet.enrollment-wire.v0.2"; +const MAX_RESULT_BYTES: usize = 256 * 1024; + +#[derive(Clone, PartialEq, Eq)] +pub struct EnrollmentAttempt { + pub operation_id: Uuid, + pub node_id: NodeId, + pub requested_profile: BootstrapProfile, + pub signing_public_key_base64: String, + pub tls_csr_pem: String, + exact_claims_base64: String, + node_signature_base64: String, +} + +impl Debug for EnrollmentAttempt { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EnrollmentAttempt") + .field("operation_id", &self.operation_id) + .field("node_id", &self.node_id) + .field("requested_profile", &self.requested_profile) + .field("signing_public_key_base64", &self.signing_public_key_base64) + .field("tls_csr_pem", &"[CSR]") + .field("node_signature_base64", &"[SIGNATURE]") + .finish() + } +} + +impl EnrollmentAttempt { + pub fn sign( + handoff: &InvitationHandoff, + operation_id: Uuid, + node_id: NodeId, + requested_profile: BootstrapProfile, + signing_key: &SigningKey, + tls_csr_pem: String, + ) -> Result { + if requested_profile != handoff.public_claims().allowed_profile { + return Err(EnrollmentError::PolicyRejected); + } + let claims = EnrollmentRequestClaims { + protocol_version: handoff.public_claims().protocol_version.clone(), + operation_id, + invitation_claims_sha256: super::invitation::public_claims_sha256_for_enrollment( + handoff.public_claims(), + )?, + node_id: node_id.clone(), + requested_profile, + signing_public_key_base64: STANDARD.encode(signing_key.verifying_key().to_bytes()), + tls_csr_pem: tls_csr_pem.clone(), + }; + let signing_public_key_base64 = claims.signing_public_key_base64.clone(); + let (exact_claims_base64, node_signature_base64) = + sign_enrollment_claims(&claims, signing_key) + .map_err(|_| EnrollmentError::InvalidRequest)?; + Ok(Self { + operation_id, + node_id, + requested_profile, + signing_public_key_base64, + tls_csr_pem, + exact_claims_base64, + node_signature_base64, + }) + } + + pub fn verify(&self, handoff: &InvitationHandoff) -> Result<(), EnrollmentError> { + self.verify_public_claims(handoff.public_claims()) + } + + pub fn verify_public_claims( + &self, + public_claims: &InvitationPublicClaims, + ) -> Result<(), EnrollmentError> { + let claims = + verify_enrollment_claims(&self.exact_claims_base64, &self.node_signature_base64) + .map_err(|_| EnrollmentError::InvalidRequest)?; + let expected_digest = + super::invitation::public_claims_sha256_for_enrollment(public_claims)?; + if claims.operation_id != self.operation_id + || claims.invitation_claims_sha256 != expected_digest + || claims.node_id != self.node_id + || claims.requested_profile != self.requested_profile + || claims.signing_public_key_base64 != self.signing_public_key_base64 + || claims.tls_csr_pem != self.tls_csr_pem + { + return Err(EnrollmentError::InvalidRequest); + } + Ok(()) + } + + pub(crate) fn exact_claims_base64(&self) -> &str { + &self.exact_claims_base64 + } + + pub(crate) fn node_signature_base64(&self) -> &str { + &self.node_signature_base64 + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EnrollmentWireRequest { + pub format_version: String, + pub public_claims: InvitationPublicClaims, + pub exact_claims_base64: String, + pub node_signature_base64: String, + pub invitation_secret: SecretWireText, + pub claims_integrity_hmac_sha256_base64: String, +} + +impl Debug for EnrollmentWireRequest { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EnrollmentWireRequest") + .field("format_version", &self.format_version) + .field("invitation_id", &self.public_claims.invitation_id) + .field("invitation_secret", &"[REDACTED]") + .field("claims_integrity_hmac_sha256_base64", &"[REDACTED]") + .finish_non_exhaustive() + } +} + +impl EnrollmentWireRequest { + pub(crate) fn from_local( + handoff: &InvitationHandoff, + attempt: &EnrollmentAttempt, + ) -> Result { + attempt.verify(handoff)?; + Ok(Self { + format_version: ENROLLMENT_WIRE_VERSION.to_owned(), + public_claims: handoff.public_claims().clone(), + exact_claims_base64: attempt.exact_claims_base64().to_owned(), + node_signature_base64: attempt.node_signature_base64().to_owned(), + invitation_secret: SecretWireText( + handoff + .authentication() + .secret_for_request() + .expose_secret() + .to_owned(), + ), + claims_integrity_hmac_sha256_base64: URL_SAFE_NO_PAD + .encode(handoff.authentication().claims_integrity_hmac_for_request()), + }) + } + + fn authentication(&self) -> Result { + let decoded = URL_SAFE_NO_PAD + .decode(&self.claims_integrity_hmac_sha256_base64) + .map_err(|_| EnrollmentError::InvalidInvitation)?; + let tag: [u8; 32] = decoded + .try_into() + .map_err(|_| EnrollmentError::InvalidInvitation)?; + Ok(InvitationAuthentication::from_enrollment_wire( + SecretString::from(self.invitation_secret.0.clone()), + tag, + )) + } + + fn exact_request_digest(&self) -> Result<[u8; 32], EnrollmentError> { + let encoded = + Zeroizing::new(serde_json::to_vec(self).map_err(|_| EnrollmentError::InvalidRequest)?); + Ok(Sha256::digest(encoded).into()) + } +} + +pub(crate) struct SecretWireText(String); + +impl Drop for SecretWireText { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +impl Serialize for SecretWireText { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for SecretWireText { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer).map(Self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EnrollmentWireResponse { + pub format_version: String, + pub bundle: EnrollmentBundle, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DurableEnrollmentResult { + format_version: String, + invitation_id: Uuid, + operation_id: Uuid, + exact_request_sha256: [u8; 32], + issued_at_ms: i64, + bundle: EnrollmentBundle, +} + +struct VerifiedEnrollmentRequest { + claims: EnrollmentRequestClaims, + request_digest: [u8; 32], +} + +pub struct EnrollmentAuthority { + result_directory: PathBuf, + invitations: Arc, + root_public_key: VerifyingKey, + authority_credential: SignedAuthorityCredential, + authority_signing_key: SigningKey, + pki: AuthorityPki, + authority_endpoint: Url, + issuance_lock: Mutex<()>, +} + +impl Debug for EnrollmentAuthority { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EnrollmentAuthority") + .field("result_directory", &self.result_directory) + .field("authority_endpoint", &self.authority_endpoint) + .field("authority_signing_key", &"[REDACTED]") + .finish_non_exhaustive() + } +} + +impl EnrollmentAuthority { + #[allow(clippy::too_many_arguments)] + pub fn open( + result_directory: &Path, + invitations: Arc, + root_public_key: VerifyingKey, + authority_credential: SignedAuthorityCredential, + authority_signing_key: SigningKey, + pki: AuthorityPki, + authority_endpoint: Url, + ) -> Result { + prepare_result_directory(result_directory)?; + Ok(Self { + result_directory: result_directory.to_owned(), + invitations, + root_public_key, + authority_credential, + authority_signing_key, + pki, + authority_endpoint, + issuance_lock: Mutex::new(()), + }) + } + + pub(crate) fn process( + &self, + request: &EnrollmentWireRequest, + now_ms: i64, + ) -> Result { + self.process_with_fault(request, now_ms, EnrollmentFault::None) + } + + fn process_with_fault( + &self, + request: &EnrollmentWireRequest, + now_ms: i64, + fault: EnrollmentFault, + ) -> Result { + let _issuance_guard = self + .issuance_lock + .lock() + .map_err(|_| EnrollmentError::PersistenceFailed)?; + let verified = self.verify_request(request, now_ms)?; + if let Some(bundle) = self.recover_result(request, &verified)? { + return Ok(bundle); + } + self.reserve_request(request, &verified.claims, now_ms)?; + let bundle = self.issue_or_release(request, &verified.claims, now_ms)?; + self.persist_and_consume(request, verified, bundle, now_ms, fault) + } + + fn verify_request( + &self, + request: &EnrollmentWireRequest, + now_ms: i64, + ) -> Result { + if request.format_version != ENROLLMENT_WIRE_VERSION || now_ms <= 0 { + return Err(EnrollmentError::InvalidRequest); + } + let claims = + verify_enrollment_claims(&request.exact_claims_base64, &request.node_signature_base64) + .map_err(|_| EnrollmentError::InvalidRequest)?; + let claims_digest = + super::invitation::public_claims_sha256_for_enrollment(&request.public_claims)?; + if claims.invitation_claims_sha256 != claims_digest + || claims.requested_profile != request.public_claims.allowed_profile + { + return Err(EnrollmentError::PolicyRejected); + } + Ok(VerifiedEnrollmentRequest { + request_digest: request.exact_request_digest()?, + claims, + }) + } + + fn recover_result( + &self, + request: &EnrollmentWireRequest, + verified: &VerifiedEnrollmentRequest, + ) -> Result, EnrollmentError> { + if let Some(existing) = self.load_result( + request.public_claims.invitation_id, + verified.claims.operation_id, + )? { + if existing.exact_request_sha256 != verified.request_digest { + return Err(EnrollmentError::InvalidRequest); + } + self.invitations + .consume( + request.public_claims.invitation_id, + verified.claims.operation_id, + verified.claims.node_id.clone(), + existing.issued_at_ms, + ) + .map_err(|_| EnrollmentError::PersistenceFailed)?; + return Ok(Some(existing.bundle)); + } + Ok(None) + } + + fn reserve_request( + &self, + request: &EnrollmentWireRequest, + claims: &EnrollmentRequestClaims, + now_ms: i64, + ) -> Result<(), EnrollmentError> { + if now_ms > request.public_claims.expires_at_ms { + return Err(EnrollmentError::PolicyRejected); + } + let authentication = request.authentication()?; + match self.invitations.reserve( + &request.public_claims, + &authentication, + claims.operation_id, + now_ms, + )? { + ReservationStatus::Reserved => {} + ReservationStatus::Consumed(_) => return Err(EnrollmentError::InvalidInvitation), + } + Ok(()) + } + + fn issue_or_release( + &self, + request: &EnrollmentWireRequest, + claims: &EnrollmentRequestClaims, + now_ms: i64, + ) -> Result { + let bundle = match self.issue_bundle(&request.public_claims, claims, now_ms) { + Ok(bundle) => bundle, + Err(error) => { + if self + .invitations + .release(request.public_claims.invitation_id, claims.operation_id) + .is_err() + { + return Err(EnrollmentError::PersistenceFailed); + } + return Err(error); + } + }; + Ok(bundle) + } + + fn persist_and_consume( + &self, + request: &EnrollmentWireRequest, + verified: VerifiedEnrollmentRequest, + bundle: EnrollmentBundle, + now_ms: i64, + fault: EnrollmentFault, + ) -> Result { + if fault == EnrollmentFault::BeforeResultPersistence { + self.invitations + .release( + request.public_claims.invitation_id, + verified.claims.operation_id, + ) + .map_err(|_| EnrollmentError::PersistenceFailed)?; + return Err(EnrollmentError::TransportFailed); + } + let result = DurableEnrollmentResult { + format_version: "agenet.enrollment-result.v0.2".to_owned(), + invitation_id: request.public_claims.invitation_id, + operation_id: verified.claims.operation_id, + exact_request_sha256: verified.request_digest, + issued_at_ms: now_ms, + bundle: bundle.clone(), + }; + if let Err(error) = self.persist_result(&result) { + let result_absent = self + .load_result( + request.public_claims.invitation_id, + verified.claims.operation_id, + )? + .is_none(); + let release_failed = result_absent + && self + .invitations + .release( + request.public_claims.invitation_id, + verified.claims.operation_id, + ) + .is_err(); + if release_failed { + return Err(EnrollmentError::PersistenceFailed); + } + return Err(error); + } + if fault == EnrollmentFault::AfterResultPersistence { + return Err(EnrollmentError::TransportFailed); + } + self.invitations + .consume( + request.public_claims.invitation_id, + verified.claims.operation_id, + verified.claims.node_id, + now_ms, + ) + .map_err(|_| EnrollmentError::PersistenceFailed)?; + if fault == EnrollmentFault::AfterConsumption { + return Err(EnrollmentError::TransportFailed); + } + Ok(bundle) + } + + fn issue_bundle( + &self, + invitation: &InvitationPublicClaims, + request: &EnrollmentRequestClaims, + now_ms: i64, + ) -> Result { + let (issued_at_ms, expires_at_ms) = self.credential_validity(now_ms)?; + let node_claims = NodeCredentialClaims { + domain_id: invitation.domain_id.clone(), + authority_id: self.authority_credential.claims.authority_id.clone(), + node_id: request.node_id.clone(), + signing_public_key_base64: request.signing_public_key_base64.clone(), + bootstrap_profile: request.requested_profile, + allowed_roles: roles_for_bootstrap_profile(request.requested_profile), + issued_at_ms, + expires_at_ms, + }; + let node = self + .authority_credential + .issue_node_credential( + &self.root_public_key, + &self.authority_signing_key, + node_claims, + now_ms, + ) + .map_err(|_| EnrollmentError::PolicyRejected)?; + let certificate = self + .pki + .issue_client( + &request.tls_csr_pem, + &request.node_id, + issued_at_ms, + expires_at_ms, + ) + .map_err(|_| EnrollmentError::CertificateRejected)?; + Ok(EnrollmentBundle { + domain_id: invitation.domain_id.clone(), + root_public_key_base64: STANDARD.encode(self.root_public_key.to_bytes()), + credential_chain: CredentialChain { + authority: self.authority_credential.clone(), + node, + }, + tls_client_certificate_pem: certificate.cert_pem, + tls_ca_certificate_pem: self.pki.ca_cert_pem.to_string(), + authority_endpoint: self.authority_endpoint.clone(), + directory_seeds: invitation.directory_seeds.clone(), + }) + } + + fn credential_validity(&self, now_ms: i64) -> Result<(i64, i64), EnrollmentError> { + let issued_at_ms = now_ms - now_ms.rem_euclid(1_000); + let maximum_lifetime = + i64::try_from(self.authority_credential.claims.maximum_node_lifetime_ms) + .map_err(|_| EnrollmentError::PolicyRejected)?; + let maximum_expiry = issued_at_ms + .checked_add(maximum_lifetime) + .ok_or(EnrollmentError::PolicyRejected)?; + let authority_expiry = self.authority_credential.claims.expires_at_ms; + let expires_at_ms = authority_expiry.min(maximum_expiry); + let expires_at_ms = expires_at_ms - expires_at_ms.rem_euclid(1_000); + if expires_at_ms <= issued_at_ms { + return Err(EnrollmentError::PolicyRejected); + } + Ok((issued_at_ms, expires_at_ms)) + } + + fn result_path(&self, invitation_id: Uuid, operation_id: Uuid) -> PathBuf { + self.result_directory + .join(format!("{invitation_id}-{operation_id}.json")) + } + + fn persist_result(&self, result: &DurableEnrollmentResult) -> Result<(), EnrollmentError> { + let encoded = serde_json::to_vec(result).map_err(|_| EnrollmentError::PersistenceFailed)?; + if encoded.len() > MAX_RESULT_BYTES { + return Err(EnrollmentError::ResponseTooLarge); + } + atomic_write_owner_only( + &self.result_path(result.invitation_id, result.operation_id), + &encoded, + false, + ) + .map_err(|_| EnrollmentError::PersistenceFailed) + } + + fn load_result( + &self, + invitation_id: Uuid, + operation_id: Uuid, + ) -> Result, EnrollmentError> { + let path = self.result_path(invitation_id, operation_id); + let mut file = match OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(EnrollmentError::PersistenceFailed), + }; + validate_owner_only_file(&file)?; + let mut bounded = (&mut file).take((MAX_RESULT_BYTES + 1) as u64); + let mut encoded = Vec::new(); + bounded + .read_to_end(&mut encoded) + .map_err(|_| EnrollmentError::PersistenceFailed)?; + if encoded.len() > MAX_RESULT_BYTES { + return Err(EnrollmentError::PersistenceFailed); + } + let result: DurableEnrollmentResult = + serde_json::from_slice(&encoded).map_err(|_| EnrollmentError::PersistenceFailed)?; + if result.format_version != "agenet.enrollment-result.v0.2" + || result.invitation_id != invitation_id + || result.operation_id != operation_id + || result.issued_at_ms <= 0 + { + return Err(EnrollmentError::PersistenceFailed); + } + Ok(Some(result)) + } +} + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Copy, PartialEq, Eq)] +enum EnrollmentFault { + None, + BeforeResultPersistence, + AfterResultPersistence, + AfterConsumption, +} + +pub struct EnrollmentHandoffValidation; + +impl EnrollmentHandoffValidation { + pub fn validate( + handoff: &InvitationHandoff, + attempt: &EnrollmentAttempt, + bundle: &EnrollmentBundle, + now_ms: i64, + ) -> Result<(), EnrollmentError> { + attempt.verify(handoff)?; + let invitation = handoff.public_claims(); + if bundle.domain_id != invitation.domain_id + || bundle.authority_endpoint != invitation.authority_endpoint + || bundle.directory_seeds != invitation.directory_seeds + || bundle.credential_chain.authority.claims.tls_ca_sha256 != invitation.tls_ca_sha256 + { + return Err(EnrollmentError::CertificateRejected); + } + let root_bytes = STANDARD + .decode(&bundle.root_public_key_base64) + .map_err(|_| EnrollmentError::CertificateRejected)?; + let root_array: [u8; 32] = root_bytes + .try_into() + .map_err(|_| EnrollmentError::CertificateRejected)?; + let root = VerifyingKey::from_bytes(&root_array) + .map_err(|_| EnrollmentError::CertificateRejected)?; + if fingerprint_bytes(root.as_bytes()) != invitation.root_sha256 { + return Err(EnrollmentError::CertificateRejected); + } + let verified = verify_credential_chain( + &root, + &bundle.credential_chain, + &invitation.domain_id, + NodeRole::Requester, + now_ms, + ) + .map_err(|_| EnrollmentError::CertificateRejected)?; + if verified.node_id != attempt.node_id + || verified.signing_public_key.to_bytes() + != STANDARD + .decode(&attempt.signing_public_key_base64) + .map_err(|_| EnrollmentError::CertificateRejected)? + .as_slice() + || verified.bootstrap_profile != attempt.requested_profile + || verified.allowed_roles != roles_for_bootstrap_profile(attempt.requested_profile) + { + return Err(EnrollmentError::CertificateRejected); + } + validate_tls_bundle(invitation, attempt, bundle, &verified, now_ms) + } +} + +fn validate_tls_bundle( + invitation: &InvitationPublicClaims, + attempt: &EnrollmentAttempt, + bundle: &EnrollmentBundle, + verified: &crate::protocol::VerifiedNodeClaims, + now_ms: i64, +) -> Result<(), EnrollmentError> { + let ca_der = parse_one_pem_certificate(&bundle.tls_ca_certificate_pem)?; + if fingerprint_bytes(&ca_der) != invitation.tls_ca_sha256 { + return Err(EnrollmentError::CertificateRejected); + } + let client_der = parse_one_pem_certificate(&bundle.tls_client_certificate_pem)?; + let (_, ca) = x509_parser::prelude::X509Certificate::from_der(&ca_der) + .map_err(|_| EnrollmentError::CertificateRejected)?; + let (_, client) = x509_parser::prelude::X509Certificate::from_der(&client_der) + .map_err(|_| EnrollmentError::CertificateRejected)?; + validate_returned_ca(&ca, now_ms)?; + validate_returned_leaf(&client)?; + client + .verify_signature(Some(ca.public_key())) + .map_err(|_| EnrollmentError::CertificateRejected)?; + validate_leaf_validity(&client, verified, now_ms)?; + validate_leaf_csr_and_node(&client, attempt) +} + +fn validate_leaf_validity( + client: &x509_parser::certificate::X509Certificate<'_>, + verified: &crate::protocol::VerifiedNodeClaims, + now_ms: i64, +) -> Result<(), EnrollmentError> { + let now_seconds = now_ms.div_euclid(1_000); + if now_seconds < client.validity().not_before.timestamp() + || now_seconds > client.validity().not_after.timestamp() + || client + .validity() + .not_before + .timestamp() + .saturating_mul(1_000) + != verified.issued_at_ms + || client + .validity() + .not_after + .timestamp() + .saturating_mul(1_000) + != verified.expires_at_ms + { + return Err(EnrollmentError::CertificateRejected); + } + Ok(()) +} + +fn validate_leaf_csr_and_node( + client: &x509_parser::certificate::X509Certificate<'_>, + attempt: &EnrollmentAttempt, +) -> Result<(), EnrollmentError> { + let csr_pem = + ::pem::parse(&attempt.tls_csr_pem).map_err(|_| EnrollmentError::CertificateRejected)?; + let (_, csr) = + x509_parser::certification_request::X509CertificationRequest::from_der(csr_pem.contents()) + .map_err(|_| EnrollmentError::CertificateRejected)?; + csr.verify_signature() + .map_err(|_| EnrollmentError::CertificateRejected)?; + if client.public_key().subject_public_key.data + != csr + .certification_request_info + .subject_pki + .subject_public_key + .data + { + return Err(EnrollmentError::CertificateRejected); + } + let node_extensions = client + .extensions() + .iter() + .filter(|extension| extension.oid.to_id_string() == super::AGENET_NODE_ID_OID) + .collect::>(); + let [node_extension] = node_extensions.as_slice() else { + return Err(EnrollmentError::CertificateRejected); + }; + if decode_der_utf8(node_extension.value)? != attempt.node_id.as_str().as_bytes() { + return Err(EnrollmentError::CertificateRejected); + } + Ok(()) +} + +fn parse_one_pem_certificate(pem_text: &str) -> Result, EnrollmentError> { + let parsed = ::pem::parse_many(pem_text).map_err(|_| EnrollmentError::CertificateRejected)?; + let [pem] = parsed.as_slice() else { + return Err(EnrollmentError::CertificateRejected); + }; + if pem.tag() != "CERTIFICATE" { + return Err(EnrollmentError::CertificateRejected); + } + Ok(pem.contents().to_vec()) +} + +fn validate_returned_ca( + ca: &x509_parser::certificate::X509Certificate<'_>, + now_ms: i64, +) -> Result<(), EnrollmentError> { + let now = now_ms.div_euclid(1_000); + let basic = ca + .basic_constraints() + .map_err(|_| EnrollmentError::CertificateRejected)? + .ok_or(EnrollmentError::CertificateRejected)?; + let usage = ca + .key_usage() + .map_err(|_| EnrollmentError::CertificateRejected)? + .ok_or(EnrollmentError::CertificateRejected)?; + if !basic.value.ca + || !usage.value.key_cert_sign() + || !usage.value.crl_sign() + || ca.subject() != ca.issuer() + || now < ca.validity().not_before.timestamp() + || now > ca.validity().not_after.timestamp() + || ca.verify_signature(Some(ca.public_key())).is_err() + { + return Err(EnrollmentError::CertificateRejected); + } + Ok(()) +} + +fn validate_returned_leaf( + leaf: &x509_parser::certificate::X509Certificate<'_>, +) -> Result<(), EnrollmentError> { + let basic = leaf + .basic_constraints() + .map_err(|_| EnrollmentError::CertificateRejected)? + .ok_or(EnrollmentError::CertificateRejected)?; + let usage = leaf + .key_usage() + .map_err(|_| EnrollmentError::CertificateRejected)? + .ok_or(EnrollmentError::CertificateRejected)?; + let extended = leaf + .extended_key_usage() + .map_err(|_| EnrollmentError::CertificateRejected)? + .ok_or(EnrollmentError::CertificateRejected)?; + let has_san = leaf + .subject_alternative_name() + .map_err(|_| EnrollmentError::CertificateRejected)? + .is_some(); + if basic.value.ca + || !usage.value.digital_signature() + || !extended.value.client_auth + || extended.value.any + || extended.value.server_auth + || has_san + { + return Err(EnrollmentError::CertificateRejected); + } + Ok(()) +} + +fn decode_der_utf8(encoded: &[u8]) -> Result<&[u8], EnrollmentError> { + if encoded.first() != Some(&0x0c) || encoded.len() < 2 { + return Err(EnrollmentError::CertificateRejected); + } + let (header, length) = match encoded[1] { + value @ 0..=127 => (2, usize::from(value)), + 0x81 if encoded.len() >= 3 => (3, usize::from(encoded[2])), + 0x82 if encoded.len() >= 4 => { + (4, usize::from(u16::from_be_bytes([encoded[2], encoded[3]]))) + } + _ => return Err(EnrollmentError::CertificateRejected), + }; + if encoded.len() != header + length { + return Err(EnrollmentError::CertificateRejected); + } + std::str::from_utf8(&encoded[header..]).map_err(|_| EnrollmentError::CertificateRejected)?; + Ok(&encoded[header..]) +} + +fn fingerprint_bytes(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = Sha256::digest(bytes); + let mut output = String::with_capacity(64); + for byte in digest { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output +} + +fn prepare_result_directory(path: &Path) -> Result<(), EnrollmentError> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.permissions().mode() & 0o777 != 0o700 + { + return Err(EnrollmentError::PersistenceFailed); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + DirBuilder::new() + .mode(0o700) + .create(path) + .map_err(|_| EnrollmentError::PersistenceFailed)?; + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|_| EnrollmentError::PersistenceFailed)?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + File::open(parent) + .and_then(|file| file.sync_all()) + .map_err(|_| EnrollmentError::PersistenceFailed)?; + } + Err(_) => return Err(EnrollmentError::PersistenceFailed), + } + let metadata = fs::symlink_metadata(path).map_err(|_| EnrollmentError::PersistenceFailed)?; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.permissions().mode() & 0o777 != 0o700 + { + return Err(EnrollmentError::PersistenceFailed); + } + Ok(()) +} + +fn validate_owner_only_file(file: &File) -> Result<(), EnrollmentError> { + let metadata = file + .metadata() + .map_err(|_| EnrollmentError::PersistenceFailed)?; + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.permissions().mode() & 0o777 != 0o600 + { + return Err(EnrollmentError::PersistenceFailed); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnrollmentError { + InvalidRequest, + InvalidInvitation, + PolicyRejected, + CertificateRejected, + PersistenceFailed, + TransportFailed, + ResponseTooLarge, +} + +impl std::fmt::Display for EnrollmentError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for EnrollmentError {} + +impl From for EnrollmentError { + fn from(_: BootstrapError) -> Self { + Self::InvalidInvitation + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use tempfile::TempDir; + + use crate::protocol::{ + AuthorityClaims, AuthorityScope, CapabilityKind, DomainId, SignedAuthorityCredential, + }; + + use super::*; + + const NOW_MS: i64 = 2_000_000_000_000; + + struct Fixture { + state: TempDir, + root_public_key: VerifyingKey, + authority: EnrollmentAuthority, + invitations: Arc, + handoff: InvitationHandoff, + attempt: EnrollmentAttempt, + } + + fn fixture(operation_id: Uuid) -> Fixture { + let state = TempDir::new().expect("state"); + let root = SigningKey::from_bytes(&[41_u8; 32]); + let authority_key = SigningKey::from_bytes(&[42_u8; 32]); + let pki = AuthorityPki::generate(NOW_MS - 60_000, NOW_MS + 3_600_000).expect("CA"); + let authority_credential = SignedAuthorityCredential::issue( + &root, + AuthorityClaims { + domain_id: DomainId::new("domain-crash-boundary").expect("domain"), + authority_id: NodeId::new("authority-crash-boundary").expect("node"), + signing_public_key_base64: STANDARD + .encode(authority_key.verifying_key().to_bytes()), + tls_ca_sha256: pki.fingerprint_sha256.clone(), + scopes: BTreeSet::from([AuthorityScope::IssueNodeCredential]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Provider]), + maximum_node_lifetime_ms: 1_800_000, + issued_at_ms: NOW_MS - 60_000, + expires_at_ms: NOW_MS + 3_600_000, + }, + ) + .expect("authority credential"); + let endpoint = Url::parse("https://127.0.0.1:8443/").expect("endpoint"); + let invitations = + Arc::new(InvitationStore::open(&state.path().join("invitations")).expect("store")); + let handoff = invitations + .create( + super::super::InvitationSpec { + protocol_version: "agenet.enrollment.v0.2".to_owned(), + domain_id: authority_credential.claims.domain_id.clone(), + authority_endpoint: endpoint.clone(), + directory_seeds: vec![ + Url::parse("https://127.0.0.1:9443/").expect("directory"), + ], + root_sha256: fingerprint_bytes(root.verifying_key().as_bytes()), + tls_ca_sha256: pki.fingerprint_sha256.clone(), + allowed_profile: BootstrapProfile::Provider, + capability_ceiling: BTreeSet::from([ + CapabilityKind::new("source.metrics.v1").expect("capability") + ]), + }, + NOW_MS, + ) + .expect("handoff"); + let attempt = EnrollmentAttempt::sign( + &handoff, + operation_id, + NodeId::new(format!("node-{operation_id}")).expect("node"), + BootstrapProfile::Provider, + &SigningKey::from_bytes(&[43_u8; 32]), + super::super::NodeTlsCsr::generate().expect("CSR").csr_pem, + ) + .expect("attempt"); + let authority = EnrollmentAuthority::open( + &state.path().join("results"), + Arc::clone(&invitations), + root.verifying_key(), + authority_credential, + authority_key, + pki, + endpoint, + ) + .expect("authority"); + Fixture { + state, + root_public_key: root.verifying_key(), + authority, + invitations, + handoff, + attempt, + } + } + + #[test] + fn crash_boundaries_release_before_publish_and_reconcile_after_publish() { + let before = fixture(Uuid::from_u128(101)); + let request = + EnrollmentWireRequest::from_local(&before.handoff, &before.attempt).expect("request"); + assert_eq!( + before.authority.process_with_fault( + &request, + NOW_MS, + EnrollmentFault::BeforeResultPersistence, + ), + Err(EnrollmentError::TransportFailed) + ); + let record = before + .invitations + .record(before.handoff.public_claims().invitation_id) + .expect("record") + .expect("present"); + assert_eq!(record.state, super::super::InvitationState::Available); + before + .authority + .process(&request, NOW_MS) + .expect("retry after pre-publish crash"); + + let after_result = fixture(Uuid::from_u128(102)); + let request = + EnrollmentWireRequest::from_local(&after_result.handoff, &after_result.attempt) + .expect("request"); + assert_eq!( + after_result.authority.process_with_fault( + &request, + NOW_MS, + EnrollmentFault::AfterResultPersistence, + ), + Err(EnrollmentError::TransportFailed) + ); + let recovered = after_result + .authority + .process(&request, NOW_MS + 11 * 60_000) + .expect("reconcile durable result"); + assert_eq!( + recovered, + after_result + .authority + .process(&request, NOW_MS + 12 * 60_000) + .expect("idempotent recovery") + ); + + let after_consumption = fixture(Uuid::from_u128(103)); + let request = EnrollmentWireRequest::from_local( + &after_consumption.handoff, + &after_consumption.attempt, + ) + .expect("request"); + assert_eq!( + after_consumption.authority.process_with_fault( + &request, + NOW_MS, + EnrollmentFault::AfterConsumption, + ), + Err(EnrollmentError::TransportFailed) + ); + after_consumption + .authority + .process(&request, NOW_MS + 1_000) + .expect("recover consumed result"); + } + + #[test] + fn returned_bundle_is_fully_bound_and_durable_record_redacts_bearers() { + let fixture = fixture(Uuid::from_u128(104)); + let request = + EnrollmentWireRequest::from_local(&fixture.handoff, &fixture.attempt).expect("request"); + let bearer = fixture + .handoff + .authentication() + .secret_for_request() + .expose_secret() + .to_owned(); + assert!(!format!("{request:?}").contains(&bearer)); + let bundle = fixture.authority.process(&request, NOW_MS).expect("bundle"); + EnrollmentHandoffValidation::validate(&fixture.handoff, &fixture.attempt, &bundle, NOW_MS) + .expect("valid bundle"); + + let mut wrong_domain = bundle.clone(); + wrong_domain.domain_id = DomainId::new("wrong-domain").expect("domain"); + assert!( + EnrollmentHandoffValidation::validate( + &fixture.handoff, + &fixture.attempt, + &wrong_domain, + NOW_MS, + ) + .is_err() + ); + + let other_csr = super::super::NodeTlsCsr::generate().expect("other CSR"); + let mismatched_certificate = fixture + .authority + .pki + .issue_client( + &other_csr.csr_pem, + &fixture.attempt.node_id, + NOW_MS, + NOW_MS + 1_800_000, + ) + .expect("mismatched certificate"); + let mut wrong_spki = bundle.clone(); + wrong_spki.tls_client_certificate_pem = mismatched_certificate.cert_pem; + assert!( + EnrollmentHandoffValidation::validate( + &fixture.handoff, + &fixture.attempt, + &wrong_spki, + NOW_MS, + ) + .is_err() + ); + + let result_bytes = fs::read_dir(fixture.state.path().join("results")) + .expect("results") + .map(|entry| fs::read(entry.expect("entry").path()).expect("record")) + .next() + .expect("result record"); + assert!( + !result_bytes + .windows(bearer.len()) + .any(|window| window == bearer.as_bytes()) + ); + assert!(!String::from_utf8_lossy(&result_bytes).contains("PRIVATE KEY")); + let verified = verify_credential_chain( + &fixture.root_public_key, + &bundle.credential_chain, + &bundle.domain_id, + NodeRole::Requester, + NOW_MS, + ) + .expect("chain"); + assert_eq!(verified.node_id, fixture.attempt.node_id); + } +} diff --git a/src/bootstrap/invitation.rs b/src/bootstrap/invitation.rs index f714899..a9fc7fb 100644 --- a/src/bootstrap/invitation.rs +++ b/src/bootstrap/invitation.rs @@ -144,6 +144,16 @@ impl InvitationAuthentication { pub(crate) fn claims_integrity_hmac_for_request(&self) -> &[u8; 32] { &self.claims_integrity_hmac_sha256 } + + pub(crate) fn from_enrollment_wire( + secret: SecretString, + claims_integrity_hmac_sha256: [u8; 32], + ) -> Self { + Self { + secret, + claims_integrity_hmac_sha256, + } + } } impl Debug for InvitationAuthentication { @@ -960,6 +970,12 @@ fn public_claims_sha256(claims: &InvitationPublicClaims) -> Result<[u8; 32], Boo Ok(Sha256::digest(encode_public_claims(claims)?).into()) } +pub(crate) fn public_claims_sha256_for_enrollment( + claims: &InvitationPublicClaims, +) -> Result<[u8; 32], BootstrapError> { + public_claims_sha256(claims) +} + fn encode_public_claims(claims: &InvitationPublicClaims) -> Result, BootstrapError> { validate_public_claims(claims)?; let mut encoded = Vec::with_capacity(512); diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 04c587e..0968452 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -1,5 +1,6 @@ //! Bootstrap orchestration boundary for the v0.2 multi-host preview. +mod enrollment; mod invitation; mod journal; mod keystore; @@ -8,6 +9,10 @@ mod pki; use std::fmt::{Display, Formatter}; +pub use enrollment::{ + EnrollmentAttempt, EnrollmentAuthority, EnrollmentError, EnrollmentHandoffValidation, +}; +pub(crate) use enrollment::{EnrollmentWireRequest, EnrollmentWireResponse}; pub use invitation::{ ConsumptionResult, InvitationAuthentication, InvitationHandoff, InvitationPublicClaims, InvitationRecord, InvitationSpec, InvitationState, InvitationStore, ReservationStatus, diff --git a/src/protocol/authority.rs b/src/protocol/authority.rs index 72ee0fb..e7ced7d 100644 --- a/src/protocol/authority.rs +++ b/src/protocol/authority.rs @@ -217,18 +217,22 @@ fn validate_profile_roles( profile: BootstrapProfile, roles: &BTreeSet, ) -> Result<(), ProtocolError> { - let permitted = match profile { + let permitted = roles_for_bootstrap_profile(profile); + if roles.is_empty() || !roles.is_subset(&permitted) { + return Err(ProtocolError::CredentialRoleMismatch); + } + Ok(()) +} + +pub(crate) fn roles_for_bootstrap_profile(profile: BootstrapProfile) -> BTreeSet { + match profile { BootstrapProfile::Base | BootstrapProfile::AgentCandidate => { BTreeSet::from([NodeRole::Requester]) } BootstrapProfile::Provider => { BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]) } - }; - if roles.is_empty() || !roles.is_subset(&permitted) { - return Err(ProtocolError::CredentialRoleMismatch); } - Ok(()) } fn validate_node_lifetime( diff --git a/src/protocol/enrollment.rs b/src/protocol/enrollment.rs new file mode 100644 index 0000000..c484c27 --- /dev/null +++ b/src/protocol/enrollment.rs @@ -0,0 +1,162 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{BootstrapProfile, CredentialChain, DomainId, NodeId, ProtocolError}; + +const ENROLLMENT_REQUEST_DOMAIN: &[u8] = b"AGENET\0enrollment-request-v0.2\0"; +pub const MAX_ENROLLMENT_CSR_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnrollmentBundle { + pub domain_id: DomainId, + pub root_public_key_base64: String, + pub credential_chain: CredentialChain, + pub tls_client_certificate_pem: String, + pub tls_ca_certificate_pem: String, + pub authority_endpoint: Url, + pub directory_seeds: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EnrollmentRequestClaims { + pub protocol_version: String, + pub operation_id: Uuid, + pub invitation_claims_sha256: [u8; 32], + pub node_id: NodeId, + pub requested_profile: BootstrapProfile, + pub signing_public_key_base64: String, + pub tls_csr_pem: String, +} + +pub(crate) fn sign_enrollment_claims( + claims: &EnrollmentRequestClaims, + signing_key: &SigningKey, +) -> Result<(String, String), ProtocolError> { + validate_enrollment_claims(claims)?; + if signing_key.verifying_key() != decode_signing_key(&claims.signing_public_key_base64)? { + return Err(ProtocolError::InvalidEnrollmentRequest); + } + let exact_claims = + serde_json::to_vec(claims).map_err(|_| ProtocolError::SerializationFailed)?; + let signature = signing_key.sign(&signature_message(&exact_claims)); + Ok(( + STANDARD.encode(exact_claims), + STANDARD.encode(signature.to_bytes()), + )) +} + +pub(crate) fn verify_enrollment_claims( + exact_claims_base64: &str, + signature_base64: &str, +) -> Result { + let exact_claims = STANDARD + .decode(exact_claims_base64) + .map_err(|_| ProtocolError::InvalidEnrollmentRequest)?; + if exact_claims.len() > MAX_ENROLLMENT_CSR_BYTES + 4 * 1024 { + return Err(ProtocolError::EnrollmentRequestTooLarge); + } + let claims: EnrollmentRequestClaims = serde_json::from_slice(&exact_claims) + .map_err(|_| ProtocolError::InvalidEnrollmentRequest)?; + validate_enrollment_claims(&claims)?; + let verifying_key = decode_signing_key(&claims.signing_public_key_base64)?; + let signature_bytes = STANDARD + .decode(signature_base64) + .map_err(|_| ProtocolError::InvalidEnrollmentSignature)?; + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| ProtocolError::InvalidEnrollmentSignature)?; + verifying_key + .verify_strict(&signature_message(&exact_claims), &signature) + .map_err(|_| ProtocolError::InvalidEnrollmentSignature)?; + Ok(claims) +} + +fn validate_enrollment_claims(claims: &EnrollmentRequestClaims) -> Result<(), ProtocolError> { + if claims.protocol_version != "agenet.enrollment.v0.2" { + return Err(ProtocolError::UnsupportedEnrollmentVersion); + } + if claims.operation_id.is_nil() || claims.invitation_claims_sha256 == [0_u8; 32] { + return Err(ProtocolError::InvalidEnrollmentRequest); + } + if claims.tls_csr_pem.is_empty() || claims.tls_csr_pem.len() > MAX_ENROLLMENT_CSR_BYTES { + return Err(ProtocolError::EnrollmentRequestTooLarge); + } + decode_signing_key(&claims.signing_public_key_base64).map(|_| ()) +} + +fn decode_signing_key(encoded: &str) -> Result { + let decoded = STANDARD + .decode(encoded) + .map_err(|_| ProtocolError::InvalidEnrollmentRequest)?; + let bytes: [u8; 32] = decoded + .try_into() + .map_err(|_| ProtocolError::InvalidEnrollmentRequest)?; + VerifyingKey::from_bytes(&bytes).map_err(|_| ProtocolError::InvalidEnrollmentRequest) +} + +fn signature_message(exact_claims: &[u8]) -> Vec { + let mut message = Vec::with_capacity(ENROLLMENT_REQUEST_DOMAIN.len() + 8 + exact_claims.len()); + message.extend_from_slice(ENROLLMENT_REQUEST_DOMAIN); + message.extend_from_slice(&(exact_claims.len() as u64).to_be_bytes()); + message.extend_from_slice(exact_claims); + message +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn exact_signed_claim_bytes_reject_each_field_mutation() { + let key = SigningKey::from_bytes(&[31_u8; 32]); + let claims = EnrollmentRequestClaims { + protocol_version: "agenet.enrollment.v0.2".to_owned(), + operation_id: Uuid::from_u128(9), + invitation_claims_sha256: [7_u8; 32], + node_id: NodeId::new("node-exact-claims").expect("node"), + requested_profile: BootstrapProfile::Provider, + signing_public_key_base64: STANDARD.encode(key.verifying_key().to_bytes()), + tls_csr_pem: + "-----BEGIN CERTIFICATE REQUEST-----\nabc\n-----END CERTIFICATE REQUEST-----\n" + .to_owned(), + }; + let (exact, signature) = sign_enrollment_claims(&claims, &key).expect("sign"); + let original = STANDARD.decode(exact).expect("claims"); + let original_value: Value = serde_json::from_slice(&original).expect("JSON"); + let mutations = [ + ( + "protocol_version", + Value::String("agenet.enrollment.v0.3".to_owned()), + ), + ( + "operation_id", + Value::String(Uuid::from_u128(10).to_string()), + ), + ( + "invitation_claims_sha256", + Value::Array(vec![Value::from(8); 32]), + ), + ("node_id", Value::String("node-mutated".to_owned())), + ("requested_profile", Value::String("base".to_owned())), + ( + "signing_public_key_base64", + Value::String(STANDARD.encode([4_u8; 32])), + ), + ("tls_csr_pem", Value::String("different CSR".to_owned())), + ]; + for (field, replacement) in mutations { + let mut changed = original_value.clone(); + changed[field] = replacement; + let changed_exact = STANDARD.encode(serde_json::to_vec(&changed).expect("JSON")); + assert!( + verify_enrollment_claims(&changed_exact, &signature).is_err(), + "{field}" + ); + } + } +} diff --git a/src/protocol/error.rs b/src/protocol/error.rs index 8226055..9515fe5 100644 --- a/src/protocol/error.rs +++ b/src/protocol/error.rs @@ -21,6 +21,10 @@ pub enum ProtocolError { CredentialExpired, CredentialNotYetValid, CredentialIssuerMismatch, + InvalidEnrollmentRequest, + InvalidEnrollmentSignature, + UnsupportedEnrollmentVersion, + EnrollmentRequestTooLarge, InvalidEnvelopeSignature, InvalidContractSignature, UnexpectedObjectType, diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index d46b17c..c0e40fa 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -1,16 +1,22 @@ mod authority; mod contract; +mod enrollment; mod envelope; mod error; mod identity; mod sealed_contract; mod types; +pub(crate) use authority::roles_for_bootstrap_profile; pub use authority::{ AuthorityClaims, AuthorityScope, CredentialChain, SignedAuthorityCredential, verify_credential_chain, }; pub use contract::{ContractProjection, apply_event, event_hash}; +pub use enrollment::{EnrollmentBundle, MAX_ENROLLMENT_CSR_BYTES}; +pub(crate) use enrollment::{ + EnrollmentRequestClaims, sign_enrollment_claims, verify_enrollment_claims, +}; pub use envelope::WireEnvelope; pub use error::ProtocolError; pub use identity::{ diff --git a/src/transport/enrollment.rs b/src/transport/enrollment.rs new file mode 100644 index 0000000..34107eb --- /dev/null +++ b/src/transport/enrollment.rs @@ -0,0 +1,373 @@ +use std::{ + fmt::{Debug, Formatter}, + sync::Arc, + time::Duration, +}; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, State}, + http::StatusCode, + routing::{get, post}, +}; +use reqwest::{Client, Url, redirect::Policy}; +use rustls::{ + ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme, + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + pki_types::{CertificateDer, ServerName, UnixTime}, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use x509_parser::prelude::{FromDer, X509Certificate}; +use zeroize::Zeroizing; + +use super::MAX_JSON_BODY_BYTES; +use crate::{ + bootstrap::{ + EnrollmentAttempt, EnrollmentAuthority, EnrollmentError, EnrollmentHandoffValidation, + InvitationHandoff, IssuedServerIdentity, network::NetworkBoundary, + }, + protocol::EnrollmentBundle, +}; + +pub async fn enrollment_tls_config( + identity: &IssuedServerIdentity, + ca_certificate_pem: &str, +) -> Result { + let mut chain = identity.cert_pem.clone(); + chain.push_str(ca_certificate_pem); + axum_server::tls_rustls::RustlsConfig::from_pem( + chain.into_bytes(), + identity.private_key_pem.as_bytes().to_vec(), + ) + .await + .map_err(|_| EnrollmentTransportError::TlsRejected) +} + +#[derive(Clone)] +pub struct EnrollmentClient { + client: Client, + endpoint: Url, + pinned_ca_sha256: String, +} + +impl Debug for EnrollmentClient { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EnrollmentClient") + .field("endpoint", &self.endpoint) + .finish_non_exhaustive() + } +} + +impl EnrollmentClient { + pub fn new( + boundary: &NetworkBoundary, + endpoint: Url, + pinned_ca_sha256: String, + connect_timeout: Duration, + request_timeout: Duration, + ) -> Result { + validate_endpoint(boundary, &endpoint)?; + if pinned_ca_sha256.len() != 64 + || !pinned_ca_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(EnrollmentTransportError::TlsRejected); + } + let provider = rustls::crypto::aws_lc_rs::default_provider(); + let verifier = Arc::new(PinnedCaVerifier { + pinned_ca_sha256: pinned_ca_sha256.clone(), + supported: provider.signature_verification_algorithms, + }); + let tls = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(); + let client = Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(connect_timeout) + .timeout(request_timeout) + .https_only(true) + .tls_backend_preconfigured(tls) + .build() + .map_err(|_| EnrollmentTransportError::TlsRejected)?; + Ok(Self { + client, + endpoint, + pinned_ca_sha256, + }) + } + + pub async fn enroll( + &self, + handoff: &InvitationHandoff, + attempt: &EnrollmentAttempt, + ) -> Result { + if self.endpoint != handoff.public_claims().authority_endpoint { + return Err(EnrollmentError::InvalidRequest); + } + if self.pinned_ca_sha256 != handoff.public_claims().tls_ca_sha256 { + return Err(EnrollmentError::InvalidRequest); + } + let request = crate::bootstrap::EnrollmentWireRequest::from_local(handoff, attempt)?; + let body = Zeroizing::new( + serde_json::to_vec(&request).map_err(|_| EnrollmentError::InvalidRequest)?, + ); + if body.len() > MAX_JSON_BODY_BYTES { + return Err(EnrollmentError::InvalidRequest); + } + let wire = self.send_enrollment(body).await?; + if wire.format_version != "agenet.enrollment-wire.v0.2" { + return Err(EnrollmentError::InvalidRequest); + } + EnrollmentHandoffValidation::validate(handoff, attempt, &wire.bundle, current_time_ms())?; + Ok(wire.bundle) + } + + async fn send_enrollment( + &self, + body: Zeroizing>, + ) -> Result { + let url = self + .endpoint + .join("/v0/enroll") + .map_err(|_| EnrollmentError::InvalidRequest)?; + let mut response = self + .client + .post(url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body.as_slice().to_vec()) + .send() + .await + .map_err(|_| EnrollmentError::TransportFailed)?; + if !response.status().is_success() { + return Err(EnrollmentError::TransportFailed); + } + let encoded = read_bounded_response(&mut response).await?; + serde_json::from_slice(&encoded).map_err(|_| EnrollmentError::TransportFailed) + } +} + +async fn read_bounded_response( + response: &mut reqwest::Response, +) -> Result, EnrollmentError> { + if response + .content_length() + .is_some_and(|length| length > MAX_JSON_BODY_BYTES as u64) + { + return Err(EnrollmentError::ResponseTooLarge); + } + let mut encoded = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| EnrollmentError::TransportFailed)? + { + if encoded.len().saturating_add(chunk.len()) > MAX_JSON_BODY_BYTES { + return Err(EnrollmentError::ResponseTooLarge); + } + encoded.extend_from_slice(&chunk); + } + Ok(encoded) +} + +pub fn enrollment_router(authority: Arc) -> Router { + Router::new() + .route("/healthz", get(|| async { StatusCode::OK })) + .route("/v0/enroll", post(handle_enrollment)) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .with_state(authority) +} + +async fn handle_enrollment( + State(authority): State>, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + authority + .process(&request, current_time_ms()) + .map(|bundle| { + Json(crate::bootstrap::EnrollmentWireResponse { + format_version: "agenet.enrollment-wire.v0.2".to_owned(), + bundle, + }) + }) + .map_err(|error| { + let status = match error { + EnrollmentError::InvalidRequest | EnrollmentError::InvalidInvitation => { + StatusCode::BAD_REQUEST + } + EnrollmentError::PolicyRejected | EnrollmentError::CertificateRejected => { + StatusCode::FORBIDDEN + } + EnrollmentError::ResponseTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + EnrollmentError::PersistenceFailed | EnrollmentError::TransportFailed => { + StatusCode::SERVICE_UNAVAILABLE + } + }; + ( + status, + Json(SanitizedError { + code: error.to_string(), + message: "enrollment rejected", + retryable: status == StatusCode::SERVICE_UNAVAILABLE, + }), + ) + }) +} + +#[derive(Serialize)] +struct SanitizedError { + code: String, + message: &'static str, + retryable: bool, +} + +fn current_time_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_millis()).ok()) + .unwrap_or(0) +} + +fn validate_endpoint( + boundary: &NetworkBoundary, + endpoint: &Url, +) -> Result<(), EnrollmentTransportError> { + if endpoint.scheme() != "https" + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || endpoint.path() != "/" + || boundary.validate_peer_endpoint(endpoint.as_str()).is_err() + { + return Err(EnrollmentTransportError::InvalidEndpoint); + } + Ok(()) +} + +fn fingerprint(certificate: &CertificateDer<'_>) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = Sha256::digest(certificate.as_ref()); + let mut output = String::with_capacity(64); + for byte in digest { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output +} + +struct PinnedCaVerifier { + pinned_ca_sha256: String, + supported: rustls::crypto::WebPkiSupportedAlgorithms, +} + +impl Debug for PinnedCaVerifier { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PinnedCaVerifier") + .field("pinned_ca_sha256", &self.pinned_ca_sha256) + .finish_non_exhaustive() + } +} + +impl ServerCertVerifier for PinnedCaVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + let pinned_ca = intermediates + .iter() + .find(|certificate| fingerprint(certificate) == self.pinned_ca_sha256) + .ok_or_else(|| { + rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer) + })?; + validate_ca_certificate(pinned_ca, now)?; + let mut roots = RootCertStore::empty(); + if roots.add(pinned_ca.clone()).is_err() { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::BadEncoding, + )); + } + let webpki = rustls::client::WebPkiServerVerifier::builder(Arc::new(roots)) + .build() + .map_err(|_| rustls::Error::General("pinned trust anchor rejected".to_owned()))?; + webpki.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported) + } + + fn supported_verify_schemes(&self) -> Vec { + self.supported.supported_schemes() + } +} + +fn validate_ca_certificate( + certificate: &CertificateDer<'_>, + now: UnixTime, +) -> Result<(), rustls::Error> { + let (_, parsed) = X509Certificate::from_der(certificate.as_ref()) + .map_err(|_| rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding))?; + let now = i64::try_from(now.as_secs()) + .map_err(|_| rustls::Error::InvalidCertificate(rustls::CertificateError::Expired))?; + if now < parsed.validity().not_before.timestamp() { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::NotValidYet, + )); + } + if now > parsed.validity().not_after.timestamp() { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::Expired, + )); + } + let is_ca = parsed + .basic_constraints() + .map_err(|_| rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding))? + .is_some_and(|constraints| constraints.value.ca); + if !is_ca || parsed.verify_signature(Some(parsed.public_key())).is_err() { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::UnknownIssuer, + )); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnrollmentTransportError { + InvalidEndpoint, + TlsRejected, + RequestFailed, + ResponseTooLarge, +} + +impl std::fmt::Display for EnrollmentTransportError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for EnrollmentTransportError {} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index aa3ae57..04833c0 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -1,9 +1,13 @@ mod client; mod directory; +mod enrollment; mod node; pub use client::{HttpStats, PeerClient, TransportError}; pub use directory::directory_router; +pub use enrollment::{ + EnrollmentClient, EnrollmentTransportError, enrollment_router, enrollment_tls_config, +}; pub use node::{artifact_router, provider_router, requester_router}; pub const MAX_JSON_BODY_BYTES: usize = 256 * 1024; diff --git a/tests/enrollment_protocol.rs b/tests/enrollment_protocol.rs new file mode 100644 index 0000000..048cb27 --- /dev/null +++ b/tests/enrollment_protocol.rs @@ -0,0 +1,162 @@ +use std::collections::BTreeSet; + +use agenet::{ + bootstrap::{EnrollmentAttempt, InvitationSpec, InvitationStore, NodeTlsCsr}, + protocol::{BootstrapProfile, CapabilityKind, DomainId, NodeId}, +}; +use ed25519_dalek::SigningKey; +use reqwest::Url; +use tempfile::TempDir; +use uuid::Uuid; + +const NOW_MS: i64 = 2_000_000_000_000; + +fn invitation() -> (TempDir, agenet::bootstrap::InvitationHandoff) { + let directory = TempDir::new().expect("temporary state"); + let state = directory.path().join("state"); + let store = InvitationStore::open(&state).expect("invitation store"); + let handoff = store + .create( + InvitationSpec { + protocol_version: "agenet.enrollment.v0.2".to_owned(), + domain_id: DomainId::new("domain-test").expect("domain"), + authority_endpoint: Url::parse("https://127.0.0.1:8443").expect("url"), + directory_seeds: vec![Url::parse("https://127.0.0.1:9443").expect("url")], + root_sha256: "11".repeat(32), + tls_ca_sha256: "22".repeat(32), + allowed_profile: BootstrapProfile::Provider, + capability_ceiling: BTreeSet::from([ + CapabilityKind::new("source.metrics.v1").expect("capability") + ]), + }, + NOW_MS, + ) + .expect("handoff"); + (directory, handoff) +} + +fn attempt(handoff: &agenet::bootstrap::InvitationHandoff) -> (SigningKey, EnrollmentAttempt) { + let signing_key = SigningKey::from_bytes(&[7_u8; 32]); + let csr = NodeTlsCsr::generate().expect("CSR"); + let attempt = EnrollmentAttempt::sign( + handoff, + Uuid::from_u128(17), + NodeId::new("node-joiner").expect("node"), + BootstrapProfile::Provider, + &signing_key, + csr.csr_pem, + ) + .expect("signed attempt"); + (signing_key, attempt) +} + +#[test] +fn exact_request_signature_rejects_every_protected_field_mutation() { + let (_directory, handoff) = invitation(); + let (_key, original) = attempt(&handoff); + original.verify(&handoff).expect("original verifies"); + + let mut mutations = Vec::new(); + let mut changed = original.clone(); + changed.operation_id = Uuid::from_u128(18); + mutations.push(changed); + let mut changed = original.clone(); + changed.node_id = NodeId::new("node-other").expect("node"); + mutations.push(changed); + let mut changed = original.clone(); + changed.requested_profile = BootstrapProfile::Base; + mutations.push(changed); + let mut changed = original.clone(); + changed.signing_public_key_base64 = "invalid-key".to_owned(); + mutations.push(changed); + let mut changed = original.clone(); + changed.tls_csr_pem.push('x'); + mutations.push(changed); + + for changed in mutations { + assert!(changed.verify(&handoff).is_err()); + } +} + +#[test] +fn invitation_claim_mutation_invalidates_the_exact_request_signature() { + let (_directory, handoff) = invitation(); + let (_key, attempt) = attempt(&handoff); + let original = handoff.public_claims(); + let mut mutations = Vec::new(); + let mut claims = original.clone(); + claims.protocol_version = "agenet.enrollment.v0.3".to_owned(); + mutations.push(claims); + let mut claims = original.clone(); + claims.domain_id = DomainId::new("domain-mutated").expect("domain"); + mutations.push(claims); + let mut claims = original.clone(); + claims.authority_endpoint = Url::parse("https://127.0.0.1:8555/").expect("url"); + mutations.push(claims); + let mut claims = original.clone(); + claims.directory_seeds = vec![Url::parse("https://127.0.0.1:9555/").expect("url")]; + mutations.push(claims); + let mut claims = original.clone(); + claims.root_sha256 = "33".repeat(32); + mutations.push(claims); + let mut claims = original.clone(); + claims.tls_ca_sha256 = "44".repeat(32); + mutations.push(claims); + let mut claims = original.clone(); + claims.allowed_profile = BootstrapProfile::Base; + mutations.push(claims); + let mut claims = original.clone(); + claims.capability_ceiling = + BTreeSet::from([CapabilityKind::new("project.build.v1").expect("capability")]); + mutations.push(claims); + let mut claims = original.clone(); + claims.invitation_id = Uuid::from_u128(999); + mutations.push(claims); + let mut claims = original.clone(); + claims.expires_at_ms += 1_000; + mutations.push(claims); + let mut claims = original.clone(); + claims.maximum_attempts -= 1; + mutations.push(claims); + + for claims in mutations { + assert!(attempt.verify_public_claims(&claims).is_err()); + } + attempt.verify(&handoff).expect("original remains valid"); +} + +#[test] +fn invalid_operation_profile_and_csr_bounds_fail_before_transport() { + let (_directory, handoff) = invitation(); + let signing_key = SigningKey::from_bytes(&[9_u8; 32]); + + let nil = EnrollmentAttempt::sign( + &handoff, + Uuid::nil(), + NodeId::new("node-joiner").expect("node"), + BootstrapProfile::Provider, + &signing_key, + "csr".to_owned(), + ); + assert!(nil.is_err()); + + let escalated = EnrollmentAttempt::sign( + &handoff, + Uuid::from_u128(1), + NodeId::new("node-joiner").expect("node"), + BootstrapProfile::AgentCandidate, + &signing_key, + "csr".to_owned(), + ); + assert!(escalated.is_err()); + + let oversized = EnrollmentAttempt::sign( + &handoff, + Uuid::from_u128(2), + NodeId::new("node-joiner").expect("node"), + BootstrapProfile::Provider, + &signing_key, + "x".repeat(16 * 1024 + 1), + ); + assert!(oversized.is_err()); +} diff --git a/tests/http_enrollment.rs b/tests/http_enrollment.rs new file mode 100644 index 0000000..a03242a --- /dev/null +++ b/tests/http_enrollment.rs @@ -0,0 +1,669 @@ +use agenet::{ + bootstrap::{ + AuthorityPki, EnrollmentAttempt, EnrollmentAuthority, InvitationHandoff, InvitationSpec, + InvitationState, InvitationStore, NodeTlsCsr, + network::{NetworkBoundary, OverlayKind}, + }, + protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityKind, DomainId, NodeId, + NodeRole, SignedAuthorityCredential, verify_credential_chain, + }, + transport::{ + EnrollmentClient, EnrollmentTransportError, MAX_JSON_BODY_BYTES, enrollment_router, + }, +}; +use axum::{Router, http::StatusCode, response::Redirect, routing::post}; +use axum_server::{Handle, tls_rustls::RustlsConfig}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; +use ipnet::IpNet; +use reqwest::Url; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeSet, + net::{IpAddr, SocketAddr, TcpListener}, + os::unix::fs::PermissionsExt, + sync::Arc, + time::Duration, +}; +use tempfile::TempDir; +use tokio::task::JoinHandle; +use uuid::Uuid; + +struct TestIdentity { + chain: String, + key: Vec, +} + +struct HttpEnrollmentFixture { + state: TempDir, + root: SigningKey, + credential: SignedAuthorityCredential, + invitations: Arc, + authority: Arc, + handoff: InvitationHandoff, + attempt: EnrollmentAttempt, + expired_ca_handoff: InvitationHandoff, + expired_ca_attempt: EnrollmentAttempt, + endpoint: Url, + port: u16, + ca_pem: String, + valid: TestIdentity, + wrong_ip: TestIdentity, + wrong_chain: TestIdentity, + expired_leaf: TestIdentity, + expired_ca: TestIdentity, +} + +impl HttpEnrollmentFixture { + fn new() -> Self { + let now_ms = current_second_ms(); + let root = SigningKey::from_bytes(&[1_u8; 32]); + let authority_key = SigningKey::from_bytes(&[2_u8; 32]); + let pki = AuthorityPki::generate(now_ms - 60_000, now_ms + 3_600_000).expect("CA"); + let ca_pem = pki.ca_cert_pem.to_string(); + let port = reserve_loopback_port(); + let endpoint = Url::parse(&format!("https://127.0.0.1:{port}/")).expect("endpoint"); + let valid = identity( + &pki, + &ca_pem, + "127.0.0.1", + now_ms - 60_000, + now_ms + 600_000, + ); + let wrong_ip = identity( + &pki, + &ca_pem, + "127.0.0.2", + now_ms - 60_000, + now_ms + 600_000, + ); + let expired_leaf = identity(&pki, &ca_pem, "127.0.0.1", now_ms - 60_000, now_ms - 1_000); + let other_pki = + AuthorityPki::generate(now_ms - 60_000, now_ms + 3_600_000).expect("other CA"); + let wrong_chain = identity( + &other_pki, + &ca_pem, + "127.0.0.1", + now_ms - 60_000, + now_ms + 600_000, + ); + let expired_pki = + AuthorityPki::generate(now_ms - 120_000, now_ms - 60_000).expect("expired CA"); + let expired_ca = identity( + &expired_pki, + &expired_pki.ca_cert_pem, + "127.0.0.1", + now_ms - 120_000, + now_ms - 60_000, + ); + let credential = authority_credential(&root, &authority_key, &pki, now_ms); + let state = TempDir::new().expect("state"); + let invitations = Arc::new( + InvitationStore::open(&state.path().join("invitations")).expect("invitation store"), + ); + let handoff = create_handoff( + &invitations, + &credential, + &root, + &endpoint, + &pki.fingerprint_sha256, + now_ms, + ); + let expired_ca_handoff = create_handoff( + &invitations, + &credential, + &root, + &endpoint, + &expired_pki.fingerprint_sha256, + now_ms, + ); + let attempt = signed_attempt(&handoff, 44, "node-http-enrollment", &[3_u8; 32]); + let expired_ca_attempt = + signed_attempt(&expired_ca_handoff, 45, "node-expired-ca", &[3_u8; 32]); + let authority = Arc::new( + EnrollmentAuthority::open( + &state.path().join("results"), + Arc::clone(&invitations), + root.verifying_key(), + credential.clone(), + authority_key, + pki, + endpoint.clone(), + ) + .expect("enrollment authority"), + ); + Self { + state, + root, + credential, + invitations, + authority, + handoff, + attempt, + expired_ca_handoff, + expired_ca_attempt, + endpoint, + port, + ca_pem, + valid, + wrong_ip, + wrong_chain, + expired_leaf, + expired_ca, + } + } + + fn client(&self, timeout: Duration) -> EnrollmentClient { + EnrollmentClient::new( + &loopback_boundary(), + self.endpoint.clone(), + self.handoff.public_claims().tls_ca_sha256.clone(), + Duration::from_secs(2), + timeout, + ) + .expect("client") + } + + async fn start_authority(&self, identity: &TestIdentity) -> RunningServer { + start_server( + self.port, + identity, + enrollment_router(Arc::clone(&self.authority)), + ) + .await + } + + async fn start_valid(&self) -> RunningServer { + self.start_authority(&self.valid).await + } +} + +struct RunningServer { + handle: Handle, + task: JoinHandle<()>, +} + +impl RunningServer { + async fn stop(self) { + self.handle.graceful_shutdown(Some(Duration::from_secs(2))); + self.task.await.expect("server task"); + } +} + +#[tokio::test] +async fn enrollment_issues_and_recovers_the_exact_durable_bundle() { + let fixture = HttpEnrollmentFixture::new(); + let server = fixture.start_valid().await; + let client = fixture.client(Duration::from_secs(10)); + let first = client + .enroll(&fixture.handoff, &fixture.attempt) + .await + .expect("enrollment"); + let recovered = client + .enroll(&fixture.handoff, &fixture.attempt) + .await + .expect("recovery"); + assert_eq!( + serde_json::to_vec(&first).unwrap(), + serde_json::to_vec(&recovered).unwrap() + ); + let verified = verify_credential_chain( + &fixture.root.verifying_key(), + &first.credential_chain, + &first.domain_id, + NodeRole::Requester, + current_second_ms(), + ) + .expect("credential chain"); + assert!(verified.expires_at_ms > fixture.handoff.public_claims().expires_at_ms); + server.stop().await; +} + +#[tokio::test] +async fn enrollment_rejects_invalid_csr_changed_operation_and_reuse() { + let fixture = HttpEnrollmentFixture::new(); + let server = fixture.start_valid().await; + let invalid_handoff = fixture.new_handoff(current_second_ms()); + let invalid = EnrollmentAttempt::sign( + &invalid_handoff, + Uuid::from_u128(46), + NodeId::new("node-invalid-csr").unwrap(), + BootstrapProfile::Provider, + &SigningKey::from_bytes(&[3; 32]), + "-----BEGIN CERTIFICATE REQUEST-----\nAAAA\n-----END CERTIFICATE REQUEST-----\n".to_owned(), + ) + .expect("signed invalid CSR"); + let client = fixture.client(Duration::from_secs(10)); + assert!(client.enroll(&invalid_handoff, &invalid).await.is_err()); + client + .enroll(&fixture.handoff, &fixture.attempt) + .await + .expect("initial issue"); + let changed = signed_attempt(&fixture.handoff, 44, "node-changed-request", &[4; 32]); + let reused = signed_attempt(&fixture.handoff, 47, "node-reused-invitation", &[3; 32]); + assert!(client.enroll(&fixture.handoff, &changed).await.is_err()); + assert!(client.enroll(&fixture.handoff, &reused).await.is_err()); + server.stop().await; +} + +#[tokio::test] +async fn enrollment_tls_rejects_wrong_pin_ip_chain_and_validity() { + let fixture = HttpEnrollmentFixture::new(); + let wrong_pin = EnrollmentClient::new( + &loopback_boundary(), + fixture.endpoint.clone(), + "00".repeat(32), + Duration::from_secs(2), + Duration::from_secs(10), + ) + .expect("syntactically valid client"); + let server = fixture.start_valid().await; + assert!( + wrong_pin + .enroll(&fixture.handoff, &fixture.attempt) + .await + .is_err() + ); + server.stop().await; + assert_tls_rejected( + &fixture, + &fixture.wrong_ip, + &fixture.handoff, + &fixture.attempt, + ) + .await; + assert_tls_rejected( + &fixture, + &fixture.wrong_chain, + &fixture.handoff, + &fixture.attempt, + ) + .await; + assert_tls_rejected( + &fixture, + &fixture.expired_leaf, + &fixture.handoff, + &fixture.attempt, + ) + .await; + assert_tls_rejected( + &fixture, + &fixture.expired_ca, + &fixture.expired_ca_handoff, + &fixture.expired_ca_attempt, + ) + .await; +} + +#[tokio::test] +async fn enrollment_http_rejects_redirect_oversize_status_and_timeout() { + let fixture = HttpEnrollmentFixture::new(); + let sentinel = TcpListener::bind(("127.0.0.1", 0)).expect("redirect sentinel"); + sentinel + .set_nonblocking(true) + .expect("nonblocking sentinel"); + let destination = format!("https://{}/", sentinel.local_addr().unwrap()); + let redirect = Router::new().route( + "/v0/enroll", + post(move || async move { Redirect::temporary(&destination) }), + ); + assert_http_rejected(&fixture, redirect, Duration::from_secs(10)).await; + assert_eq!( + sentinel.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + let oversized = Router::new().route( + "/v0/enroll", + post(|| async { vec![b'x'; MAX_JSON_BODY_BYTES + 1] }), + ); + assert_http_rejected(&fixture, oversized, Duration::from_secs(10)).await; + let unavailable = Router::new().route( + "/v0/enroll", + post(|| async { StatusCode::SERVICE_UNAVAILABLE }), + ); + assert_http_rejected(&fixture, unavailable, Duration::from_secs(10)).await; + let slow = Router::new().route( + "/v0/enroll", + post(|| async { + tokio::time::sleep(Duration::from_millis(100)).await; + StatusCode::OK + }), + ); + assert_http_rejected(&fixture, slow, Duration::from_millis(20)).await; +} + +#[tokio::test] +async fn enrollment_server_rejects_oversized_request_without_side_effects() { + let fixture = HttpEnrollmentFixture::new(); + let server = fixture.start_valid().await; + let ca = reqwest::Certificate::from_pem(fixture.ca_pem.as_bytes()).expect("CA certificate"); + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .tls_certs_only([ca]) + .build() + .expect("raw TLS client"); + let sentinel = "SENSITIVE-OVERSIZED-BODY-SENTINEL"; + let mut body = vec![b'x'; MAX_JSON_BODY_BYTES + 1]; + body[..sentinel.len()].copy_from_slice(sentinel.as_bytes()); + let response = client + .post(fixture.endpoint.join("/v0/enroll").unwrap()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert!(!response.text().await.unwrap().contains(sentinel)); + let record = fixture + .invitations + .record(fixture.handoff.public_claims().invitation_id) + .unwrap() + .expect("invitation record"); + assert_eq!(record.state, InvitationState::Available); + assert_eq!( + std::fs::read_dir(fixture.state.path().join("results")) + .unwrap() + .count(), + 0 + ); + server.stop().await; +} + +#[tokio::test] +async fn enrollment_ignores_system_proxy_configuration() { + const CHILD_MARKER: &str = "AGENET_PROXY_TEST_CHILD"; + if std::env::var_os(CHILD_MARKER).is_none() { + let status = std::process::Command::new(std::env::current_exe().expect("test binary")) + .arg("--exact") + .arg("enrollment_ignores_system_proxy_configuration") + .arg("--test-threads=1") + .env(CHILD_MARKER, "1") + .status() + .expect("isolated proxy child"); + assert!(status.success()); + return; + } + let fixture = HttpEnrollmentFixture::new(); + let proxy = TcpListener::bind(("127.0.0.1", 0)).expect("proxy sentinel"); + proxy.set_nonblocking(true).expect("nonblocking proxy"); + let proxy_url = format!("http://{}", proxy.local_addr().unwrap()); + let guard = ProxyEnvironmentGuard::install(&proxy_url); + let client = fixture.client(Duration::from_secs(10)); + drop(guard); + let server = fixture.start_valid().await; + client + .enroll(&fixture.handoff, &fixture.attempt) + .await + .expect("direct enrollment"); + assert_eq!( + proxy.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + server.stop().await; +} + +#[tokio::test] +async fn durable_results_are_owner_only_and_do_not_store_private_keys() { + let fixture = HttpEnrollmentFixture::new(); + let server = fixture.start_valid().await; + fixture + .client(Duration::from_secs(10)) + .enroll(&fixture.handoff, &fixture.attempt) + .await + .unwrap(); + let directory = fixture.state.path().join("results"); + assert_eq!( + std::fs::metadata(&directory).unwrap().permissions().mode() & 0o777, + 0o700 + ); + for entry in std::fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let bytes = std::fs::read(path).unwrap(); + assert!( + !bytes + .windows(b"PRIVATE KEY".len()) + .any(|window| window == b"PRIVATE KEY") + ); + } + server.stop().await; +} + +#[test] +fn enrollment_client_rejects_non_exact_private_overlay_endpoints() { + for endpoint in [ + "https://localhost:443/", + "https://8.8.8.8:443/", + "https://user@127.0.0.1:443/", + "https://127.0.0.1:443/enroll", + "https://127.0.0.1:443/?redirect=https://evil.invalid", + "http://127.0.0.1:443/", + ] { + let result = EnrollmentClient::new( + &loopback_boundary(), + Url::parse(endpoint).unwrap(), + "00".repeat(32), + Duration::from_secs(2), + Duration::from_secs(10), + ); + assert_eq!( + result.unwrap_err(), + EnrollmentTransportError::InvalidEndpoint + ); + } +} + +impl HttpEnrollmentFixture { + fn new_handoff(&self, now_ms: i64) -> InvitationHandoff { + create_handoff( + &self.invitations, + &self.credential, + &self.root, + &self.endpoint, + &self.handoff.public_claims().tls_ca_sha256, + now_ms, + ) + } +} + +fn authority_credential( + root: &SigningKey, + authority_key: &SigningKey, + pki: &AuthorityPki, + now_ms: i64, +) -> SignedAuthorityCredential { + SignedAuthorityCredential::issue( + root, + AuthorityClaims { + domain_id: DomainId::new("domain-http-enrollment").unwrap(), + authority_id: NodeId::new("authority-http-enrollment").unwrap(), + signing_public_key_base64: STANDARD.encode(authority_key.verifying_key().to_bytes()), + tls_ca_sha256: pki.fingerprint_sha256.clone(), + scopes: BTreeSet::from([AuthorityScope::IssueNodeCredential]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Provider]), + maximum_node_lifetime_ms: 1_800_000, + issued_at_ms: now_ms - 60_000, + expires_at_ms: now_ms + 3_600_000, + }, + ) + .expect("Authority credential") +} + +fn create_handoff( + invitations: &InvitationStore, + credential: &SignedAuthorityCredential, + root: &SigningKey, + endpoint: &Url, + tls_ca_sha256: &str, + now_ms: i64, +) -> InvitationHandoff { + invitations + .create( + InvitationSpec { + protocol_version: "agenet.enrollment.v0.2".to_owned(), + domain_id: credential.claims.domain_id.clone(), + authority_endpoint: endpoint.clone(), + directory_seeds: vec![Url::parse("https://127.0.0.1:9443/").unwrap()], + root_sha256: fingerprint(root.verifying_key().as_bytes()), + tls_ca_sha256: tls_ca_sha256.to_owned(), + allowed_profile: BootstrapProfile::Provider, + capability_ceiling: BTreeSet::from([ + CapabilityKind::new("source.metrics.v1").unwrap() + ]), + }, + now_ms, + ) + .expect("invitation") +} + +fn signed_attempt( + handoff: &InvitationHandoff, + operation: u128, + node: &str, + key: &[u8; 32], +) -> EnrollmentAttempt { + EnrollmentAttempt::sign( + handoff, + Uuid::from_u128(operation), + NodeId::new(node).unwrap(), + BootstrapProfile::Provider, + &SigningKey::from_bytes(key), + NodeTlsCsr::generate().unwrap().csr_pem, + ) + .expect("attempt") +} + +fn identity( + pki: &AuthorityPki, + presented_ca_pem: &str, + ip: &str, + not_before_ms: i64, + not_after_ms: i64, +) -> TestIdentity { + let issued = pki + .issue_server(ip.parse().unwrap(), not_before_ms, not_after_ms) + .expect("identity"); + TestIdentity { + chain: format!("{}{}", issued.cert_pem, presented_ca_pem), + key: issued.private_key_pem.as_bytes().to_vec(), + } +} + +async fn start_server(port: u16, identity: &TestIdentity, router: Router) -> RunningServer { + let tls = RustlsConfig::from_pem(identity.chain.as_bytes().to_vec(), identity.key.clone()) + .await + .expect("TLS configuration"); + let handle = Handle::new(); + let server_handle = handle.clone(); + let task = tokio::spawn(async move { + axum_server::bind_rustls(SocketAddr::from(([127, 0, 0, 1], port)), tls) + .handle(server_handle) + .serve(router.into_make_service()) + .await + .expect("server"); + }); + handle.listening().await.expect("listener"); + RunningServer { handle, task } +} + +async fn assert_tls_rejected( + fixture: &HttpEnrollmentFixture, + identity: &TestIdentity, + handoff: &InvitationHandoff, + attempt: &EnrollmentAttempt, +) { + let server = fixture.start_authority(identity).await; + let client = EnrollmentClient::new( + &loopback_boundary(), + fixture.endpoint.clone(), + handoff.public_claims().tls_ca_sha256.clone(), + Duration::from_secs(2), + Duration::from_secs(10), + ) + .expect("client"); + assert!(client.enroll(handoff, attempt).await.is_err()); + server.stop().await; +} + +async fn assert_http_rejected(fixture: &HttpEnrollmentFixture, router: Router, timeout: Duration) { + let server = start_server(fixture.port, &fixture.valid, router).await; + assert!( + fixture + .client(timeout) + .enroll(&fixture.handoff, &fixture.attempt) + .await + .is_err() + ); + server.stop().await; +} + +fn loopback_boundary() -> NetworkBoundary { + NetworkBoundary { + kind: OverlayKind::Loopback, + bind_ip: "127.0.0.1".parse().unwrap(), + allowed_cidrs: vec![IpNet::from("127.0.0.1".parse::().unwrap())], + } +} + +fn reserve_loopback_port() -> u16 { + TcpListener::bind(("127.0.0.1", 0)) + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn current_second_ms() -> i64 { + let duration = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap(); + i64::try_from(duration.as_secs()).unwrap() * 1_000 +} + +fn fingerprint(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +struct ProxyEnvironmentGuard { + previous_https_proxy: Option, + previous_all_proxy: Option, +} + +impl ProxyEnvironmentGuard { + fn install(proxy: &str) -> Self { + let guard = Self { + previous_https_proxy: std::env::var_os("HTTPS_PROXY"), + previous_all_proxy: std::env::var_os("ALL_PROXY"), + }; + // SAFETY: this executes only in a dedicated one-test child process. + unsafe { + std::env::set_var("HTTPS_PROXY", proxy); + std::env::set_var("ALL_PROXY", proxy); + } + guard + } +} + +impl Drop for ProxyEnvironmentGuard { + fn drop(&mut self) { + // SAFETY: restores the values in the same dedicated child process. + unsafe { + match self.previous_https_proxy.take() { + Some(value) => std::env::set_var("HTTPS_PROXY", value), + None => std::env::remove_var("HTTPS_PROXY"), + } + match self.previous_all_proxy.take() { + Some(value) => std::env::set_var("ALL_PROXY", value), + None => std::env::remove_var("ALL_PROXY"), + } + } + } +} diff --git a/tests/invitation_store.rs b/tests/invitation_store.rs index c3f45b1..8520aec 100644 --- a/tests/invitation_store.rs +++ b/tests/invitation_store.rs @@ -98,14 +98,12 @@ fn create_uses_opaque_authentication_and_ten_minute_default_expiry() { let first = create_invitation(&store); let second = create_invitation(&store); - assert_eq!( - format!("{:?}", first.authentication()), - format!("{:?}", second.authentication()) - ); - assert_eq!( - format!("{:?}", first.authentication()), - "InvitationAuthentication { secret: \"[REDACTED]\", claims_integrity_hmac_sha256: \"[REDACTED]\" }" - ); + for authentication in [first.authentication(), second.authentication()] { + let debug = format!("{authentication:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains(&first.public_claims().invitation_id.to_string())); + assert!(!debug.contains(&second.public_claims().invitation_id.to_string())); + } assert_eq!( first.public_claims().expires_at_ms, NOW_MS + 10 * 60 * 1_000 @@ -117,12 +115,7 @@ fn create_uses_opaque_authentication_and_ten_minute_default_expiry() { fn persistence_contains_only_hmac_and_owner_only_separate_pepper() { let temp = TempDir::new().expect("temporary directory is created"); let store = create_store(&temp); - let handoff = create_invitation(&store); - let record = store - .record(handoff.public_claims().invitation_id) - .expect("record lookup succeeds") - .expect("record exists"); - + create_invitation(&store); let journal = fs::read(temp.path().join(JOURNAL_FILE)).expect("journal is readable"); let pepper = fs::read(temp.path().join(PEPPER_FILE)).expect("pepper is readable"); assert_eq!(pepper.len(), 32); @@ -141,7 +134,6 @@ fn persistence_contains_only_hmac_and_owner_only_separate_pepper() { 0o600 ); assert!(!journal.windows(pepper.len()).any(|window| window == pepper)); - assert_ne!(record.public_claims_sha256, record.secret_hmac_sha256); } #[test] From a05bf266c80cdd1ad0f5dea4e97e16c33ae321af Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 20:29:30 +0800 Subject: [PATCH 19/67] [bug] Zeroize enrollment bearer lifecycle Root cause: The client borrowed bearer state and copied its serialized secret into an ordinary request buffer. Trust bindings and CA policy were also incomplete before invitation reservation. Solution: Consume handoffs, transfer a zeroizing body owner into Reqwest, and validate exact Authority, CA, CSR, and canonical DER boundaries. Risks: Retry callers must reacquire the same secure handoff source. Dependency: Bootstrap step 6 commit 3c1f92a. Links: plan/01-v1-multi-host-node-bootstrap.md Post-mortem: Audit ownership and every secret-bearing allocation before reviewing cryptographic correctness in isolation. --- Cargo.lock | 1 + Cargo.toml | 1 + ROADMAP.md | 10 +++ src/bootstrap/enrollment.rs | 94 +++++++++++++++++-- src/bootstrap/invitation.rs | 42 +++++++++ src/transport/enrollment.rs | 159 +++++++++++++++++++++++++++++--- tests/http_enrollment.rs | 175 +++++++++++++++++++++++------------- 7 files changed, 402 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b1b06df..9419649 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,6 +96,7 @@ dependencies = [ "axum", "axum-server", "base64 0.23.1", + "bytes", "clap", "directories", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index be239fc..8eb1bbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ age = "=0.12.1" axum = "=0.8.9" axum-server = { version = "=0.8.0", features = ["tls-rustls"] } base64 = "=0.23.1" +bytes = "=1.12.1" clap = { version = "=4.6.6", features = ["derive"] } directories = "=6.0.0" dotenvy = "=0.15.7" diff --git a/ROADMAP.md b/ROADMAP.md index 13d3597..a03329d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,15 @@ # ROADMAP +## 2026-08-15 00:48 CST + +- **Change**: Corrected Task 6 enrollment bearer ownership, request-body zeroization, pre-reservation Authority binding, CA policy validation, Node ID DER canonicality, and CSR negative coverage after security review. +- **Files**: `src/bootstrap/enrollment.rs`, `src/bootstrap/invitation.rs`, `src/transport/enrollment.rs`, `tests/http_enrollment.rs`, `Cargo.toml`, `Cargo.lock`, and ignored Task 6 report/evidence. +- **Root cause**: Security boundary omission — the first implementation borrowed `InvitationHandoff`, made a normal request-body `Vec` copy, validated the pinned presented CA without requiring its exact Authority KeyUsage policy, and deferred configured Authority identity checks until after the invitation authorization boundary. It also accepted non-minimal DER length encodings and used a malformed rather than parseable-signature-tampered CSR fixture. +- **Solution**: Consume the handoff by value; transfer a zeroizing owner directly into `Bytes::from_owner`; require exact CA pathLen/KeyUsage and exact leaf usages; bind invitation domain, Authority endpoint, Root fingerprint, Authority CA fingerprint and live PKI fingerprint before reservation; require canonical DER and a non-critical unique Node ID extension; and test a structurally valid CSR whose signature bit is changed. +- **Prevention**: Treat bearer ownership and every serialization allocation as part of the secret lifecycle; enumerate configured trust values at the authorization boundary; test certificate policy with real positive and negative DER/TLS handshakes; and distinguish parser rejection from cryptographic verification rejection. +- **Retry boundary**: Because enrollment consumes the in-memory handoff, a caller that must retry after uncertain delivery has to reacquire the same invitation from its secure external handoff source while reusing the already persisted operation identity and local keys. No clone or raw-secret recovery API was added. +- **Memory boundary**: AgenNet zeroizes its `SecretString`, exact request buffers, and project-owned Reqwest body owner. Hyper, Rustls, kernel socket buffers, TLS record buffers, and remote peer memory remain outside this guarantee. + ## 2026-08-14 23:55 CST - **Change**: Implemented provisional v0.2 enrollment with exact-byte Ed25519 request proof, locally verified handoff claims, fingerprint-pinned WebPKI TLS, Authority-issued credentials and CSR certificates, and exact durable lost-response recovery. diff --git a/src/bootstrap/enrollment.rs b/src/bootstrap/enrollment.rs index f40c868..9a6e26f 100644 --- a/src/bootstrap/enrollment.rs +++ b/src/bootstrap/enrollment.rs @@ -338,12 +338,29 @@ impl EnrollmentAuthority { { return Err(EnrollmentError::PolicyRejected); } + self.verify_authority_binding(&request.public_claims)?; Ok(VerifiedEnrollmentRequest { request_digest: request.exact_request_digest()?, claims, }) } + fn verify_authority_binding( + &self, + invitation: &InvitationPublicClaims, + ) -> Result<(), EnrollmentError> { + let authority = &self.authority_credential.claims; + if invitation.domain_id != authority.domain_id + || invitation.authority_endpoint != self.authority_endpoint + || invitation.root_sha256 != fingerprint_bytes(self.root_public_key.as_bytes()) + || invitation.tls_ca_sha256 != authority.tls_ca_sha256 + || invitation.tls_ca_sha256 != self.pki.fingerprint_sha256 + { + return Err(EnrollmentError::PolicyRejected); + } + Ok(()) + } + fn recover_result( &self, request: &EnrollmentWireRequest, @@ -736,7 +753,9 @@ fn validate_leaf_csr_and_node( let [node_extension] = node_extensions.as_slice() else { return Err(EnrollmentError::CertificateRejected); }; - if decode_der_utf8(node_extension.value)? != attempt.node_id.as_str().as_bytes() { + if node_extension.critical + || decode_der_utf8(node_extension.value)? != attempt.node_id.as_str().as_bytes() + { return Err(EnrollmentError::CertificateRejected); } Ok(()) @@ -767,8 +786,8 @@ fn validate_returned_ca( .map_err(|_| EnrollmentError::CertificateRejected)? .ok_or(EnrollmentError::CertificateRejected)?; if !basic.value.ca - || !usage.value.key_cert_sign() - || !usage.value.crl_sign() + || basic.value.path_len_constraint != Some(0) + || usage.value.flags != 0x60 || ca.subject() != ca.issuer() || now < ca.validity().not_before.timestamp() || now > ca.validity().not_after.timestamp() @@ -799,7 +818,8 @@ fn validate_returned_leaf( .map_err(|_| EnrollmentError::CertificateRejected)? .is_some(); if basic.value.ca - || !usage.value.digital_signature() + || basic.value.path_len_constraint.is_some() + || usage.value.flags != 1 || !extended.value.client_auth || extended.value.any || extended.value.server_auth @@ -816,9 +836,13 @@ fn decode_der_utf8(encoded: &[u8]) -> Result<&[u8], EnrollmentError> { } let (header, length) = match encoded[1] { value @ 0..=127 => (2, usize::from(value)), - 0x81 if encoded.len() >= 3 => (3, usize::from(encoded[2])), + 0x81 if encoded.len() >= 3 && encoded[2] >= 128 => (3, usize::from(encoded[2])), 0x82 if encoded.len() >= 4 => { - (4, usize::from(u16::from_be_bytes([encoded[2], encoded[3]]))) + let length = usize::from(u16::from_be_bytes([encoded[2], encoded[3]])); + if encoded[2] == 0 || length <= 255 { + return Err(EnrollmentError::CertificateRejected); + } + (4, length) } _ => return Err(EnrollmentError::CertificateRejected), }; @@ -1153,4 +1177,62 @@ mod tests { .expect("chain"); assert_eq!(verified.node_id, fixture.attempt.node_id); } + + #[test] + fn authority_binding_mismatch_fails_before_invitation_reservation() { + for case in 0_u8..5 { + let mut fixture = fixture(Uuid::from_u128(105 + u128::from(case))); + match case { + 0 => { + fixture.authority.authority_endpoint = + Url::parse("https://127.0.0.1:9444/").expect("endpoint") + } + 1 => { + fixture.authority.authority_credential.claims.domain_id = + DomainId::new("different-domain").expect("domain") + } + 2 => { + fixture.authority.root_public_key = + SigningKey::from_bytes(&[99; 32]).verifying_key() + } + 3 => fixture.authority.authority_credential.claims.tls_ca_sha256 = "11".repeat(32), + 4 => fixture.authority.pki.fingerprint_sha256 = "22".repeat(32), + _ => unreachable!(), + } + assert_binding_rejected_without_side_effects(&fixture); + } + } + + fn assert_binding_rejected_without_side_effects(fixture: &Fixture) { + let request = + EnrollmentWireRequest::from_local(&fixture.handoff, &fixture.attempt).expect("request"); + assert_eq!( + fixture.authority.process(&request, NOW_MS), + Err(EnrollmentError::PolicyRejected) + ); + let record = fixture + .invitations + .record(fixture.handoff.public_claims().invitation_id) + .expect("record") + .expect("present"); + assert_eq!(record.state, super::super::InvitationState::Available); + assert_eq!( + std::fs::read_dir(fixture.state.path().join("results")) + .expect("results") + .count(), + 0 + ); + } + + #[test] + fn node_id_der_rejects_noncanonical_long_form_lengths() { + assert_eq!( + decode_der_utf8(&[0x0c, 0x81, 0x01, b'a']), + Err(EnrollmentError::CertificateRejected) + ); + assert_eq!( + decode_der_utf8(&[0x0c, 0x82, 0x00, 0x80]), + Err(EnrollmentError::CertificateRejected) + ); + } } diff --git a/src/bootstrap/invitation.rs b/src/bootstrap/invitation.rs index a9fc7fb..f9cf9a1 100644 --- a/src/bootstrap/invitation.rs +++ b/src/bootstrap/invitation.rs @@ -117,6 +117,8 @@ pub enum InvitationState { pub struct InvitationHandoff { public_claims: InvitationPublicClaims, authentication: InvitationAuthentication, + #[cfg(test)] + drop_audit: Option>, } impl InvitationHandoff { @@ -128,6 +130,20 @@ impl InvitationHandoff { pub fn authentication(&self) -> &InvitationAuthentication { &self.authentication } + + #[cfg(test)] + fn install_drop_audit(&mut self, counter: std::sync::Arc) { + self.drop_audit = Some(counter); + } +} + +#[cfg(test)] +impl Drop for InvitationHandoff { + fn drop(&mut self) { + if let Some(counter) = &self.drop_audit { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } } /// Opaque invitation bearer proof. Its secret is never publicly exposed. @@ -306,6 +322,8 @@ fn decode_handoff(encoded: &[u8]) -> Result { secret, claims_integrity_hmac_sha256, }, + #[cfg(test)] + drop_audit: None, }) } @@ -482,6 +500,8 @@ impl InvitationStore { secret, claims_integrity_hmac_sha256, }, + #[cfg(test)] + drop_audit: None, }) } @@ -1414,9 +1434,31 @@ mod tests { secret, claims_integrity_hmac_sha256, }, + drop_audit: None, } } + #[test] + fn handoff_drop_boundary_runs_once_with_secret_owned_inside() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + let drops = Arc::new(AtomicUsize::new(0)); + let mut handoff = handoff(Uuid::from_u128(900), "drop-audit-domain"); + handoff.install_drop_audit(Arc::clone(&drops)); + assert!( + !handoff + .authentication() + .secret_for_request() + .expose_secret() + .is_empty() + ); + drop(handoff); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + #[test] fn deserialized_secret_wrapper_redacts_and_zeroizes() { let mut secret = serde_json::from_str::("\"sensitive-test-value\"") diff --git a/src/transport/enrollment.rs b/src/transport/enrollment.rs index 34107eb..d3fb2ea 100644 --- a/src/transport/enrollment.rs +++ b/src/transport/enrollment.rs @@ -10,6 +10,7 @@ use axum::{ http::StatusCode, routing::{get, post}, }; +use bytes::Bytes; use reqwest::{Client, Url, redirect::Policy}; use rustls::{ ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme, @@ -19,7 +20,7 @@ use rustls::{ use serde::Serialize; use sha2::{Digest, Sha256}; use x509_parser::prelude::{FromDer, X509Certificate}; -use zeroize::Zeroizing; +use zeroize::{Zeroize, Zeroizing}; use super::MAX_JSON_BODY_BYTES; use crate::{ @@ -103,7 +104,7 @@ impl EnrollmentClient { pub async fn enroll( &self, - handoff: &InvitationHandoff, + handoff: InvitationHandoff, attempt: &EnrollmentAttempt, ) -> Result { if self.endpoint != handoff.public_claims().authority_endpoint { @@ -112,8 +113,8 @@ impl EnrollmentClient { if self.pinned_ca_sha256 != handoff.public_claims().tls_ca_sha256 { return Err(EnrollmentError::InvalidRequest); } - let request = crate::bootstrap::EnrollmentWireRequest::from_local(handoff, attempt)?; - let body = Zeroizing::new( + let request = crate::bootstrap::EnrollmentWireRequest::from_local(&handoff, attempt)?; + let body = EnrollmentBodyOwner::new( serde_json::to_vec(&request).map_err(|_| EnrollmentError::InvalidRequest)?, ); if body.len() > MAX_JSON_BODY_BYTES { @@ -123,13 +124,13 @@ impl EnrollmentClient { if wire.format_version != "agenet.enrollment-wire.v0.2" { return Err(EnrollmentError::InvalidRequest); } - EnrollmentHandoffValidation::validate(handoff, attempt, &wire.bundle, current_time_ms())?; + EnrollmentHandoffValidation::validate(&handoff, attempt, &wire.bundle, current_time_ms())?; Ok(wire.bundle) } async fn send_enrollment( &self, - body: Zeroizing>, + body: EnrollmentBodyOwner, ) -> Result { let url = self .endpoint @@ -139,7 +140,7 @@ impl EnrollmentClient { .client .post(url) .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body.as_slice().to_vec()) + .body(Bytes::from_owner(body)) .send() .await .map_err(|_| EnrollmentError::TransportFailed)?; @@ -151,6 +152,50 @@ impl EnrollmentClient { } } +struct EnrollmentBodyOwner { + bytes: Zeroizing>, + #[cfg(test)] + drop_counter: Option>, +} + +impl EnrollmentBodyOwner { + fn new(bytes: Vec) -> Self { + Self { + bytes: Zeroizing::new(bytes), + #[cfg(test)] + drop_counter: None, + } + } + + fn len(&self) -> usize { + self.bytes.len() + } + + #[cfg(test)] + fn with_drop_counter(bytes: Vec, counter: Arc) -> Self { + Self { + bytes: Zeroizing::new(bytes), + drop_counter: Some(counter), + } + } +} + +impl AsRef<[u8]> for EnrollmentBodyOwner { + fn as_ref(&self) -> &[u8] { + self.bytes.as_slice() + } +} + +impl Drop for EnrollmentBodyOwner { + fn drop(&mut self) { + self.bytes.zeroize(); + #[cfg(test)] + if let Some(counter) = &self.drop_counter { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } +} + async fn read_bounded_response( response: &mut reqwest::Response, ) -> Result, EnrollmentError> { @@ -344,11 +389,23 @@ fn validate_ca_certificate( rustls::CertificateError::Expired, )); } - let is_ca = parsed + let constraints = parsed .basic_constraints() .map_err(|_| rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding))? - .is_some_and(|constraints| constraints.value.ca); - if !is_ca || parsed.verify_signature(Some(parsed.public_key())).is_err() { + .ok_or_else(|| { + rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer) + })?; + let usage = parsed + .key_usage() + .map_err(|_| rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding))? + .ok_or_else(|| { + rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer) + })?; + if !constraints.value.ca + || constraints.value.path_len_constraint != Some(0) + || usage.value.flags != 0x60 + || parsed.verify_signature(Some(parsed.public_key())).is_err() + { return Err(rustls::Error::InvalidCertificate( rustls::CertificateError::UnknownIssuer, )); @@ -371,3 +428,85 @@ impl std::fmt::Display for EnrollmentTransportError { } impl std::error::Error for EnrollmentTransportError {} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + use std::time::Duration; + + use axum::{Router, http::StatusCode, routing::post}; + use bytes::Bytes; + + use super::EnrollmentBodyOwner; + + #[test] + fn sensitive_body_is_zeroized_only_after_the_last_bytes_clone_drops() { + let drops = Arc::new(AtomicUsize::new(0)); + let owner = EnrollmentBodyOwner::with_drop_counter(vec![7_u8; 32], Arc::clone(&drops)); + let body = Bytes::from_owner(owner); + let request_clone = body.clone(); + drop(body); + assert_eq!(drops.load(Ordering::SeqCst), 0); + drop(request_clone); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn reqwest_drops_sensitive_body_on_all_response_and_cancel_paths() { + let router = Router::new() + .route("/success", post(|| async { StatusCode::OK })) + .route("/failure", post(|| async { StatusCode::BAD_REQUEST })) + .route("/slow", post(slow_response)); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("listener"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + let server = + tokio::spawn(async move { axum::serve(listener, router).await.expect("server") }); + assert_body_dropped_after_send(&endpoint, "/success", Duration::from_secs(1)).await; + assert_body_dropped_after_send(&endpoint, "/failure", Duration::from_secs(1)).await; + assert_body_dropped_after_send(&endpoint, "/slow", Duration::from_millis(10)).await; + assert_body_dropped_after_cancel(&endpoint).await; + server.abort(); + } + + async fn slow_response() -> StatusCode { + tokio::time::sleep(Duration::from_secs(1)).await; + StatusCode::OK + } + + fn audited_body() -> (Bytes, Arc) { + let drops = Arc::new(AtomicUsize::new(0)); + let owner = EnrollmentBodyOwner::with_drop_counter(vec![7_u8; 32], Arc::clone(&drops)); + (Bytes::from_owner(owner), drops) + } + + async fn assert_body_dropped_after_send(endpoint: &str, path: &str, timeout: Duration) { + let (body, drops) = audited_body(); + let client = reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("client"); + let _result = client + .post(format!("{endpoint}{path}")) + .body(body) + .send() + .await; + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + + async fn assert_body_dropped_after_cancel(endpoint: &str) { + let (body, drops) = audited_body(); + let client = reqwest::Client::new(); + let mut request = Box::pin(client.post(format!("{endpoint}/slow")).body(body).send()); + tokio::select! { + _ = request.as_mut() => panic!("slow response completed before cancellation"), + _ = tokio::time::sleep(Duration::from_millis(10)) => {} + } + drop(request); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } +} diff --git a/tests/http_enrollment.rs b/tests/http_enrollment.rs index a03242a..4b57c9c 100644 --- a/tests/http_enrollment.rs +++ b/tests/http_enrollment.rs @@ -17,6 +17,10 @@ use axum_server::{Handle, tls_rustls::RustlsConfig}; use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::SigningKey; use ipnet::IpNet; +use rcgen::{ + BasicConstraints, CertificateParams, CertifiedIssuer, ExtendedKeyUsagePurpose, IsCa, KeyPair, + KeyUsagePurpose, SanType, +}; use reqwest::Url; use sha2::{Digest, Sha256}; use std::{ @@ -29,6 +33,7 @@ use std::{ use tempfile::TempDir; use tokio::task::JoinHandle; use uuid::Uuid; +use x509_parser::prelude::FromDer; struct TestIdentity { chain: String, @@ -43,8 +48,7 @@ struct HttpEnrollmentFixture { authority: Arc, handoff: InvitationHandoff, attempt: EnrollmentAttempt, - expired_ca_handoff: InvitationHandoff, - expired_ca_attempt: EnrollmentAttempt, + expired_ca_pin: String, endpoint: Url, port: u16, ca_pem: String, @@ -110,17 +114,8 @@ impl HttpEnrollmentFixture { &pki.fingerprint_sha256, now_ms, ); - let expired_ca_handoff = create_handoff( - &invitations, - &credential, - &root, - &endpoint, - &expired_pki.fingerprint_sha256, - now_ms, - ); let attempt = signed_attempt(&handoff, 44, "node-http-enrollment", &[3_u8; 32]); - let expired_ca_attempt = - signed_attempt(&expired_ca_handoff, 45, "node-expired-ca", &[3_u8; 32]); + let expired_ca_pin = expired_pki.fingerprint_sha256.clone(); let authority = Arc::new( EnrollmentAuthority::open( &state.path().join("results"), @@ -141,8 +136,7 @@ impl HttpEnrollmentFixture { authority, handoff, attempt, - expired_ca_handoff, - expired_ca_attempt, + expired_ca_pin, endpoint, port, ca_pem, @@ -192,22 +186,15 @@ impl RunningServer { } #[tokio::test] -async fn enrollment_issues_and_recovers_the_exact_durable_bundle() { +async fn enrollment_consumes_handoff_and_issues_a_valid_bundle() { let fixture = HttpEnrollmentFixture::new(); let server = fixture.start_valid().await; let client = fixture.client(Duration::from_secs(10)); + let invitation_expiry = fixture.handoff.public_claims().expires_at_ms; let first = client - .enroll(&fixture.handoff, &fixture.attempt) + .enroll(fixture.handoff, &fixture.attempt) .await .expect("enrollment"); - let recovered = client - .enroll(&fixture.handoff, &fixture.attempt) - .await - .expect("recovery"); - assert_eq!( - serde_json::to_vec(&first).unwrap(), - serde_json::to_vec(&recovered).unwrap() - ); let verified = verify_credential_chain( &fixture.root.verifying_key(), &first.credential_chain, @@ -216,34 +203,27 @@ async fn enrollment_issues_and_recovers_the_exact_durable_bundle() { current_second_ms(), ) .expect("credential chain"); - assert!(verified.expires_at_ms > fixture.handoff.public_claims().expires_at_ms); + assert!(verified.expires_at_ms > invitation_expiry); server.stop().await; } #[tokio::test] -async fn enrollment_rejects_invalid_csr_changed_operation_and_reuse() { +async fn enrollment_rejects_invalid_csr_without_consuming_bearer_twice() { let fixture = HttpEnrollmentFixture::new(); let server = fixture.start_valid().await; let invalid_handoff = fixture.new_handoff(current_second_ms()); + let invalid_csr = tampered_csr_signature(); let invalid = EnrollmentAttempt::sign( &invalid_handoff, Uuid::from_u128(46), NodeId::new("node-invalid-csr").unwrap(), BootstrapProfile::Provider, &SigningKey::from_bytes(&[3; 32]), - "-----BEGIN CERTIFICATE REQUEST-----\nAAAA\n-----END CERTIFICATE REQUEST-----\n".to_owned(), + invalid_csr, ) .expect("signed invalid CSR"); let client = fixture.client(Duration::from_secs(10)); - assert!(client.enroll(&invalid_handoff, &invalid).await.is_err()); - client - .enroll(&fixture.handoff, &fixture.attempt) - .await - .expect("initial issue"); - let changed = signed_attempt(&fixture.handoff, 44, "node-changed-request", &[4; 32]); - let reused = signed_attempt(&fixture.handoff, 47, "node-reused-invitation", &[3; 32]); - assert!(client.enroll(&fixture.handoff, &changed).await.is_err()); - assert!(client.enroll(&fixture.handoff, &reused).await.is_err()); + assert!(client.enroll(invalid_handoff, &invalid).await.is_err()); server.stop().await; } @@ -259,43 +239,52 @@ async fn enrollment_tls_rejects_wrong_pin_ip_chain_and_validity() { ) .expect("syntactically valid client"); let server = fixture.start_valid().await; + let wrong_pin_handoff = fixture.new_handoff(current_second_ms()); + let wrong_pin_attempt = signed_attempt(&wrong_pin_handoff, 50, "node-wrong-pin", &[7; 32]); assert!( wrong_pin - .enroll(&fixture.handoff, &fixture.attempt) + .enroll(wrong_pin_handoff, &wrong_pin_attempt) .await .is_err() ); server.stop().await; - assert_tls_rejected( - &fixture, - &fixture.wrong_ip, - &fixture.handoff, - &fixture.attempt, - ) - .await; - assert_tls_rejected( - &fixture, - &fixture.wrong_chain, - &fixture.handoff, - &fixture.attempt, - ) - .await; - assert_tls_rejected( - &fixture, - &fixture.expired_leaf, - &fixture.handoff, - &fixture.attempt, - ) - .await; + assert_tls_rejected_with_new_handoff(&fixture, &fixture.wrong_ip, 51).await; + assert_tls_rejected_with_new_handoff(&fixture, &fixture.wrong_chain, 52).await; + assert_tls_rejected_with_new_handoff(&fixture, &fixture.expired_leaf, 53).await; + let expired_handoff = create_handoff( + &fixture.invitations, + &fixture.credential, + &fixture.root, + &fixture.endpoint, + &fixture.expired_ca_pin, + current_second_ms(), + ); + let expired_attempt = signed_attempt(&expired_handoff, 54, "node-expired-ca", &[10; 32]); assert_tls_rejected( &fixture, &fixture.expired_ca, - &fixture.expired_ca_handoff, - &fixture.expired_ca_attempt, + expired_handoff, + &expired_attempt, ) .await; } +#[tokio::test] +async fn enrollment_tls_rejects_a_pinned_ca_without_key_cert_sign() { + let fixture = HttpEnrollmentFixture::new(); + let (identity, pin) = identity_with_ca_missing_key_usage(); + let handoff = create_handoff( + &fixture.invitations, + &fixture.credential, + &fixture.root, + &fixture.endpoint, + &pin, + current_second_ms(), + ); + let attempt = signed_attempt(&handoff, 55, "node-bad-ca-usage", &[11; 32]); + assert_tls_rejected(&fixture, &identity, handoff, &attempt).await; +} + #[tokio::test] async fn enrollment_http_rejects_redirect_oversize_status_and_timeout() { let fixture = HttpEnrollmentFixture::new(); @@ -394,7 +383,7 @@ async fn enrollment_ignores_system_proxy_configuration() { drop(guard); let server = fixture.start_valid().await; client - .enroll(&fixture.handoff, &fixture.attempt) + .enroll(fixture.handoff, &fixture.attempt) .await .expect("direct enrollment"); assert_eq!( @@ -410,7 +399,7 @@ async fn durable_results_are_owner_only_and_do_not_store_private_keys() { let server = fixture.start_valid().await; fixture .client(Duration::from_secs(10)) - .enroll(&fixture.handoff, &fixture.attempt) + .enroll(fixture.handoff, &fixture.attempt) .await .unwrap(); let directory = fixture.state.path().join("results"); @@ -538,6 +527,18 @@ fn signed_attempt( .expect("attempt") } +fn tampered_csr_signature() -> String { + let csr = NodeTlsCsr::generate().expect("CSR"); + let parsed = pem::parse(csr.csr_pem).expect("parse generated CSR"); + let mut der = parsed.contents().to_vec(); + let last = der.last_mut().expect("CSR signature byte"); + *last ^= 1; + let (_, request) = x509_parser::certification_request::X509CertificationRequest::from_der(&der) + .expect("tampered CSR remains structurally parseable"); + assert!(request.verify_signature().is_err()); + pem::encode(&pem::Pem::new("CERTIFICATE REQUEST", der)) +} + fn identity( pki: &AuthorityPki, presented_ca_pem: &str, @@ -554,6 +555,35 @@ fn identity( } } +fn identity_with_ca_missing_key_usage() -> (TestIdentity, String) { + let now = time::OffsetDateTime::now_utc(); + let ca_key = KeyPair::generate().expect("CA key"); + let mut ca = CertificateParams::default(); + ca.not_before = now - time::Duration::minutes(1); + ca.not_after = now + time::Duration::hours(1); + ca.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + ca.key_usages = Vec::new(); + let issuer = CertifiedIssuer::self_signed(ca, ca_key).expect("CA without key usage"); + let leaf_key = KeyPair::generate().expect("leaf key"); + let private_key = leaf_key.serialize_pem().into_bytes(); + let mut leaf = CertificateParams::default(); + leaf.not_before = now - time::Duration::minutes(1); + leaf.not_after = now + time::Duration::minutes(10); + leaf.subject_alt_names = vec![SanType::IpAddress("127.0.0.1".parse().unwrap())]; + leaf.is_ca = IsCa::ExplicitNoCa; + leaf.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + leaf.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let cert = leaf.signed_by(&leaf_key, &issuer).expect("leaf"); + let pin = AuthorityPki::fingerprint_der(issuer.der()); + ( + TestIdentity { + chain: format!("{}{}", cert.pem(), issuer.pem()), + key: private_key, + }, + pin, + ) +} + async fn start_server(port: u16, identity: &TestIdentity, router: Router) -> RunningServer { let tls = RustlsConfig::from_pem(identity.chain.as_bytes().to_vec(), identity.key.clone()) .await @@ -574,7 +604,7 @@ async fn start_server(port: u16, identity: &TestIdentity, router: Router) -> Run async fn assert_tls_rejected( fixture: &HttpEnrollmentFixture, identity: &TestIdentity, - handoff: &InvitationHandoff, + handoff: InvitationHandoff, attempt: &EnrollmentAttempt, ) { let server = fixture.start_authority(identity).await; @@ -592,16 +622,33 @@ async fn assert_tls_rejected( async fn assert_http_rejected(fixture: &HttpEnrollmentFixture, router: Router, timeout: Duration) { let server = start_server(fixture.port, &fixture.valid, router).await; + let handoff = fixture.new_handoff(current_second_ms()); + let attempt = signed_attempt( + &handoff, + Uuid::new_v4().as_u128(), + "node-http-error", + &[8; 32], + ); assert!( fixture .client(timeout) - .enroll(&fixture.handoff, &fixture.attempt) + .enroll(handoff, &attempt) .await .is_err() ); server.stop().await; } +async fn assert_tls_rejected_with_new_handoff( + fixture: &HttpEnrollmentFixture, + identity: &TestIdentity, + operation: u128, +) { + let handoff = fixture.new_handoff(current_second_ms()); + let attempt = signed_attempt(&handoff, operation, "node-tls-rejected", &[9; 32]); + assert_tls_rejected(fixture, identity, handoff, &attempt).await; +} + fn loopback_boundary() -> NetworkBoundary { NetworkBoundary { kind: OverlayKind::Loopback, From d1facfa9962e666ea0a74fd6ca6682a4952ac70b Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 20:40:29 +0800 Subject: [PATCH 20/67] [bug] Prevent bearer serialization reallocation Root cause: Serde serialized the bearer into a growable ordinary Vec before the final allocation gained zeroizing ownership. Solution: Serialize through one fixed-capacity zeroizing writer shared by HTTP transmission and exact-request digesting. Risks: Each in-flight enrollment reserves 256 KiB of userspace capacity. Dependency: Bootstrap step 6 commit a05bf26. Links: plan/01-v1-multi-host-node-bootstrap.md Post-mortem: Secret-memory reviews must audit allocation history, not only the final buffer owner. --- ROADMAP.md | 9 ++++ src/bootstrap/enrollment.rs | 95 +++++++++++++++++++++++++++++++++++-- src/bootstrap/mod.rs | 4 +- src/transport/enrollment.rs | 9 ++-- 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a03329d..0df8456 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 01:18 CST + +- **Change**: Replaced enrollment request `serde_json::to_vec` serialization with one fixed-capacity zeroizing writer shared by HTTP transmission and exact-request digesting. +- **Files**: `src/bootstrap/enrollment.rs`, `src/bootstrap/mod.rs`, `src/transport/enrollment.rs`, `ROADMAP.md`, and ignored Task 6 report/evidence. +- **Root cause**: Security implementation gap — wrapping the final `Vec` in `Zeroizing` did not protect earlier allocations released by `Vec` growth. A real request characterization showed capacity growing from 0 to 2048 while serializing the bearer. +- **Solution**: Preallocate the complete 256 KiB enrollment request limit inside `Zeroizing>`; route Serde through a custom `Write` implementation that checks `current_len + incoming_len` before every append and fails before mutation when the bound would be exceeded. Move that same zeroizing allocation directly into `Bytes::from_owner`, and compute the durable exact-request digest from the same helper. +- **Prevention**: For secret serialization, audit allocation history rather than only the final owner. A security limit must be enforced before growth, not checked after growth, and all consumers of the same secret wire representation must share one serialization primitive. +- **Resource boundary**: Each enrollment request temporarily reserves 256 KiB of userspace capacity so successful and rejected serialization never reallocates. The allocation is bounded and released/zeroized at request completion; third-party and kernel buffers remain outside the project guarantee. + ## 2026-08-15 00:48 CST - **Change**: Corrected Task 6 enrollment bearer ownership, request-body zeroization, pre-reservation Authority binding, CA policy validation, Node ID DER canonicality, and CSR negative coverage after security review. diff --git a/src/bootstrap/enrollment.rs b/src/bootstrap/enrollment.rs index 9a6e26f..d51887e 100644 --- a/src/bootstrap/enrollment.rs +++ b/src/bootstrap/enrollment.rs @@ -1,7 +1,7 @@ use std::{ fmt::{Debug, Formatter}, fs::{self, DirBuilder, File, OpenOptions}, - io::Read, + io::{Read, Write}, os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}, path::{Path, PathBuf}, sync::{Arc, Mutex}, @@ -34,6 +34,7 @@ use super::{ const ENROLLMENT_WIRE_VERSION: &str = "agenet.enrollment-wire.v0.2"; const MAX_RESULT_BYTES: usize = 256 * 1024; +pub(crate) const MAX_ENROLLMENT_REQUEST_BYTES: usize = 256 * 1024; #[derive(Clone, PartialEq, Eq)] pub struct EnrollmentAttempt { @@ -192,12 +193,73 @@ impl EnrollmentWireRequest { } fn exact_request_digest(&self) -> Result<[u8; 32], EnrollmentError> { - let encoded = - Zeroizing::new(serde_json::to_vec(self).map_err(|_| EnrollmentError::InvalidRequest)?); + let encoded = serialize_enrollment_request(self)?; Ok(Sha256::digest(encoded).into()) } } +pub(crate) fn serialize_enrollment_request( + request: &EnrollmentWireRequest, +) -> Result>, EnrollmentError> { + serialize_enrollment_request_bounded(request, MAX_ENROLLMENT_REQUEST_BYTES) +} + +fn serialize_enrollment_request_bounded( + request: &EnrollmentWireRequest, + limit: usize, +) -> Result>, EnrollmentError> { + let (encoded, result) = attempt_bounded_enrollment_serialization(request, limit); + result?; + Ok(encoded) +} + +fn attempt_bounded_enrollment_serialization( + request: &EnrollmentWireRequest, + limit: usize, +) -> (Zeroizing>, Result<(), EnrollmentError>) { + let mut encoded = Zeroizing::new(Vec::with_capacity(limit)); + let result = serde_json::to_writer( + BoundedEnrollmentWriter { + encoded: &mut encoded, + limit, + }, + request, + ) + .map_err(|_| EnrollmentError::InvalidRequest); + (encoded, result) +} + +struct BoundedEnrollmentWriter<'a> { + encoded: &'a mut Zeroizing>, + limit: usize, +} + +impl Write for BoundedEnrollmentWriter<'_> { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + let next_len = self + .encoded + .len() + .checked_add(bytes.len()) + .ok_or_else(capacity_error)?; + if next_len > self.limit { + return Err(capacity_error()); + } + self.encoded.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn capacity_error() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::WriteZero, + "enrollment request exceeds limit", + ) +} + pub(crate) struct SecretWireText(String); impl Drop for SecretWireText { @@ -1235,4 +1297,31 @@ mod tests { Err(EnrollmentError::CertificateRejected) ); } + + #[test] + fn enrollment_request_serialization_never_reallocates_secret_storage() { + let fixture = fixture(Uuid::from_u128(110)); + let request = + EnrollmentWireRequest::from_local(&fixture.handoff, &fixture.attempt).expect("request"); + let sentinel = fixture + .handoff + .authentication() + .secret_for_request() + .expose_secret(); + let encoded = serialize_enrollment_request(&request).expect("bounded serialization"); + assert!( + encoded + .windows(sentinel.len()) + .any(|window| window == sentinel.as_bytes()) + ); + assert_eq!(encoded.capacity(), MAX_ENROLLMENT_REQUEST_BYTES); + let expected_digest: [u8; 32] = Sha256::digest(&*encoded).into(); + assert_eq!( + request.exact_request_digest().expect("digest"), + expected_digest + ); + let (partial, result) = attempt_bounded_enrollment_serialization(&request, 1); + assert_eq!(result, Err(EnrollmentError::InvalidRequest)); + assert_eq!(partial.capacity(), 1); + } } diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 0968452..2c5a74e 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -12,7 +12,9 @@ use std::fmt::{Display, Formatter}; pub use enrollment::{ EnrollmentAttempt, EnrollmentAuthority, EnrollmentError, EnrollmentHandoffValidation, }; -pub(crate) use enrollment::{EnrollmentWireRequest, EnrollmentWireResponse}; +pub(crate) use enrollment::{ + EnrollmentWireRequest, EnrollmentWireResponse, serialize_enrollment_request, +}; pub use invitation::{ ConsumptionResult, InvitationAuthentication, InvitationHandoff, InvitationPublicClaims, InvitationRecord, InvitationSpec, InvitationState, InvitationStore, ReservationStatus, diff --git a/src/transport/enrollment.rs b/src/transport/enrollment.rs index d3fb2ea..b662f09 100644 --- a/src/transport/enrollment.rs +++ b/src/transport/enrollment.rs @@ -27,6 +27,7 @@ use crate::{ bootstrap::{ EnrollmentAttempt, EnrollmentAuthority, EnrollmentError, EnrollmentHandoffValidation, InvitationHandoff, IssuedServerIdentity, network::NetworkBoundary, + serialize_enrollment_request, }, protocol::EnrollmentBundle, }; @@ -114,9 +115,7 @@ impl EnrollmentClient { return Err(EnrollmentError::InvalidRequest); } let request = crate::bootstrap::EnrollmentWireRequest::from_local(&handoff, attempt)?; - let body = EnrollmentBodyOwner::new( - serde_json::to_vec(&request).map_err(|_| EnrollmentError::InvalidRequest)?, - ); + let body = EnrollmentBodyOwner::new(serialize_enrollment_request(&request)?); if body.len() > MAX_JSON_BODY_BYTES { return Err(EnrollmentError::InvalidRequest); } @@ -159,9 +158,9 @@ struct EnrollmentBodyOwner { } impl EnrollmentBodyOwner { - fn new(bytes: Vec) -> Self { + fn new(bytes: Zeroizing>) -> Self { Self { - bytes: Zeroizing::new(bytes), + bytes, #[cfg(test)] drop_counter: None, } From c336e0d60fa50923d860a69cfe146c43da6b47a2 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 21:29:24 +0800 Subject: [PATCH 21/67] [feat][Bootstrap][7/14] Enforce signed revocation Root cause: NA Solution: Distribute monotonic signed revocation snapshots and fail closed on stale policy for effectful peer operations. Risks: Authority downtime pauses work after snapshot freshness ends. Dependency: Bootstrap step 6 at d1facfa. Links: plan/01-v1-multi-host-node-bootstrap.md --- Cargo.toml | 2 +- ROADMAP.md | 12 + src/protocol/authority.rs | 3 +- src/protocol/error.rs | 6 + src/protocol/mod.rs | 6 +- src/protocol/revocation.rs | 166 ++++++++++++ src/protocol/types.rs | 2 +- src/runtime/error.rs | 6 + src/runtime/mod.rs | 2 + src/runtime/revocation.rs | 404 ++++++++++++++++++++++++++++ src/transport/directory.rs | 65 ++++- src/transport/mod.rs | 4 +- src/transport/revocation.rs | 269 +++++++++++++++++++ tests/http_revocation.rs | 522 ++++++++++++++++++++++++++++++++++++ tests/revocation.rs | 465 ++++++++++++++++++++++++++++++++ 15 files changed, 1924 insertions(+), 10 deletions(-) create mode 100644 src/protocol/revocation.rs create mode 100644 src/runtime/revocation.rs create mode 100644 src/transport/revocation.rs create mode 100644 tests/http_revocation.rs create mode 100644 tests/revocation.rs diff --git a/Cargo.toml b/Cargo.toml index 8eb1bbc..a09e4c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" sha2 = "=0.11.0" time = "=0.3.55" -tokio = { version = "=1.53.1", features = ["full"] } +tokio = { version = "=1.53.1", features = ["full", "test-util"] } tracing = "=0.1.44" tracing-subscriber = { version = "=0.3.20", features = ["env-filter", "fmt"] } uuid = { version = "=1.24.0", features = ["serde", "v4"] } diff --git a/ROADMAP.md b/ROADMAP.md index 0df8456..73c163f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,17 @@ # ROADMAP +## 2026-08-15 04:20 CST + +- **Change**: Added provisional v0.2 signed revocation snapshots, monotonic owner-only Authority/cache persistence, stale-first policy guards, a signed snapshot endpoint, and a resilient no-proxy refresh loop. +- **Files**: `src/protocol/revocation.rs`, `src/runtime/revocation.rs`, `src/transport/revocation.rs`, adjacent Authority/error/module/Directory boundaries, `tests/revocation.rs`, `tests/http_revocation.rs`, `Cargo.toml`, this roadmap, and ignored Task 7 brief/report/evidence. +- **Decision**: The online Authority uses a dedicated `PublishRevocationSnapshot` scope and exact-byte Ed25519 proof. Epochs start at one; exact replay is idempotent; changed same-epoch data and rollback fail. Freshness expires at `now >= next_update_ms`, so stale data never authorizes effectful work. A snapshot may revoke its issuer once and then freezes that issuer against every higher epoch. +- **Persistence**: Authority epoch and node cache formats are bounded and versioned, use 0700 directories and 0600 no-follow regular files, publish atomically with file and directory sync, and re-verify persisted signatures plus stable publisher binding on restart. Same-key Root credential renewal is accepted; key/issuer rollover requires a future explicit migration. +- **HTTP behavior**: Directory registration is guarded only after signed-envelope verification; route query, health and snapshot refresh stay available. The refresh client disables proxies and redirects, bounds responses, retains the last verified cache through transient failures, reports a typed last error, and retries until watch-based shutdown. +- **Error record**: Technical blind spot — the first paused-time refresh test mixed real sockets with Tokio's auto-advanced timeout clock, then an intermediate test server let accepted sockets inherit nonblocking mode and used arbitrary wall-clock polling for assertions. Under full-suite load this produced a false `RequestFailed`. The scheduler is now tested with a pure async closure under paused time, real 503-to-200 recovery uses normal time and `watch` diagnostic events, accepted streams are explicitly blocking, and the only remaining one-millisecond poll is a bounded server `accept` loop rather than state synchronization. +- **Prevention**: Never combine paused Tokio time with external socket progress. Use event channels for async state assertions, separate pure scheduling from transport, and verify concurrency fixes with repeated focused runs plus the complete parallel suite. +- **Boundary**: The existing Directory still carries a fixed validation timestamp, and Task 7 only integrates the concrete registration endpoint plus a central guard. Task 12 must compose a live clock and apply the guard to all effectful Runtime routes. Task 8 must authenticate TLS identity before calling the verified-credential guard. Cross-process writer exclusion remains Task 9 work. +- **Deferred must-fix**: Task 5 `encode_handoff` still uses growable `serde_json::to_vec` before the result is placed in `Zeroizing`. This TTY handoff path must be corrected before Task 10 enables the CLI; Task 7 deliberately does not change it. + ## 2026-08-15 01:18 CST - **Change**: Replaced enrollment request `serde_json::to_vec` serialization with one fixed-capacity zeroizing writer shared by HTTP transmission and exact-request digesting. diff --git a/src/protocol/authority.rs b/src/protocol/authority.rs index e7ced7d..ee402a9 100644 --- a/src/protocol/authority.rs +++ b/src/protocol/authority.rs @@ -16,6 +16,7 @@ const AUTHORITY_CREDENTIAL_DOMAIN: &[u8] = b"AGENET\0authority-credential-v0.2\0 pub enum AuthorityScope { IssueNodeCredential, IssueFoundingDirectoryCredential, + PublishRevocationSnapshot, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -121,7 +122,7 @@ pub fn verify_credential_chain( super::VerifiedNodeClaims::try_from(node) } -fn verify_authority_credential( +pub(crate) fn verify_authority_credential( root_public_key: &VerifyingKey, credential: &SignedAuthorityCredential, now_ms: i64, diff --git a/src/protocol/error.rs b/src/protocol/error.rs index 9515fe5..f61ed39 100644 --- a/src/protocol/error.rs +++ b/src/protocol/error.rs @@ -23,6 +23,12 @@ pub enum ProtocolError { CredentialIssuerMismatch, InvalidEnrollmentRequest, InvalidEnrollmentSignature, + InvalidRevocationSnapshot, + InvalidRevocationSignature, + InvalidRevocationTimeWindow, + RevocationSnapshotFromFuture, + RevocationSetTooLarge, + UnsupportedRevocationVersion, UnsupportedEnrollmentVersion, EnrollmentRequestTooLarge, InvalidEnvelopeSignature, diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index c0e40fa..821cdfa 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -4,14 +4,15 @@ mod enrollment; mod envelope; mod error; mod identity; +mod revocation; mod sealed_contract; mod types; -pub(crate) use authority::roles_for_bootstrap_profile; pub use authority::{ AuthorityClaims, AuthorityScope, CredentialChain, SignedAuthorityCredential, verify_credential_chain, }; +pub(crate) use authority::{roles_for_bootstrap_profile, verify_authority_credential}; pub use contract::{ContractProjection, apply_event, event_hash}; pub use enrollment::{EnrollmentBundle, MAX_ENROLLMENT_CSR_BYTES}; pub(crate) use enrollment::{ @@ -22,6 +23,9 @@ pub use error::ProtocolError; pub use identity::{ BootstrapProfile, NodeCredentialClaims, SignedNodeCredential, VerifiedNodeClaims, }; +pub use revocation::{ + REVOCATION_FORMAT_VERSION, RevocationClaims, RevocationDecision, RevocationSnapshot, +}; pub use sealed_contract::{ContractOffer, SealedContract}; pub use types::{ AcceptanceProfile, ArtifactId, ArtifactPayload, ArtifactReadRequest, ArtifactRef, CandidateSet, diff --git a/src/protocol/revocation.rs b/src/protocol/revocation.rs new file mode 100644 index 0000000..0da4bcd --- /dev/null +++ b/src/protocol/revocation.rs @@ -0,0 +1,166 @@ +use std::collections::BTreeSet; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use super::{ + AuthorityScope, DomainId, NodeId, ProtocolError, SignedAuthorityCredential, + authority::verify_authority_credential, +}; + +pub const REVOCATION_FORMAT_VERSION: &str = "agenet.revocation-snapshot.v0.2"; +pub const MAX_REVOCATION_FRESHNESS_MS: i64 = 5 * 60 * 1_000; +pub const MAX_REVOCATION_FUTURE_SKEW_MS: i64 = 30 * 1_000; +pub const MAX_REVOKED_IDENTITIES_PER_SET: usize = 4_096; +const REVOCATION_SIGNATURE_DOMAIN: &[u8] = b"AGENET\0revocation-snapshot-v0.2\0"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RevocationClaims { + pub format_version: String, + pub domain_id: DomainId, + pub issuer_id: NodeId, + pub epoch: u64, + pub generated_at_ms: i64, + pub next_update_ms: i64, + pub revoked_authorities: BTreeSet, + pub revoked_nodes: BTreeSet, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RevocationSnapshot { + pub claims: RevocationClaims, + pub authority_credential: SignedAuthorityCredential, + pub exact_claims_base64: String, + pub signature_base64: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RevocationDecision { + CurrentAndAllowed, + Revoked, + Stale, +} + +impl RevocationSnapshot { + pub fn sign( + authority_credential: SignedAuthorityCredential, + authority_signing_key: &SigningKey, + claims: RevocationClaims, + root_public_key: &VerifyingKey, + now_ms: i64, + ) -> Result { + validate_claims(&claims, now_ms)?; + let authority_key = + verify_authority_credential(root_public_key, &authority_credential, now_ms)?; + validate_authority_binding(&authority_credential, &claims, &authority_key)?; + if authority_key != authority_signing_key.verifying_key() { + return Err(ProtocolError::CredentialIssuerMismatch); + } + let exact_claims = + serde_json::to_vec(&claims).map_err(|_| ProtocolError::SerializationFailed)?; + let signature = authority_signing_key.sign(&signature_message(&exact_claims)); + Ok(Self { + claims, + authority_credential, + exact_claims_base64: STANDARD.encode(&exact_claims), + signature_base64: STANDARD.encode(signature.to_bytes()), + }) + } + + pub fn verify( + &self, + root_public_key: &VerifyingKey, + expected_domain: &DomainId, + now_ms: i64, + ) -> Result { + let exact_claims = STANDARD + .decode(&self.exact_claims_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let decoded: RevocationClaims = serde_json::from_slice(&exact_claims) + .map_err(|_| ProtocolError::InvalidRevocationSnapshot)?; + if decoded != self.claims { + return Err(ProtocolError::InvalidRevocationSnapshot); + } + validate_claims(&decoded, now_ms)?; + if &decoded.domain_id != expected_domain { + return Err(ProtocolError::DomainMismatch); + } + let authority_key = + verify_authority_credential(root_public_key, &self.authority_credential, now_ms)?; + validate_authority_binding(&self.authority_credential, &decoded, &authority_key)?; + let signature_bytes = STANDARD + .decode(&self.signature_base64) + .map_err(|_| ProtocolError::InvalidBase64)?; + let signature = Signature::from_slice(&signature_bytes) + .map_err(|_| ProtocolError::InvalidRevocationSignature)?; + authority_key + .verify_strict(&signature_message(&exact_claims), &signature) + .map_err(|_| ProtocolError::InvalidRevocationSignature)?; + Ok(decoded) + } +} + +fn validate_authority_binding( + credential: &SignedAuthorityCredential, + claims: &RevocationClaims, + authority_key: &VerifyingKey, +) -> Result<(), ProtocolError> { + if credential.claims.domain_id != claims.domain_id { + return Err(ProtocolError::DomainMismatch); + } + if credential.claims.authority_id != claims.issuer_id { + return Err(ProtocolError::CredentialIssuerMismatch); + } + if !credential + .claims + .scopes + .contains(&AuthorityScope::PublishRevocationSnapshot) + { + return Err(ProtocolError::AuthorityScopeViolation); + } + if claims.next_update_ms > credential.claims.expires_at_ms { + return Err(ProtocolError::InvalidRevocationTimeWindow); + } + let encoded = STANDARD.encode(authority_key.to_bytes()); + if credential.claims.signing_public_key_base64 != encoded { + return Err(ProtocolError::CredentialIssuerMismatch); + } + Ok(()) +} + +fn validate_claims(claims: &RevocationClaims, now_ms: i64) -> Result<(), ProtocolError> { + if claims.format_version != REVOCATION_FORMAT_VERSION { + return Err(ProtocolError::UnsupportedRevocationVersion); + } + if claims.revoked_authorities.len() > MAX_REVOKED_IDENTITIES_PER_SET + || claims.revoked_nodes.len() > MAX_REVOKED_IDENTITIES_PER_SET + { + return Err(ProtocolError::RevocationSetTooLarge); + } + if claims.epoch == 0 + || claims.generated_at_ms <= 0 + || claims.next_update_ms <= claims.generated_at_ms + || claims + .next_update_ms + .checked_sub(claims.generated_at_ms) + .is_none_or(|freshness| freshness > MAX_REVOCATION_FRESHNESS_MS) + { + return Err(ProtocolError::InvalidRevocationTimeWindow); + } + if claims.generated_at_ms > now_ms.saturating_add(MAX_REVOCATION_FUTURE_SKEW_MS) { + return Err(ProtocolError::RevocationSnapshotFromFuture); + } + Ok(()) +} + +fn signature_message(exact_claims: &[u8]) -> Vec { + let mut message = + Vec::with_capacity(REVOCATION_SIGNATURE_DOMAIN.len() + 8 + exact_claims.len()); + message.extend_from_slice(REVOCATION_SIGNATURE_DOMAIN); + message.extend_from_slice(&(exact_claims.len() as u64).to_be_bytes()); + message.extend_from_slice(exact_claims); + message +} diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 458c3d9..9821c81 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -4,7 +4,7 @@ use super::{ContractOffer, ProtocolError}; macro_rules! identifier { ($name:ident) => { - #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct $name(String); diff --git a/src/runtime/error.rs b/src/runtime/error.rs index e944417..82a2b9e 100644 --- a/src/runtime/error.rs +++ b/src/runtime/error.rs @@ -28,6 +28,12 @@ pub enum RuntimeError { CapabilityUnavailable, ContractExecutionFailed, VerificationFailed, + RevocationStateStale, + CredentialRevoked, + RevocationEpochRollback, + RevocationEpochConflict, + CorruptRevocationState, + UnsafeRevocationState, Protocol(ProtocolError), } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 62b7fc5..7f23dd6 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod key_store; mod provider; mod recorder; mod requester; +mod revocation; pub use artifact::ArtifactStore; pub use artifact_access::ArtifactAccessService; @@ -17,5 +18,6 @@ pub use key_store::{read_signing_key, write_signing_key}; pub use provider::ProviderService; pub use recorder::ContractRecorder; pub use requester::{PursuitQuery, PursuitRequest, PursuitResult, RequesterService}; +pub use revocation::{AuthorityRevocationStore, RevocationCache, RevocationGuard}; pub const MAX_ARTIFACT_BYTES: usize = 64 * 1024; diff --git a/src/runtime/revocation.rs b/src/runtime/revocation.rs new file mode 100644 index 0000000..e07c226 --- /dev/null +++ b/src/runtime/revocation.rs @@ -0,0 +1,404 @@ +use std::{ + collections::BTreeSet, + fs::{self, DirBuilder, OpenOptions}, + io::Read, + os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + path::{Path, PathBuf}, + sync::{Arc, Mutex, MutexGuard}, +}; + +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use crate::protocol::{ + CredentialChain, DomainId, NodeId, REVOCATION_FORMAT_VERSION, RevocationClaims, + RevocationDecision, RevocationSnapshot, SignedAuthorityCredential, +}; + +use super::{RuntimeError, key_store::atomic_write_owner_only}; + +const AUTHORITY_STATE_FILE: &str = "revocation-authority.json"; +const CACHE_FILE: &str = "revocation-cache.json"; +const AUTHORITY_STATE_VERSION: &str = "agenet.revocation-authority-state.v0.2"; +const CACHE_STATE_VERSION: &str = "agenet.revocation-cache-state.v0.2"; +const MAX_STATE_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthorityState { + format_version: String, + last_epoch: u64, + latest_snapshot: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CacheState { + format_version: String, + snapshot: RevocationSnapshot, +} + +pub struct AuthorityRevocationStore { + path: PathBuf, + root_public_key: VerifyingKey, + authority_credential: SignedAuthorityCredential, + authority_signing_key: SigningKey, + state: Mutex, +} + +impl AuthorityRevocationStore { + pub fn open( + directory: &Path, + root_public_key: VerifyingKey, + authority_credential: SignedAuthorityCredential, + authority_signing_key: SigningKey, + ) -> Result { + prepare_owner_directory(directory)?; + crate::protocol::verify_authority_credential( + &root_public_key, + &authority_credential, + authority_credential.claims.issued_at_ms, + )?; + let path = directory.join(AUTHORITY_STATE_FILE); + let state = match read_owner_state::(&path)? { + Some(state) => { + if state.format_version != AUTHORITY_STATE_VERSION + || state.last_epoch == 0 + || state + .latest_snapshot + .as_ref() + .is_none_or(|snapshot| snapshot.claims.epoch != state.last_epoch) + { + return Err(RuntimeError::CorruptRevocationState); + } + let snapshot = state + .latest_snapshot + .as_ref() + .ok_or(RuntimeError::CorruptRevocationState)?; + snapshot + .verify( + &root_public_key, + &authority_credential.claims.domain_id, + snapshot.claims.generated_at_ms, + ) + .map_err(|_| RuntimeError::CorruptRevocationState)?; + if !same_publisher_binding(&snapshot.authority_credential, &authority_credential) { + return Err(RuntimeError::CorruptRevocationState); + } + state + } + None => AuthorityState { + format_version: AUTHORITY_STATE_VERSION.to_owned(), + last_epoch: 0, + latest_snapshot: None, + }, + }; + if authority_credential.claims.signing_public_key_base64 + != base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + authority_signing_key.verifying_key().to_bytes(), + ) + { + return Err(RuntimeError::Protocol( + crate::protocol::ProtocolError::CredentialIssuerMismatch, + )); + } + Ok(Self { + path, + root_public_key, + authority_credential, + authority_signing_key, + state: Mutex::new(state), + }) + } + + pub fn publish( + &self, + now_ms: i64, + revoked_authorities: BTreeSet, + revoked_nodes: BTreeSet, + ) -> Result { + let mut state = lock(&self.state)?; + let epoch = state + .last_epoch + .checked_add(1) + .ok_or(RuntimeError::CorruptRevocationState)?; + let snapshot = RevocationSnapshot::sign( + self.authority_credential.clone(), + &self.authority_signing_key, + RevocationClaims { + format_version: REVOCATION_FORMAT_VERSION.to_owned(), + domain_id: self.authority_credential.claims.domain_id.clone(), + issuer_id: self.authority_credential.claims.authority_id.clone(), + epoch, + generated_at_ms: now_ms, + next_update_ms: now_ms.saturating_add(5 * 60 * 1_000), + revoked_authorities, + revoked_nodes, + }, + &self.root_public_key, + now_ms, + )?; + let next = AuthorityState { + format_version: AUTHORITY_STATE_VERSION.to_owned(), + last_epoch: epoch, + latest_snapshot: Some(snapshot.clone()), + }; + persist(&self.path, &next)?; + *state = next; + Ok(snapshot) + } + + pub fn latest(&self) -> Result, RuntimeError> { + Ok(lock(&self.state)?.latest_snapshot.clone()) + } +} + +#[derive(Clone)] +pub struct RevocationCache { + path: PathBuf, + root_public_key: VerifyingKey, + expected_domain: DomainId, + snapshot: Arc>>, +} + +impl RevocationCache { + pub fn open( + directory: &Path, + root_public_key: VerifyingKey, + expected_domain: DomainId, + ) -> Result { + prepare_owner_directory(directory)?; + let path = directory.join(CACHE_FILE); + let snapshot = match read_owner_state::(&path)? { + Some(state) => { + if state.format_version != CACHE_STATE_VERSION { + return Err(RuntimeError::CorruptRevocationState); + } + state.snapshot.verify( + &root_public_key, + &expected_domain, + state.snapshot.claims.generated_at_ms, + )?; + Some(state.snapshot) + } + None => None, + }; + Ok(Self { + path, + root_public_key, + expected_domain, + snapshot: Arc::new(Mutex::new(snapshot)), + }) + } + + pub fn accept(&self, incoming: RevocationSnapshot, now_ms: i64) -> Result { + incoming.verify(&self.root_public_key, &self.expected_domain, now_ms)?; + let mut current = lock(&self.snapshot)?; + if let Some(existing) = current.as_ref() { + if exact_snapshot_bytes(existing)? == exact_snapshot_bytes(&incoming)? { + return Ok(false); + } + if existing + .claims + .revoked_authorities + .contains(&incoming.claims.issuer_id) + { + return Err(RuntimeError::CredentialRevoked); + } + if incoming.claims.epoch < existing.claims.epoch { + return Err(RuntimeError::RevocationEpochRollback); + } + if incoming.claims.epoch == existing.claims.epoch { + return Err(RuntimeError::RevocationEpochConflict); + } + } + let state = CacheState { + format_version: CACHE_STATE_VERSION.to_owned(), + snapshot: incoming.clone(), + }; + persist(&self.path, &state)?; + *current = Some(incoming); + Ok(true) + } + + pub fn decision( + &self, + now_ms: i64, + authority_id: &NodeId, + node_id: &NodeId, + ) -> RevocationDecision { + let Ok(current) = self.snapshot.lock() else { + return RevocationDecision::Stale; + }; + let Some(snapshot) = current.as_ref() else { + return RevocationDecision::Stale; + }; + if now_ms >= snapshot.claims.next_update_ms { + return RevocationDecision::Stale; + } + if snapshot.claims.revoked_authorities.contains(authority_id) + || snapshot.claims.revoked_nodes.contains(node_id) + { + return RevocationDecision::Revoked; + } + RevocationDecision::CurrentAndAllowed + } + + pub fn epoch(&self) -> Option { + self.snapshot + .lock() + .ok() + .and_then(|snapshot| snapshot.as_ref().map(|value| value.claims.epoch)) + } +} + +#[derive(Clone)] +pub struct RevocationGuard { + cache: RevocationCache, +} + +impl RevocationGuard { + pub fn new(cache: RevocationCache) -> Self { + Self { cache } + } + + pub fn effectful( + &self, + now_ms: i64, + authority_id: &NodeId, + node_id: &NodeId, + ) -> Result<(), RuntimeError> { + match self.cache.decision(now_ms, authority_id, node_id) { + RevocationDecision::CurrentAndAllowed => Ok(()), + RevocationDecision::Revoked => Err(RuntimeError::CredentialRevoked), + RevocationDecision::Stale => Err(RuntimeError::RevocationStateStale), + } + } + + /// Applies revocation policy after the caller has cryptographically + /// verified this credential chain for the request's role and domain. + /// + /// This method only maps already-authenticated identity claims to the + /// current cache. Task 8 must complete TLS identity verification before + /// invoking it; it is not a replacement for `verify_credential_chain`. + pub fn effectful_verified_credential( + &self, + now_ms: i64, + chain: &CredentialChain, + ) -> Result<(), RuntimeError> { + let node = chain.node.decode_claims()?; + self.effectful(now_ms, &chain.authority.claims.authority_id, &node.node_id) + } + + pub fn read_only( + &self, + now_ms: i64, + authority_id: &NodeId, + node_id: &NodeId, + ) -> RevocationDecision { + self.cache.decision(now_ms, authority_id, node_id) + } +} + +fn lock(mutex: &Mutex) -> Result, RuntimeError> { + mutex + .lock() + .map_err(|_| RuntimeError::CorruptRevocationState) +} + +fn prepare_owner_directory(path: &Path) -> Result<(), RuntimeError> { + if !path.exists() { + let mut builder = DirBuilder::new(); + builder.mode(0o700).recursive(true); + builder + .create(path) + .map_err(|_| RuntimeError::UnsafeRevocationState)?; + } + let metadata = fs::symlink_metadata(path).map_err(|_| RuntimeError::UnsafeRevocationState)?; + if !metadata.file_type().is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != effective_uid() + || metadata.permissions().mode() & 0o777 != 0o700 + { + return Err(RuntimeError::UnsafeRevocationState); + } + Ok(()) +} + +fn read_owner_state Deserialize<'de>>(path: &Path) -> Result, RuntimeError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(RuntimeError::UnsafeRevocationState), + }; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || metadata.uid() != effective_uid() + || metadata.permissions().mode() & 0o777 != 0o600 + || metadata.len() > MAX_STATE_BYTES + { + return Err(RuntimeError::UnsafeRevocationState); + } + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + .map_err(|_| RuntimeError::UnsafeRevocationState)?; + let opened = file + .metadata() + .map_err(|_| RuntimeError::UnsafeRevocationState)?; + if !opened.file_type().is_file() + || opened.uid() != effective_uid() + || opened.permissions().mode() & 0o777 != 0o600 + || opened.dev() != metadata.dev() + || opened.ino() != metadata.ino() + || opened.len() > MAX_STATE_BYTES + { + return Err(RuntimeError::UnsafeRevocationState); + } + let mut bytes = Vec::with_capacity(opened.len() as usize); + file.take(MAX_STATE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| RuntimeError::CorruptRevocationState)?; + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(RuntimeError::UnsafeRevocationState); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| RuntimeError::CorruptRevocationState) +} + +fn persist(path: &Path, value: &T) -> Result<(), RuntimeError> { + let bytes = serde_json::to_vec(value).map_err(|_| RuntimeError::CorruptRevocationState)?; + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(RuntimeError::CorruptRevocationState); + } + atomic_write_owner_only(path, &bytes, true) +} + +fn effective_uid() -> u32 { + // SAFETY: `geteuid` has no preconditions and does not access memory. + unsafe { libc::geteuid() } +} + +fn exact_snapshot_bytes(snapshot: &RevocationSnapshot) -> Result, RuntimeError> { + serde_json::to_vec(snapshot).map_err(|_| RuntimeError::CorruptRevocationState) +} + +fn same_publisher_binding( + persisted: &SignedAuthorityCredential, + configured: &SignedAuthorityCredential, +) -> bool { + persisted.claims.domain_id == configured.claims.domain_id + && persisted.claims.authority_id == configured.claims.authority_id + && persisted.claims.signing_public_key_base64 == configured.claims.signing_public_key_base64 + && persisted + .claims + .scopes + .contains(&crate::protocol::AuthorityScope::PublishRevocationSnapshot) + && configured + .claims + .scopes + .contains(&crate::protocol::AuthorityScope::PublishRevocationSnapshot) +} diff --git a/src/transport/directory.rs b/src/transport/directory.rs index 8c3b2f3..99fb856 100644 --- a/src/transport/directory.rs +++ b/src/transport/directory.rs @@ -10,8 +10,8 @@ use axum::{ use serde_json::json; use crate::{ - protocol::{CapabilityManifest, ErrorEnvelope, RouteQuery, WireEnvelope}, - runtime::{DirectoryRegistry, NodeIdentity}, + protocol::{CapabilityManifest, ErrorEnvelope, RevocationDecision, RouteQuery, WireEnvelope}, + runtime::{DirectoryRegistry, NodeIdentity, RevocationGuard, RuntimeError}, }; use super::MAX_JSON_BODY_BYTES; @@ -20,17 +20,37 @@ struct DirectoryHttpState { registry: DirectoryRegistry, identity: NodeIdentity, now_unix_ms: u64, + revocations: Option, } pub fn directory_router( registry: DirectoryRegistry, identity: NodeIdentity, now_unix_ms: u64, +) -> Router { + directory_router_inner(registry, identity, now_unix_ms, None) +} + +pub fn directory_router_with_revocation( + registry: DirectoryRegistry, + identity: NodeIdentity, + now_unix_ms: u64, + revocations: RevocationGuard, +) -> Router { + directory_router_inner(registry, identity, now_unix_ms, Some(revocations)) +} + +fn directory_router_inner( + registry: DirectoryRegistry, + identity: NodeIdentity, + now_unix_ms: u64, + revocations: Option, ) -> Router { let state = Arc::new(DirectoryHttpState { registry, identity, now_unix_ms, + revocations, }); Router::new() .route("/healthz", get(health)) @@ -40,8 +60,27 @@ pub fn directory_router( .with_state(state) } -async fn health() -> Json { - Json(json!({"status": "ok"})) +async fn health(State(state): State>) -> Json { + let Some(guard) = &state.revocations else { + return Json(json!({"status": "ok"})); + }; + let now_ms = i64::try_from(state.now_unix_ms).unwrap_or(i64::MAX); + let decision = guard.read_only( + now_ms, + &state.identity.claims().authority_id, + state.identity.node_id(), + ); + match decision { + RevocationDecision::CurrentAndAllowed => { + Json(json!({"status": "ok", "revocation": "current"})) + } + RevocationDecision::Revoked => { + Json(json!({"status": "degraded", "code": "CredentialRevoked"})) + } + RevocationDecision::Stale => { + Json(json!({"status": "degraded", "code": "RevocationStateStale"})) + } + } } async fn register( @@ -66,6 +105,20 @@ async fn register( Ok(manifest) => manifest, Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; + if let Some(guard) = &state.revocations + && let Err(policy_error) = + guard.effectful_verified_credential(now_ms, &envelope.credential_chain) + { + return match policy_error { + RuntimeError::RevocationStateStale => { + error_response(StatusCode::CONFLICT, "RevocationStateStale") + } + RuntimeError::CredentialRevoked => { + error_response(StatusCode::FORBIDDEN, "CredentialRevoked") + } + _ => error_response(StatusCode::FORBIDDEN, "InvalidSignedEnvelope"), + }; + } if state .registry .register(&envelope.issuer_id, manifest, state.now_unix_ms) @@ -133,6 +186,8 @@ fn error_response(status: StatusCode, code: &str) -> Response { "SignedEnvelopeRequired" => "a valid signed envelope is required", "InvalidSignedEnvelope" => "the signed envelope could not be verified", "InvalidCapabilityManifest" => "the capability manifest was rejected", + "RevocationStateStale" => "revocation policy is stale", + "CredentialRevoked" => "the peer credential is revoked", _ => "the request could not be completed", }; ( @@ -140,7 +195,7 @@ fn error_response(status: StatusCode, code: &str) -> Response { Json(ErrorEnvelope { code: code.to_owned(), message: message.to_owned(), - retryable: false, + retryable: code == "RevocationStateStale", operation_id: "http:unavailable".to_owned(), }), ) diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 04833c0..24acc82 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -2,12 +2,14 @@ mod client; mod directory; mod enrollment; mod node; +mod revocation; pub use client::{HttpStats, PeerClient, TransportError}; -pub use directory::directory_router; +pub use directory::{directory_router, directory_router_with_revocation}; pub use enrollment::{ EnrollmentClient, EnrollmentTransportError, enrollment_router, enrollment_tls_config, }; pub use node::{artifact_router, provider_router, requester_router}; +pub use revocation::{RevocationClient, RevocationTransportError, authority_revocation_router}; pub const MAX_JSON_BODY_BYTES: usize = 256 * 1024; diff --git a/src/transport/revocation.rs b/src/transport/revocation.rs new file mode 100644 index 0000000..422f39f --- /dev/null +++ b/src/transport/revocation.rs @@ -0,0 +1,269 @@ +use std::{ + fmt::{Debug, Formatter}, + future::Future, + sync::Arc, + time::Duration, +}; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use reqwest::{Client, Url, redirect::Policy}; +use tokio::sync::watch; + +use crate::{ + bootstrap::network::NetworkBoundary, + protocol::{ErrorEnvelope, RevocationSnapshot}, + runtime::{AuthorityRevocationStore, RevocationCache, RuntimeError}, +}; + +use super::MAX_JSON_BODY_BYTES; + +pub fn authority_revocation_router(store: Arc) -> Router { + Router::new() + .route("/healthz", get(|| async { StatusCode::OK })) + .route("/v0/revocations/snapshot", get(authority_snapshot)) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .with_state(store) +} + +async fn authority_snapshot(State(store): State>) -> Response { + match store.latest() { + Ok(Some(snapshot)) => (StatusCode::OK, Json(snapshot)).into_response(), + Ok(None) => sanitized_error(StatusCode::SERVICE_UNAVAILABLE, "RevocationStateStale"), + Err(_) => sanitized_error(StatusCode::SERVICE_UNAVAILABLE, "InternalError"), + } +} + +fn sanitized_error(status: StatusCode, code: &str) -> Response { + ( + status, + Json(ErrorEnvelope { + code: code.to_owned(), + message: "the revocation request could not be completed".to_owned(), + retryable: status == StatusCode::SERVICE_UNAVAILABLE, + operation_id: "revocation:unavailable".to_owned(), + }), + ) + .into_response() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RevocationTransportError { + InvalidEndpoint, + RequestFailed, + NonSuccessStatus(u16), + ResponseTooLarge, + InvalidResponse, + PolicyRejected(RuntimeError), +} + +#[derive(Clone)] +pub struct RevocationClient { + client: Client, + endpoint: Url, + diagnostics: watch::Sender>, +} + +impl Debug for RevocationClient { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RevocationClient") + .finish_non_exhaustive() + } +} + +impl RevocationClient { + pub fn new( + boundary: &NetworkBoundary, + endpoint: Url, + connect_timeout: Duration, + request_timeout: Duration, + ) -> Result { + boundary + .validate_peer_endpoint(endpoint.as_str()) + .map_err(|_| RevocationTransportError::InvalidEndpoint)?; + let client = Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(connect_timeout) + .timeout(request_timeout) + .build() + .map_err(|_| RevocationTransportError::RequestFailed)?; + let (diagnostics, _) = watch::channel(None); + Ok(Self { + client, + endpoint, + diagnostics, + }) + } + + pub async fn refresh( + &self, + cache: &RevocationCache, + now_ms: i64, + ) -> Result { + let snapshot = self.fetch_snapshot().await?; + cache + .accept(snapshot, now_ms) + .map_err(RevocationTransportError::PolicyRejected) + } + + pub async fn run_refresh_loop( + &self, + cache: &RevocationCache, + interval: Duration, + mut now_ms: F, + mut shutdown: watch::Receiver, + ) -> Result<(), RevocationTransportError> + where + F: FnMut() -> i64, + { + validate_refresh_interval(interval)?; + run_refresh_schedule(interval, &self.diagnostics, &mut shutdown, || { + let now_ms = now_ms(); + async move { self.refresh(cache, now_ms).await.map(|_| ()) } + }) + .await + } + + pub fn subscribe_last_error(&self) -> watch::Receiver> { + self.diagnostics.subscribe() + } + + async fn fetch_snapshot(&self) -> Result { + let mut url = self.endpoint.clone(); + url.set_path("/v0/revocations/snapshot"); + let mut response = self + .client + .get(url) + .send() + .await + .map_err(|_| RevocationTransportError::RequestFailed)?; + if !response.status().is_success() { + return Err(RevocationTransportError::NonSuccessStatus( + response.status().as_u16(), + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_JSON_BODY_BYTES as u64) + { + return Err(RevocationTransportError::ResponseTooLarge); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| RevocationTransportError::RequestFailed)? + { + if bytes.len().saturating_add(chunk.len()) > MAX_JSON_BODY_BYTES { + return Err(RevocationTransportError::ResponseTooLarge); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|_| RevocationTransportError::InvalidResponse) + } +} + +async fn run_refresh_schedule( + interval: Duration, + diagnostics: &watch::Sender>, + shutdown: &mut watch::Receiver, + mut refresh: F, +) -> Result<(), RevocationTransportError> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + loop { + if *shutdown.borrow() { + return Ok(()); + } + match refresh().await { + Ok(()) => { + diagnostics.send_replace(None); + } + Err(error) => { + diagnostics.send_replace(Some(error)); + } + } + tokio::select! { + _ = tokio::time::sleep(interval) => {} + changed = shutdown.changed() => { + if changed.is_err() || *shutdown.borrow() { + return Ok(()); + } + } + } + } +} + +fn validate_refresh_interval(interval: Duration) -> Result<(), RevocationTransportError> { + if interval.is_zero() || interval > Duration::from_secs(5 * 60) { + return Err(RevocationTransportError::InvalidEndpoint); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::VecDeque, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; + + use super::*; + + #[tokio::test(start_paused = true)] + async fn schedule_waits_until_deadline_recovers_and_stops() { + let outcomes = + VecDeque::from([Err(RevocationTransportError::NonSuccessStatus(503)), Ok(())]); + let outcomes = Arc::new(std::sync::Mutex::new(outcomes)); + let calls = Arc::new(AtomicUsize::new(0)); + let (diagnostics_tx, mut diagnostics_rx) = watch::channel(None); + let (shutdown_tx, mut shutdown_rx) = watch::channel(false); + let task = tokio::spawn({ + let outcomes = Arc::clone(&outcomes); + let calls = Arc::clone(&calls); + async move { + run_refresh_schedule( + Duration::from_secs(60), + &diagnostics_tx, + &mut shutdown_rx, + move || { + calls.fetch_add(1, Ordering::SeqCst); + let result = outcomes + .lock() + .expect("outcome lock") + .pop_front() + .expect("outcome"); + async move { result } + }, + ) + .await + } + }); + + diagnostics_rx.changed().await.expect("first diagnostic"); + assert_eq!( + diagnostics_rx.borrow().clone(), + Some(RevocationTransportError::NonSuccessStatus(503)) + ); + tokio::time::advance(Duration::from_secs(59)).await; + assert_eq!(calls.load(Ordering::SeqCst), 1); + tokio::time::advance(Duration::from_secs(1)).await; + diagnostics_rx.changed().await.expect("recovery diagnostic"); + assert_eq!(*diagnostics_rx.borrow(), None); + assert_eq!(calls.load(Ordering::SeqCst), 2); + shutdown_tx.send(true).expect("shutdown"); + task.await.expect("join").expect("schedule"); + } +} diff --git a/tests/http_revocation.rs b/tests/http_revocation.rs new file mode 100644 index 0000000..9609be8 --- /dev/null +++ b/tests/http_revocation.rs @@ -0,0 +1,522 @@ +mod common; + +use std::{ + collections::BTreeSet, + io::{Read, Write}, + net::TcpListener as StdTcpListener, + sync::Arc, + time::Duration, +}; + +use agenet::{ + bootstrap::network::NetworkBoundary, + protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityId, CapabilityManifest, + NodeId, NodeRole, RevocationClaims, RevocationSnapshot, RouteQuery, SideEffectProfile, + SignedAuthorityCredential, + }, + runtime::{ + AuthorityRevocationStore, DirectoryRegistry, NodeIdentity, RevocationCache, RevocationGuard, + }, + transport::{ + RevocationClient, RevocationTransportError, authority_revocation_router, + directory_router_with_revocation, + }, +}; +use axum::{ + Router, + body::Body, + http::{Request, StatusCode}, + response::IntoResponse, + routing::get, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; +use http_body_util::BodyExt; +use tempfile::tempdir; +use tokio::net::TcpListener; +use tower::ServiceExt; + +const NOW: i64 = 1_800_000_000_000; + +fn key(byte: u8) -> SigningKey { + SigningKey::from_bytes(&[byte; 32]) +} + +fn publisher_credential(root: &SigningKey, authority: &SigningKey) -> SignedAuthorityCredential { + SignedAuthorityCredential::issue( + root, + AuthorityClaims { + domain_id: common::domain_id(), + authority_id: NodeId::new("authority:revocation-http").expect("id"), + signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([AuthorityScope::PublishRevocationSnapshot]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Base]), + maximum_node_lifetime_ms: 60_000, + issued_at_ms: NOW - 60_000, + expires_at_ms: NOW + 600_000, + }, + ) + .expect("credential") +} + +fn snapshot( + root: &SigningKey, + authority: &SigningKey, + epoch: u64, + revoked_nodes: BTreeSet, +) -> RevocationSnapshot { + RevocationSnapshot::sign( + publisher_credential(root, authority), + authority, + RevocationClaims { + format_version: "agenet.revocation-snapshot.v0.2".to_owned(), + domain_id: common::domain_id(), + issuer_id: NodeId::new("authority:revocation-http").expect("id"), + epoch, + generated_at_ms: NOW, + next_update_ms: NOW + 300_000, + revoked_authorities: BTreeSet::new(), + revoked_nodes, + }, + &root.verifying_key(), + NOW, + ) + .expect("snapshot") +} + +#[tokio::test] +async fn authority_endpoint_returns_a_bounded_signed_snapshot_and_health() { + let root = key(1); + let authority = key(2); + let temp = tempdir().expect("tempdir"); + let store = Arc::new( + AuthorityRevocationStore::open( + &temp.path().join("state"), + root.verifying_key(), + publisher_credential(&root, &authority), + authority, + ) + .expect("store"), + ); + store + .publish(NOW, BTreeSet::new(), BTreeSet::new()) + .expect("publish"); + let app = authority_revocation_router(store); + + assert_eq!( + app.clone() + .oneshot( + Request::get("/healthz") + .body(Body::empty()) + .expect("request") + ) + .await + .expect("response") + .status(), + StatusCode::OK + ); + let response = app + .oneshot( + Request::get("/v0/revocations/snapshot") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let signed: RevocationSnapshot = serde_json::from_slice(&bytes).expect("snapshot"); + assert_eq!( + signed + .verify(&root.verifying_key(), &common::domain_id(), NOW) + .expect("verified") + .epoch, + 1 + ); +} + +#[tokio::test] +async fn directory_registration_fails_stale_and_revoked_but_query_health_remain_available() { + let root = key(3); + let directory_key = key(4); + let executor_key = key(5); + let directory = NodeIdentity::new( + directory_key.clone(), + common::credential_chain( + &root, + &directory_key, + "node:directory", + NodeRole::Directory, + NOW as u64, + ), + NodeRole::Directory, + root.verifying_key(), + NOW as u64, + ) + .expect("identity"); + let executor = NodeIdentity::new( + executor_key.clone(), + common::credential_chain( + &root, + &executor_key, + "node:executor", + NodeRole::Executor, + NOW as u64, + ), + NodeRole::Executor, + root.verifying_key(), + NOW as u64, + ) + .expect("identity"); + let requester_key = key(11); + let requester = NodeIdentity::new( + requester_key.clone(), + common::credential_chain( + &root, + &requester_key, + "node:requester", + NodeRole::Requester, + NOW as u64, + ), + NodeRole::Requester, + root.verifying_key(), + NOW as u64, + ) + .expect("identity"); + let temp = tempdir().expect("tempdir"); + let cache = RevocationCache::open( + &temp.path().join("cache"), + root.verifying_key(), + common::domain_id(), + ) + .expect("cache"); + let app = directory_router_with_revocation( + DirectoryRegistry::new(), + directory, + NOW as u64, + RevocationGuard::new(cache.clone()), + ); + let manifest = CapabilityManifest { + capability_id: CapabilityId::new("capability:metrics").expect("id"), + provider: executor.node_id().clone(), + kind: "source.metrics".to_owned(), + version: "v1".to_owned(), + description: "metrics".to_owned(), + input_profile: "source".to_owned(), + output_profile: "metrics".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: "http://127.0.0.1:40001".to_owned(), + evidence_types: vec!["metrics.v1".to_owned()], + expires_at_unix_ms: NOW as u64 + 30_000, + }; + let envelope = executor + .seal("capability.manifest.v1", &manifest) + .expect("envelope"); + + let stale = register(&app, &envelope).await; + assert_eq!(stale.status(), StatusCode::CONFLICT); + assert_eq!(error_code(stale).await, "RevocationStateStale"); + let health = app + .clone() + .oneshot( + Request::get("/healthz") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("health"); + assert_eq!(health.status(), StatusCode::OK); + let query = requester + .seal( + "route.query.v1", + &RouteQuery { + required_capability: "source.metrics.v1".to_owned(), + }, + ) + .expect("query"); + let query_response = app + .clone() + .oneshot( + Request::post("/v0/routes/query") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&query).expect("json"))) + .expect("request"), + ) + .await + .expect("query response"); + assert_eq!(query_response.status(), StatusCode::OK); + + let authority = key(6); + cache + .accept(snapshot(&root, &authority, 1, BTreeSet::new()), NOW) + .expect("refresh"); + assert_eq!(register(&app, &envelope).await.status(), StatusCode::OK); + + cache + .accept( + snapshot( + &root, + &authority, + 2, + BTreeSet::from([executor.node_id().clone()]), + ), + NOW, + ) + .expect("refresh"); + let revoked = register(&app, &envelope).await; + assert_eq!(revoked.status(), StatusCode::FORBIDDEN); + assert_eq!(error_code(revoked).await, "CredentialRevoked"); +} + +#[tokio::test] +async fn refresh_client_rejects_redirects_and_oversize() { + let root = key(7); + let authority = key(8); + let signed = snapshot(&root, &authority, 1, BTreeSet::new()); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + let app = Router::new().route( + "/v0/revocations/snapshot", + get({ + let signed = signed.clone(); + move || { + let signed = signed.clone(); + async move { axum::Json(signed) } + } + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + let temp = tempdir().expect("tempdir"); + let cache = RevocationCache::open( + &temp.path().join("cache"), + root.verifying_key(), + common::domain_id(), + ) + .expect("cache"); + let client = RevocationClient::new( + &NetworkBoundary::loopback_ipv4(), + endpoint.parse().expect("url"), + Duration::from_secs(1), + Duration::from_secs(1), + ) + .expect("client"); + tokio::task::yield_now().await; + client.refresh(&cache, NOW).await.expect("refresh"); + assert_eq!(cache.epoch(), Some(1)); + + assert!(!format!("{client:?}").contains(&endpoint)); + server.abort(); + + let (redirect_endpoint, redirect_server) = spawn_tokio_server(Router::new().route( + "/v0/revocations/snapshot", + get(|| async { (StatusCode::FOUND, [("location", "/elsewhere")]) }), + )) + .await; + let redirect_client = RevocationClient::new( + &NetworkBoundary::loopback_ipv4(), + redirect_endpoint.parse().expect("url"), + Duration::from_secs(1), + Duration::from_secs(1), + ) + .expect("client"); + assert_eq!( + redirect_client.refresh(&cache, NOW).await, + Err(RevocationTransportError::NonSuccessStatus(302)) + ); + redirect_server.abort(); + + let (oversize_endpoint, oversize_server) = spawn_tokio_server(Router::new().route( + "/v0/revocations/snapshot", + get(|| async { vec![b'x'; 256 * 1024 + 1].into_response() }), + )) + .await; + let oversize_client = RevocationClient::new( + &NetworkBoundary::loopback_ipv4(), + oversize_endpoint.parse().expect("url"), + Duration::from_secs(1), + Duration::from_secs(1), + ) + .expect("client"); + assert_eq!( + oversize_client.refresh(&cache, NOW).await, + Err(RevocationTransportError::ResponseTooLarge) + ); + oversize_server.abort(); +} + +#[tokio::test] +async fn refresh_loop_survives_transient_failure_and_later_recovers() { + let root = key(14); + let authority = key(15); + let signed = snapshot(&root, &authority, 1, BTreeSet::new()); + let body = serde_json::to_vec(&signed).expect("json"); + let (endpoint, server) = spawn_blocking_responses(vec![(503, Vec::new()), (200, body)]); + let temp = tempdir().expect("tempdir"); + let cache = RevocationCache::open( + &temp.path().join("cache"), + root.verifying_key(), + common::domain_id(), + ) + .expect("cache"); + let client = RevocationClient::new( + &NetworkBoundary::loopback_ipv4(), + endpoint.parse().expect("url"), + Duration::from_secs(1), + Duration::from_secs(1), + ) + .expect("client"); + let mut diagnostics = client.subscribe_last_error(); + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let task = tokio::spawn({ + let client = client.clone(); + let cache = cache.clone(); + async move { + client + .run_refresh_loop(&cache, Duration::from_millis(10), || NOW, shutdown_rx) + .await + } + }); + diagnostics.changed().await.expect("503 diagnostic"); + assert_eq!( + diagnostics.borrow().clone(), + Some(RevocationTransportError::NonSuccessStatus(503)) + ); + assert_eq!(cache.epoch(), None); + + diagnostics.changed().await.expect("recovery diagnostic"); + assert_eq!(cache.epoch(), Some(1)); + assert_eq!(*diagnostics.borrow(), None); + shutdown_tx.send(true).expect("shutdown"); + task.await.expect("join").expect("refresh loop"); + server.join().expect("server"); +} + +async fn spawn_tokio_server(app: Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + let server = tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); + (endpoint, server) +} + +fn spawn_blocking_responses( + responses: Vec<(u16, Vec)>, +) -> (String, std::thread::JoinHandle<()>) { + let listener = StdTcpListener::bind("127.0.0.1:0").expect("listener"); + listener.set_nonblocking(true).expect("nonblocking"); + let endpoint = format!("http://{}", listener.local_addr().expect("address")); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + for (status, body) in responses { + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(std::time::Instant::now() < deadline, "request deadline"); + std::thread::sleep(Duration::from_millis(1)); + } + Err(error) => panic!("connection: {error}"), + } + }; + stream.set_nonblocking(false).expect("blocking stream"); + let mut request = [0_u8; 2_048]; + let _ = stream.read(&mut request).expect("request"); + write!( + stream, + "HTTP/1.1 {status} Test\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ) + .expect("headers"); + stream.write_all(&body).expect("body"); + stream.flush().expect("flush"); + } + }); + (endpoint, server) +} + +#[test] +fn revocation_client_bypasses_ambient_proxy_in_isolated_child() { + const MARKER: &str = "AGENET_REVOCATION_PROXY_CHILD"; + if std::env::var_os(MARKER).is_some() { + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + runtime.block_on(async { + let root = key(12); + let authority = key(13); + let signed = snapshot(&root, &authority, 1, BTreeSet::new()); + let (endpoint, server) = spawn_tokio_server(Router::new().route( + "/v0/revocations/snapshot", + get(move || { + let signed = signed.clone(); + async move { axum::Json(signed) } + }), + )) + .await; + let temp = tempdir().expect("tempdir"); + let cache = RevocationCache::open( + &temp.path().join("cache"), + root.verifying_key(), + common::domain_id(), + ) + .expect("cache"); + let client = RevocationClient::new( + &NetworkBoundary::loopback_ipv4(), + endpoint.parse().expect("url"), + Duration::from_secs(1), + Duration::from_secs(1), + ) + .expect("client"); + client.refresh(&cache, NOW).await.expect("proxy bypass"); + assert_eq!(cache.epoch(), Some(1)); + server.abort(); + }); + return; + } + + let output = std::process::Command::new(std::env::current_exe().expect("test binary")) + .arg("--exact") + .arg("revocation_client_bypasses_ambient_proxy_in_isolated_child") + .arg("--nocapture") + .env(MARKER, "1") + .env("HTTP_PROXY", "http://127.0.0.1:9") + .env("HTTPS_PROXY", "http://127.0.0.1:9") + .env("ALL_PROXY", "http://127.0.0.1:9") + .output() + .expect("child"); + assert!( + output.status.success(), + "isolated proxy test failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn register( + app: &Router, + envelope: &agenet::protocol::WireEnvelope, +) -> axum::response::Response { + app.clone() + .oneshot( + Request::post("/v0/capabilities/register") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(envelope).expect("json"))) + .expect("request"), + ) + .await + .expect("response") +} + +async fn error_code(response: axum::response::Response) -> String { + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json"); + value["code"].as_str().expect("code").to_owned() +} diff --git a/tests/revocation.rs b/tests/revocation.rs new file mode 100644 index 0000000..192a8cb --- /dev/null +++ b/tests/revocation.rs @@ -0,0 +1,465 @@ +use std::{ + collections::BTreeSet, + fs, + os::unix::fs::{PermissionsExt, symlink}, +}; + +use agenet::{ + protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, DomainId, NodeId, ProtocolError, + RevocationClaims, RevocationDecision, RevocationSnapshot, SignedAuthorityCredential, + }, + runtime::{AuthorityRevocationStore, RevocationCache, RuntimeError}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; +use tempfile::tempdir; + +const NOW: i64 = 1_800_000_000_000; + +fn key(byte: u8) -> SigningKey { + SigningKey::from_bytes(&[byte; 32]) +} + +fn domain() -> DomainId { + DomainId::new("domain:revocation-test").expect("valid domain") +} + +fn authority_id() -> NodeId { + NodeId::new("authority:revocation-test").expect("valid Authority") +} + +fn authority_credential(root: &SigningKey, authority: &SigningKey) -> SignedAuthorityCredential { + SignedAuthorityCredential::issue( + root, + AuthorityClaims { + domain_id: domain(), + authority_id: authority_id(), + signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([ + AuthorityScope::IssueNodeCredential, + AuthorityScope::PublishRevocationSnapshot, + ]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Base]), + maximum_node_lifetime_ms: 60_000, + issued_at_ms: NOW - 60_000, + expires_at_ms: NOW + 600_000, + }, + ) + .expect("Authority credential") +} + +fn signed_snapshot( + root: &SigningKey, + authority: &SigningKey, + epoch: u64, + generated_at_ms: i64, +) -> RevocationSnapshot { + RevocationSnapshot::sign( + authority_credential(root, authority), + authority, + RevocationClaims { + format_version: "agenet.revocation-snapshot.v0.2".to_owned(), + domain_id: domain(), + issuer_id: authority_id(), + epoch, + generated_at_ms, + next_update_ms: generated_at_ms + 300_000, + revoked_authorities: BTreeSet::new(), + revoked_nodes: BTreeSet::new(), + }, + &root.verifying_key(), + generated_at_ms, + ) + .expect("signed snapshot") +} + +#[test] +fn verifies_exact_signed_snapshot_and_rejects_tampering() { + let root = key(1); + let authority = key(2); + let snapshot = signed_snapshot(&root, &authority, 1, NOW); + + let claims = snapshot + .verify(&root.verifying_key(), &domain(), NOW) + .expect("snapshot verifies"); + assert_eq!(claims.epoch, 1); + + let mut tampered = snapshot.clone(); + tampered.claims.epoch = 2; + assert_eq!( + tampered.verify(&root.verifying_key(), &domain(), NOW), + Err(ProtocolError::InvalidRevocationSnapshot) + ); + + let mut signature_tampered = snapshot; + let mut signature = STANDARD + .decode(&signature_tampered.signature_base64) + .expect("signature"); + signature[0] ^= 1; + signature_tampered.signature_base64 = STANDARD.encode(signature); + assert_eq!( + signature_tampered.verify(&root.verifying_key(), &domain(), NOW), + Err(ProtocolError::InvalidRevocationSignature) + ); +} + +#[test] +fn rejects_wrong_domain_issuer_scope_expiry_and_time_windows() { + let root = key(3); + let authority = key(4); + let snapshot = signed_snapshot(&root, &authority, 1, NOW); + assert_eq!( + snapshot.verify( + &root.verifying_key(), + &DomainId::new("domain:other").expect("domain"), + NOW, + ), + Err(ProtocolError::DomainMismatch) + ); + + let mut wrong_issuer = snapshot.clone(); + wrong_issuer.claims.issuer_id = NodeId::new("authority:other").expect("id"); + assert_eq!( + wrong_issuer.verify(&root.verifying_key(), &domain(), NOW), + Err(ProtocolError::InvalidRevocationSnapshot) + ); + + let mut no_scope_claims = wrong_issuer.authority_credential.claims.clone(); + no_scope_claims.authority_id = authority_id(); + no_scope_claims.scopes.clear(); + let no_scope_credential = + SignedAuthorityCredential::issue(&root, no_scope_claims).expect("credential"); + let no_scope = RevocationSnapshot::sign( + no_scope_credential, + &authority, + snapshot.claims.clone(), + &root.verifying_key(), + NOW, + ); + assert_eq!(no_scope, Err(ProtocolError::AuthorityScopeViolation)); + + assert_eq!( + snapshot.verify(&root.verifying_key(), &domain(), NOW + 600_001), + Err(ProtocolError::AuthorityCredentialExpired) + ); + + for (generated, next) in [(0, NOW + 1), (NOW, NOW), (NOW, NOW + 300_001)] { + let mut claims = snapshot.claims.clone(); + claims.generated_at_ms = generated; + claims.next_update_ms = next; + assert_eq!( + RevocationSnapshot::sign( + authority_credential(&root, &authority), + &authority, + claims, + &root.verifying_key(), + NOW, + ), + Err(ProtocolError::InvalidRevocationTimeWindow) + ); + } + + let future = signed_snapshot(&root, &authority, 2, NOW + 30_001); + assert_eq!( + future.verify(&root.verifying_key(), &domain(), NOW), + Err(ProtocolError::RevocationSnapshotFromFuture) + ); + + let mut beyond_claims = snapshot.claims.clone(); + beyond_claims.epoch = 3; + beyond_claims.generated_at_ms = NOW + 400_000; + beyond_claims.next_update_ms = NOW + 700_000; + assert_eq!( + RevocationSnapshot::sign( + authority_credential(&root, &authority), + &authority, + beyond_claims, + &root.verifying_key(), + NOW + 400_000, + ), + Err(ProtocolError::InvalidRevocationTimeWindow) + ); + + let mut oversized_claims = snapshot.claims; + oversized_claims.revoked_nodes = (0..4_097) + .map(|index| NodeId::new(format!("node:{index}")).expect("node")) + .collect(); + assert_eq!( + RevocationSnapshot::sign( + authority_credential(&root, &authority), + &authority, + oversized_claims, + &root.verifying_key(), + NOW, + ), + Err(ProtocolError::RevocationSetTooLarge) + ); +} + +#[test] +fn cache_rejects_rollback_and_changed_same_epoch_but_accepts_exact_replay() { + let temp = tempdir().expect("tempdir"); + let state_dir = temp.path().join("state"); + let root = key(5); + let authority = key(6); + let cache = RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("cache"); + let first = signed_snapshot(&root, &authority, 1, NOW); + assert_eq!(cache.accept(first.clone(), NOW), Ok(true)); + assert_eq!(cache.accept(first, NOW), Ok(false)); + + let mut changed_claims = signed_snapshot(&root, &authority, 1, NOW).claims; + changed_claims + .revoked_nodes + .insert(NodeId::new("node:changed").expect("node")); + let changed = RevocationSnapshot::sign( + authority_credential(&root, &authority), + &authority, + changed_claims, + &root.verifying_key(), + NOW, + ) + .expect("snapshot"); + assert_eq!( + cache.accept(changed, NOW), + Err(RuntimeError::RevocationEpochConflict) + ); + + let second = signed_snapshot(&root, &authority, 2, NOW + 1); + assert_eq!(cache.accept(second, NOW + 1), Ok(true)); + assert_eq!( + cache.accept(signed_snapshot(&root, &authority, 1, NOW), NOW + 1), + Err(RuntimeError::RevocationEpochRollback) + ); +} + +#[test] +fn self_revocation_freezes_the_issuer_against_higher_epoch_recovery() { + let temp = tempdir().expect("tempdir"); + let state_dir = temp.path().join("state"); + let root = key(13); + let authority = key(14); + let cache = RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("cache"); + let mut self_revoking_claims = signed_snapshot(&root, &authority, 1, NOW).claims; + self_revoking_claims + .revoked_authorities + .insert(authority_id()); + let self_revoking = RevocationSnapshot::sign( + authority_credential(&root, &authority), + &authority, + self_revoking_claims, + &root.verifying_key(), + NOW, + ) + .expect("snapshot"); + assert_eq!(cache.accept(self_revoking, NOW), Ok(true)); + + assert_eq!( + cache.accept(signed_snapshot(&root, &authority, 2, NOW + 1), NOW + 1,), + Err(RuntimeError::CredentialRevoked) + ); + assert_eq!(cache.epoch(), Some(1)); +} + +#[test] +fn decision_is_stale_first_and_checks_both_revocation_sets() { + let temp = tempdir().expect("tempdir"); + let state_dir = temp.path().join("state"); + let root = key(7); + let authority = key(8); + let cache = RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("cache"); + let mut claims = signed_snapshot(&root, &authority, 1, NOW).claims; + claims.revoked_authorities.insert(authority_id()); + claims + .revoked_nodes + .insert(NodeId::new("node:revoked").expect("node")); + let snapshot = RevocationSnapshot::sign( + authority_credential(&root, &authority), + &authority, + claims, + &root.verifying_key(), + NOW, + ) + .expect("snapshot"); + cache.accept(snapshot, NOW).expect("cache update"); + + let other_authority = NodeId::new("authority:other").expect("id"); + let allowed_node = NodeId::new("node:allowed").expect("id"); + let revoked_node = NodeId::new("node:revoked").expect("id"); + assert_eq!( + cache.decision(NOW, &authority_id(), &allowed_node), + RevocationDecision::Revoked + ); + assert_eq!( + cache.decision(NOW, &other_authority, &revoked_node), + RevocationDecision::Revoked + ); + assert_eq!( + cache.decision(NOW, &other_authority, &allowed_node), + RevocationDecision::CurrentAndAllowed + ); + assert_eq!( + cache.decision(NOW + 300_000, &authority_id(), &revoked_node), + RevocationDecision::Stale + ); +} + +#[test] +fn cache_recovers_atomically_and_rejects_unsafe_files() { + let root = key(9); + let authority = key(10); + let temp = tempdir().expect("tempdir"); + let state_dir = temp.path().join("state"); + { + let cache = + RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("cache"); + cache + .accept(signed_snapshot(&root, &authority, 1, NOW), NOW) + .expect("accepted"); + } + let reopened = + RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("reopen"); + assert_eq!(reopened.epoch(), Some(1)); + + let cache_path = state_dir.join("revocation-cache.json"); + fs::set_permissions(&cache_path, fs::Permissions::from_mode(0o644)).expect("chmod"); + assert_eq!( + RevocationCache::open(&state_dir, root.verifying_key(), domain()).map(|_| ()), + Err(RuntimeError::UnsafeRevocationState) + ); + + let symlink_dir = tempdir().expect("tempdir"); + let symlink_state = symlink_dir.path().join("state"); + fs::create_dir(&symlink_state).expect("state"); + fs::set_permissions(&symlink_state, fs::Permissions::from_mode(0o700)).expect("chmod state"); + let target = symlink_dir.path().join("target"); + fs::write(&target, b"{}").expect("target"); + symlink(&target, symlink_state.join("revocation-cache.json")).expect("symlink"); + assert_eq!( + RevocationCache::open(&symlink_state, root.verifying_key(), domain()).map(|_| ()), + Err(RuntimeError::UnsafeRevocationState) + ); +} + +#[test] +fn authority_epoch_is_monotonic_persistent_and_starts_at_one() { + let root = key(11); + let authority = key(12); + let temp = tempdir().expect("tempdir"); + let state_dir = temp.path().join("state"); + let credential = authority_credential(&root, &authority); + let first = { + let store = AuthorityRevocationStore::open( + &state_dir, + root.verifying_key(), + credential.clone(), + authority.clone(), + ) + .expect("store"); + store + .publish(NOW, BTreeSet::new(), BTreeSet::new()) + .expect("publish") + }; + assert_eq!(first.claims.epoch, 1); + let reopened = + AuthorityRevocationStore::open(&state_dir, root.verifying_key(), credential, authority) + .expect("reopen"); + let second = reopened + .publish(NOW + 1, BTreeSet::new(), BTreeSet::new()) + .expect("publish"); + assert_eq!(second.claims.epoch, 2); +} + +#[test] +fn authority_restart_reverifies_persisted_snapshot_and_configured_publisher() { + let root = key(15); + let authority = key(16); + let temp = tempdir().expect("tempdir"); + let state_dir = temp.path().join("state"); + let credential = authority_credential(&root, &authority); + let store = AuthorityRevocationStore::open( + &state_dir, + root.verifying_key(), + credential.clone(), + authority.clone(), + ) + .expect("store"); + store + .publish(NOW, BTreeSet::new(), BTreeSet::new()) + .expect("publish"); + drop(store); + + let mut renewed_claims = credential.claims.clone(); + renewed_claims.issued_at_ms = NOW; + renewed_claims.expires_at_ms = NOW + 1_200_000; + let renewed = SignedAuthorityCredential::issue(&root, renewed_claims).expect("renewed"); + assert!( + AuthorityRevocationStore::open( + &state_dir, + root.verifying_key(), + renewed, + authority.clone(), + ) + .is_ok() + ); + + let other_authority = key(17); + assert_eq!( + AuthorityRevocationStore::open( + &state_dir, + root.verifying_key(), + authority_credential(&root, &other_authority), + other_authority, + ) + .map(|_| ()), + Err(RuntimeError::CorruptRevocationState) + ); + + let path = state_dir.join("revocation-authority.json"); + let mut json: serde_json::Value = + serde_json::from_slice(&fs::read(&path).expect("state")).expect("json"); + json["latest_snapshot"]["signature_base64"] = serde_json::Value::String("A".repeat(88)); + fs::write(&path, serde_json::to_vec(&json).expect("json")).expect("tamper"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).expect("chmod"); + assert!( + AuthorityRevocationStore::open(&state_dir, root.verifying_key(), credential, authority,) + .is_err() + ); +} + +#[test] +fn cache_rejects_corrupt_oversize_and_insecure_directory_state() { + let root = key(18); + let temp = tempdir().expect("tempdir"); + + let corrupt_dir = temp.path().join("corrupt"); + fs::create_dir(&corrupt_dir).expect("dir"); + fs::set_permissions(&corrupt_dir, fs::Permissions::from_mode(0o700)).expect("chmod"); + let corrupt_path = corrupt_dir.join("revocation-cache.json"); + fs::write(&corrupt_path, b"{").expect("write"); + fs::set_permissions(&corrupt_path, fs::Permissions::from_mode(0o600)).expect("chmod"); + assert_eq!( + RevocationCache::open(&corrupt_dir, root.verifying_key(), domain()).map(|_| ()), + Err(RuntimeError::CorruptRevocationState) + ); + + let oversized_dir = temp.path().join("oversized"); + fs::create_dir(&oversized_dir).expect("dir"); + fs::set_permissions(&oversized_dir, fs::Permissions::from_mode(0o700)).expect("chmod"); + let oversized_path = oversized_dir.join("revocation-cache.json"); + fs::write(&oversized_path, vec![b'x'; 256 * 1024 + 1]).expect("write"); + fs::set_permissions(&oversized_path, fs::Permissions::from_mode(0o600)).expect("chmod"); + assert_eq!( + RevocationCache::open(&oversized_dir, root.verifying_key(), domain()).map(|_| ()), + Err(RuntimeError::UnsafeRevocationState) + ); + + let insecure_dir = temp.path().join("insecure"); + fs::create_dir(&insecure_dir).expect("dir"); + fs::set_permissions(&insecure_dir, fs::Permissions::from_mode(0o755)).expect("chmod"); + assert_eq!( + RevocationCache::open(&insecure_dir, root.verifying_key(), domain()).map(|_| ()), + Err(RuntimeError::UnsafeRevocationState) + ); +} From 44a7c9be3eda47304e7117e9981f3d24fb33c68a Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 21:53:10 +0800 Subject: [PATCH 22/67] [bug] Harden revocation policy state Root cause: Revocation cache trust was domain-wide. Routing reused admission-time authorization, and uncertain persistence did not freeze later mutations. Solution: Pin one stable publisher, carry verified envelope claims into private revocation subjects, filter routes at query time, reject stale timelines, and poison mutation after persistence errors. Risks: Cache state is now v0.3 and older cache files fail closed. Publisher issuer or key rollover requires an explicit migration. Dependency: c336e0d60fa50923d860a69cfe146c43da6b47a2 Links: plan/01-v1-multi-host-node-bootstrap.md Task 7 Post-mortem: Tests cover publisher substitution, post-rename uncertainty, authorization at use, raw-chain forgery, and proxy bypass with a real sentinel. --- ROADMAP.md | 9 + src/protocol/envelope.rs | 29 ++- src/runtime/directory.rs | 107 ++++++++-- src/runtime/error.rs | 4 + src/runtime/revocation.rs | 394 ++++++++++++++++++++++++++++++++++--- src/transport/directory.rs | 19 +- tests/http_revocation.rs | 195 ++++++++++++++++-- tests/revocation.rs | 175 ++++++++++++++-- 8 files changed, 840 insertions(+), 92 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 73c163f..e9aad3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,15 @@ - **Boundary**: The existing Directory still carries a fixed validation timestamp, and Task 7 only integrates the concrete registration endpoint plus a central guard. Task 12 must compose a live clock and apply the guard to all effectful Runtime routes. Task 8 must authenticate TLS identity before calling the verified-credential guard. Cross-process writer exclusion remains Task 9 work. - **Deferred must-fix**: Task 5 `encode_handoff` still uses growable `serde_json::to_vec` before the result is placed in `Zeroizing`. This TTY handoff path must be corrected before Task 10 enables the CLI; Task 7 deliberately does not change it. +## 2026-08-14 21:52 CST + +- **Change**: Hardened Task 7 after review by pinning every revocation cache to one Root-authorized stable publisher binding, poisoning mutation after uncertain persistence, filtering Directory candidates against current revocation state, and removing the raw credential-chain enforcement API. +- **Files**: `src/protocol/envelope.rs`, `src/runtime/directory.rs`, `src/runtime/error.rs`, `src/runtime/revocation.rs`, `src/transport/directory.rs`, `tests/revocation.rs`, `tests/http_revocation.rs`, `ROADMAP.md`, and ignored Task 7 review report/evidence. +- **Root cause**: Security boundary omission — the first cache accepted any Root-authorized publish credential in the domain, a post-rename directory-sync error left disk and memory potentially divergent without freezing later writes, and Directory query returned a previously registered provider without reevaluating revocation. The enforcement helper also accepted a raw `CredentialChain`, making its verified-chain precondition caller-enforced rather than type-enforced. +- **Solution**: Persist and validate a v0.3 stable `(domain, authority_id, signing key)` publisher binding with an explicit publish scope; accept same-key Root credential renewal but reject issuer/key rollover. Treat every persistence error as mutation poison until restart. Carry verified claims out of the single envelope-open operation into a private revocation subject, store it with registration, and return only `CurrentAndAllowed` candidates; stale policy returns HTTP 200 with an empty set. Reject stale incoming snapshots and higher-epoch timestamp regression before persistence. +- **Post-mortem**: The initial tests emphasized signature/epoch validity but did not model publisher substitution, uncertain rename durability, or the time gap between registration and routing. Future security reviews must enumerate stable trust pins, explicitly model every atomic-write error point, and retest authorization at each use boundary rather than only at admission. +- **Compatibility**: Legacy `DirectoryRegistry::register/query` remains available for the loopback router. Revocation-aware transport uses the verified registration/query path. Cache persistence is deliberately versioned to v0.3 and fails closed on v0.2; publisher rollover requires a future explicit migration. + ## 2026-08-15 01:18 CST - **Change**: Replaced enrollment request `serde_json::to_vec` serialization with one fixed-capacity zeroizing writer shared by HTTP transmission and exact-request digesting. diff --git a/src/protocol/envelope.rs b/src/protocol/envelope.rs index b1e2a38..8797b40 100644 --- a/src/protocol/envelope.rs +++ b/src/protocol/envelope.rs @@ -3,7 +3,7 @@ use ed25519_dalek::{Signature, Signer, SigningKey}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use super::{ - CredentialChain, DomainId, KERNEL_VERSION, NodeId, NodeRole, ProtocolError, + CredentialChain, DomainId, KERNEL_VERSION, NodeId, NodeRole, ProtocolError, VerifiedNodeClaims, verify_credential_chain, }; use crate::{KERNEL_VERSION_V1, KERNEL_VERSION_V2, SupportedKernelVersion}; @@ -73,6 +73,24 @@ impl WireEnvelope { expected_role: NodeRole, now_ms: i64, ) -> Result { + self.open_with_verified_claims( + expected_object_type, + root, + expected_domain, + expected_role, + now_ms, + ) + .map(|opened| opened.payload) + } + + pub(crate) fn open_with_verified_claims( + &self, + expected_object_type: &str, + root: &ed25519_dalek::VerifyingKey, + expected_domain: &DomainId, + expected_role: NodeRole, + now_ms: i64, + ) -> Result, ProtocolError> { if self.kernel_version == KERNEL_VERSION_V1 { return Err(ProtocolError::MigrationRequiredV1Credential); } @@ -111,10 +129,17 @@ impl WireEnvelope { &signature, ) .map_err(|_| ProtocolError::InvalidEnvelopeSignature)?; - serde_json::from_slice(&payload_bytes).map_err(|_| ProtocolError::SerializationFailed) + let payload = serde_json::from_slice(&payload_bytes) + .map_err(|_| ProtocolError::SerializationFailed)?; + Ok(OpenedEnvelope { payload, claims }) } } +pub(crate) struct OpenedEnvelope { + pub(crate) payload: T, + pub(crate) claims: VerifiedNodeClaims, +} + #[derive(Deserialize)] struct EnvelopeVersion { kernel_version: String, diff --git a/src/runtime/directory.rs b/src/runtime/directory.rs index b25d69e..258f30a 100644 --- a/src/runtime/directory.rs +++ b/src/runtime/directory.rs @@ -2,13 +2,21 @@ use std::{collections::HashMap, net::IpAddr, sync::Arc}; use tokio::sync::RwLock; -use crate::protocol::{CandidateSet, CapabilityId, CapabilityManifest, NodeId, RouteQuery}; +use crate::protocol::{ + CandidateSet, CapabilityId, CapabilityManifest, NodeId, RouteQuery, VerifiedNodeClaims, +}; -use super::RuntimeError; +use super::{RevocationGuard, RuntimeError, revocation::VerifiedRevocationSubject}; + +#[derive(Debug, Clone)] +struct RegisteredManifest { + manifest: CapabilityManifest, + verified_subject: Option, +} #[derive(Debug, Clone, Default)] pub struct DirectoryRegistry { - manifests: Arc>>, + manifests: Arc>>, } impl DirectoryRegistry { @@ -22,19 +30,31 @@ impl DirectoryRegistry { manifest: CapabilityManifest, now_unix_ms: u64, ) -> Result<(), RuntimeError> { - if issuer != &manifest.provider { - return Err(RuntimeError::ManifestProviderMismatch); - } - validate_loopback_endpoint(&manifest.endpoint)?; - if manifest.expires_at_unix_ms <= now_unix_ms { - return Err(RuntimeError::Protocol( - crate::protocol::ProtocolError::CredentialExpired, - )); - } - self.manifests - .write() - .await - .insert(manifest.capability_id.clone(), manifest); + validate_manifest(issuer, &manifest, now_unix_ms)?; + self.manifests.write().await.insert( + manifest.capability_id.clone(), + RegisteredManifest { + manifest, + verified_subject: None, + }, + ); + Ok(()) + } + + pub(crate) async fn register_verified( + &self, + claims: &VerifiedNodeClaims, + manifest: CapabilityManifest, + now_unix_ms: u64, + ) -> Result<(), RuntimeError> { + validate_manifest(&claims.node_id, &manifest, now_unix_ms)?; + self.manifests.write().await.insert( + manifest.capability_id.clone(), + RegisteredManifest { + manifest, + verified_subject: Some(VerifiedRevocationSubject::from_claims(claims)), + }, + ); Ok(()) } @@ -44,11 +64,41 @@ impl DirectoryRegistry { .read() .await .values() - .filter(|manifest| { - manifest.expires_at_unix_ms > now_unix_ms - && manifest.capability_kind_version() == query.required_capability + .filter(|entry| { + entry.manifest.expires_at_unix_ms > now_unix_ms + && entry.manifest.capability_kind_version() == query.required_capability + }) + .map(|entry| entry.manifest.clone()) + .collect(); + candidates.sort_by(|left, right| { + left.capability_id + .as_str() + .cmp(right.capability_id.as_str()) + }); + CandidateSet { query, candidates } + } + + pub(crate) async fn query_with_revocation( + &self, + query: RouteQuery, + now_unix_ms: u64, + guard: &RevocationGuard, + ) -> CandidateSet { + let now_ms = i64::try_from(now_unix_ms).unwrap_or(i64::MAX); + let mut candidates: Vec<_> = self + .manifests + .read() + .await + .values() + .filter(|entry| { + entry.manifest.expires_at_unix_ms > now_unix_ms + && entry.manifest.capability_kind_version() == query.required_capability + && entry + .verified_subject + .as_ref() + .is_some_and(|subject| guard.effectful_subject(now_ms, subject).is_ok()) }) - .cloned() + .map(|entry| entry.manifest.clone()) .collect(); candidates.sort_by(|left, right| { left.capability_id @@ -59,6 +109,23 @@ impl DirectoryRegistry { } } +fn validate_manifest( + issuer: &NodeId, + manifest: &CapabilityManifest, + now_unix_ms: u64, +) -> Result<(), RuntimeError> { + if issuer != &manifest.provider { + return Err(RuntimeError::ManifestProviderMismatch); + } + validate_loopback_endpoint(&manifest.endpoint)?; + if manifest.expires_at_unix_ms <= now_unix_ms { + return Err(RuntimeError::Protocol( + crate::protocol::ProtocolError::CredentialExpired, + )); + } + Ok(()) +} + fn validate_loopback_endpoint(endpoint: &str) -> Result<(), RuntimeError> { let url = reqwest::Url::parse(endpoint).map_err(|_| RuntimeError::UnsupportedNonLoopbackTransport)?; diff --git a/src/runtime/error.rs b/src/runtime/error.rs index 82a2b9e..3774465 100644 --- a/src/runtime/error.rs +++ b/src/runtime/error.rs @@ -32,6 +32,10 @@ pub enum RuntimeError { CredentialRevoked, RevocationEpochRollback, RevocationEpochConflict, + RevocationPublisherMismatch, + RevocationSnapshotStale, + RevocationTimelineRollback, + RevocationPersistenceUnavailable, CorruptRevocationState, UnsafeRevocationState, Protocol(ProtocolError), diff --git a/src/runtime/revocation.rs b/src/runtime/revocation.rs index e07c226..7e89b38 100644 --- a/src/runtime/revocation.rs +++ b/src/runtime/revocation.rs @@ -11,8 +11,8 @@ use ed25519_dalek::{SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; use crate::protocol::{ - CredentialChain, DomainId, NodeId, REVOCATION_FORMAT_VERSION, RevocationClaims, - RevocationDecision, RevocationSnapshot, SignedAuthorityCredential, + AuthorityScope, DomainId, NodeId, REVOCATION_FORMAT_VERSION, RevocationClaims, + RevocationDecision, RevocationSnapshot, SignedAuthorityCredential, VerifiedNodeClaims, }; use super::{RuntimeError, key_store::atomic_write_owner_only}; @@ -20,7 +20,7 @@ use super::{RuntimeError, key_store::atomic_write_owner_only}; const AUTHORITY_STATE_FILE: &str = "revocation-authority.json"; const CACHE_FILE: &str = "revocation-cache.json"; const AUTHORITY_STATE_VERSION: &str = "agenet.revocation-authority-state.v0.2"; -const CACHE_STATE_VERSION: &str = "agenet.revocation-cache-state.v0.2"; +const CACHE_STATE_VERSION: &str = "agenet.revocation-cache-state.v0.3"; const MAX_STATE_BYTES: u64 = 256 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -35,15 +35,77 @@ struct AuthorityState { #[serde(deny_unknown_fields)] struct CacheState { format_version: String, + publisher: PublisherBinding, snapshot: RevocationSnapshot, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PublisherBinding { + domain_id: DomainId, + authority_id: NodeId, + signing_public_key_base64: String, +} + +impl PublisherBinding { + fn verified( + root: &VerifyingKey, + credential: &SignedAuthorityCredential, + ) -> Result { + crate::protocol::verify_authority_credential( + root, + credential, + credential.claims.issued_at_ms, + )?; + if !credential + .claims + .scopes + .contains(&AuthorityScope::PublishRevocationSnapshot) + { + return Err(RuntimeError::RevocationPublisherMismatch); + } + Ok(Self { + domain_id: credential.claims.domain_id.clone(), + authority_id: credential.claims.authority_id.clone(), + signing_public_key_base64: credential.claims.signing_public_key_base64.clone(), + }) + } + + fn matches(&self, credential: &SignedAuthorityCredential) -> bool { + self.domain_id == credential.claims.domain_id + && self.authority_id == credential.claims.authority_id + && self.signing_public_key_base64 == credential.claims.signing_public_key_base64 + && credential + .claims + .scopes + .contains(&AuthorityScope::PublishRevocationSnapshot) + } +} + +trait StateWriter: Send + Sync { + fn write(&self, path: &Path, bytes: &[u8]) -> Result<(), RuntimeError>; +} + +struct AtomicStateWriter; + +impl StateWriter for AtomicStateWriter { + fn write(&self, path: &Path, bytes: &[u8]) -> Result<(), RuntimeError> { + atomic_write_owner_only(path, bytes, true) + } +} + +struct MutableState { + value: T, + persistence_unavailable: bool, +} + pub struct AuthorityRevocationStore { path: PathBuf, root_public_key: VerifyingKey, authority_credential: SignedAuthorityCredential, authority_signing_key: SigningKey, - state: Mutex, + writer: Arc, + state: Mutex>, } impl AuthorityRevocationStore { @@ -52,6 +114,22 @@ impl AuthorityRevocationStore { root_public_key: VerifyingKey, authority_credential: SignedAuthorityCredential, authority_signing_key: SigningKey, + ) -> Result { + Self::open_with_writer( + directory, + root_public_key, + authority_credential, + authority_signing_key, + Arc::new(AtomicStateWriter), + ) + } + + fn open_with_writer( + directory: &Path, + root_public_key: VerifyingKey, + authority_credential: SignedAuthorityCredential, + authority_signing_key: SigningKey, + writer: Arc, ) -> Result { prepare_owner_directory(directory)?; crate::protocol::verify_authority_credential( @@ -108,7 +186,11 @@ impl AuthorityRevocationStore { root_public_key, authority_credential, authority_signing_key, - state: Mutex::new(state), + writer, + state: Mutex::new(MutableState { + value: state, + persistence_unavailable: false, + }), }) } @@ -119,7 +201,11 @@ impl AuthorityRevocationStore { revoked_nodes: BTreeSet, ) -> Result { let mut state = lock(&self.state)?; + if state.persistence_unavailable { + return Err(RuntimeError::RevocationPersistenceUnavailable); + } let epoch = state + .value .last_epoch .checked_add(1) .ok_or(RuntimeError::CorruptRevocationState)?; @@ -144,13 +230,16 @@ impl AuthorityRevocationStore { last_epoch: epoch, latest_snapshot: Some(snapshot.clone()), }; - persist(&self.path, &next)?; - *state = next; + if persist(self.writer.as_ref(), &self.path, &next).is_err() { + state.persistence_unavailable = true; + return Err(RuntimeError::RevocationPersistenceUnavailable); + } + state.value = next; Ok(snapshot) } pub fn latest(&self) -> Result, RuntimeError> { - Ok(lock(&self.state)?.latest_snapshot.clone()) + Ok(lock(&self.state)?.value.latest_snapshot.clone()) } } @@ -159,7 +248,9 @@ pub struct RevocationCache { path: PathBuf, root_public_key: VerifyingKey, expected_domain: DomainId, - snapshot: Arc>>, + publisher: PublisherBinding, + writer: Arc, + snapshot: Arc>>>, } impl RevocationCache { @@ -167,8 +258,29 @@ impl RevocationCache { directory: &Path, root_public_key: VerifyingKey, expected_domain: DomainId, + trusted_publisher: SignedAuthorityCredential, + ) -> Result { + Self::open_with_writer( + directory, + root_public_key, + expected_domain, + trusted_publisher, + Arc::new(AtomicStateWriter), + ) + } + + fn open_with_writer( + directory: &Path, + root_public_key: VerifyingKey, + expected_domain: DomainId, + trusted_publisher: SignedAuthorityCredential, + writer: Arc, ) -> Result { prepare_owner_directory(directory)?; + let publisher = PublisherBinding::verified(&root_public_key, &trusted_publisher)?; + if publisher.domain_id != expected_domain { + return Err(RuntimeError::RevocationPublisherMismatch); + } let path = directory.join(CACHE_FILE); let snapshot = match read_owner_state::(&path)? { Some(state) => { @@ -180,6 +292,11 @@ impl RevocationCache { &expected_domain, state.snapshot.claims.generated_at_ms, )?; + if state.publisher != publisher + || !publisher.matches(&state.snapshot.authority_credential) + { + return Err(RuntimeError::RevocationPublisherMismatch); + } Some(state.snapshot) } None => None, @@ -188,14 +305,28 @@ impl RevocationCache { path, root_public_key, expected_domain, - snapshot: Arc::new(Mutex::new(snapshot)), + publisher, + writer, + snapshot: Arc::new(Mutex::new(MutableState { + value: snapshot, + persistence_unavailable: false, + })), }) } pub fn accept(&self, incoming: RevocationSnapshot, now_ms: i64) -> Result { incoming.verify(&self.root_public_key, &self.expected_domain, now_ms)?; + if !self.publisher.matches(&incoming.authority_credential) { + return Err(RuntimeError::RevocationPublisherMismatch); + } + if now_ms >= incoming.claims.next_update_ms { + return Err(RuntimeError::RevocationSnapshotStale); + } let mut current = lock(&self.snapshot)?; - if let Some(existing) = current.as_ref() { + if current.persistence_unavailable { + return Err(RuntimeError::RevocationPersistenceUnavailable); + } + if let Some(existing) = current.value.as_ref() { if exact_snapshot_bytes(existing)? == exact_snapshot_bytes(&incoming)? { return Ok(false); } @@ -212,13 +343,22 @@ impl RevocationCache { if incoming.claims.epoch == existing.claims.epoch { return Err(RuntimeError::RevocationEpochConflict); } + if incoming.claims.generated_at_ms < existing.claims.generated_at_ms + || incoming.claims.next_update_ms < existing.claims.next_update_ms + { + return Err(RuntimeError::RevocationTimelineRollback); + } } let state = CacheState { format_version: CACHE_STATE_VERSION.to_owned(), + publisher: self.publisher.clone(), snapshot: incoming.clone(), }; - persist(&self.path, &state)?; - *current = Some(incoming); + if persist(self.writer.as_ref(), &self.path, &state).is_err() { + current.persistence_unavailable = true; + return Err(RuntimeError::RevocationPersistenceUnavailable); + } + current.value = Some(incoming); Ok(true) } @@ -231,7 +371,7 @@ impl RevocationCache { let Ok(current) = self.snapshot.lock() else { return RevocationDecision::Stale; }; - let Some(snapshot) = current.as_ref() else { + let Some(snapshot) = current.value.as_ref() else { return RevocationDecision::Stale; }; if now_ms >= snapshot.claims.next_update_ms { @@ -249,7 +389,22 @@ impl RevocationCache { self.snapshot .lock() .ok() - .and_then(|snapshot| snapshot.as_ref().map(|value| value.claims.epoch)) + .and_then(|snapshot| snapshot.value.as_ref().map(|value| value.claims.epoch)) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct VerifiedRevocationSubject { + authority_id: NodeId, + node_id: NodeId, +} + +impl VerifiedRevocationSubject { + pub(crate) fn from_claims(claims: &VerifiedNodeClaims) -> Self { + Self { + authority_id: claims.authority_id.clone(), + node_id: claims.node_id.clone(), + } } } @@ -263,32 +418,27 @@ impl RevocationGuard { Self { cache } } - pub fn effectful( + pub(crate) fn effectful_subject( &self, now_ms: i64, - authority_id: &NodeId, - node_id: &NodeId, + subject: &VerifiedRevocationSubject, ) -> Result<(), RuntimeError> { - match self.cache.decision(now_ms, authority_id, node_id) { + match self + .cache + .decision(now_ms, &subject.authority_id, &subject.node_id) + { RevocationDecision::CurrentAndAllowed => Ok(()), RevocationDecision::Revoked => Err(RuntimeError::CredentialRevoked), RevocationDecision::Stale => Err(RuntimeError::RevocationStateStale), } } - /// Applies revocation policy after the caller has cryptographically - /// verified this credential chain for the request's role and domain. - /// - /// This method only maps already-authenticated identity claims to the - /// current cache. Task 8 must complete TLS identity verification before - /// invoking it; it is not a replacement for `verify_credential_chain`. - pub fn effectful_verified_credential( + pub(crate) fn effectful_verified_claims( &self, now_ms: i64, - chain: &CredentialChain, + claims: &VerifiedNodeClaims, ) -> Result<(), RuntimeError> { - let node = chain.node.decode_claims()?; - self.effectful(now_ms, &chain.authority.claims.authority_id, &node.node_id) + self.effectful_subject(now_ms, &VerifiedRevocationSubject::from_claims(claims)) } pub fn read_only( @@ -369,12 +519,16 @@ fn read_owner_state Deserialize<'de>>(path: &Path) -> Result(path: &Path, value: &T) -> Result<(), RuntimeError> { +fn persist( + writer: &dyn StateWriter, + path: &Path, + value: &T, +) -> Result<(), RuntimeError> { let bytes = serde_json::to_vec(value).map_err(|_| RuntimeError::CorruptRevocationState)?; if bytes.len() as u64 > MAX_STATE_BYTES { return Err(RuntimeError::CorruptRevocationState); } - atomic_write_owner_only(path, &bytes, true) + writer.write(path, &bytes) } fn effective_uid() -> u32 { @@ -402,3 +556,181 @@ fn same_publisher_binding( .scopes .contains(&crate::protocol::AuthorityScope::PublishRevocationSnapshot) } + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeSet, + fs::{self, OpenOptions}, + io::Write, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, + sync::Arc, + }; + + use base64::{Engine, engine::general_purpose::STANDARD}; + use ed25519_dalek::SigningKey; + use tempfile::tempdir; + + use crate::protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, DomainId, NodeId, + SignedAuthorityCredential, + }; + + use super::{AuthorityRevocationStore, RevocationCache, RuntimeError, StateWriter}; + + const NOW: i64 = 1_800_000_000_000; + + struct PostRenameDirectorySyncFailure; + + impl StateWriter for PostRenameDirectorySyncFailure { + fn write(&self, path: &std::path::Path, bytes: &[u8]) -> Result<(), RuntimeError> { + let parent = path.parent().ok_or(RuntimeError::Io)?; + let temp = parent.join(".revocation-post-rename-fault.tmp"); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&temp)?; + file.write_all(bytes)?; + file.flush()?; + file.sync_all()?; + drop(file); + fs::rename(temp, path)?; + assert_eq!(fs::metadata(path)?.permissions().mode() & 0o777, 0o600); + Err(RuntimeError::Io) + } + } + + fn fixture() -> (SigningKey, SigningKey, DomainId, SignedAuthorityCredential) { + let root = SigningKey::from_bytes(&[71; 32]); + let authority = SigningKey::from_bytes(&[72; 32]); + let domain = DomainId::new("domain:persistence-fault").expect("domain"); + let credential = SignedAuthorityCredential::issue( + &root, + AuthorityClaims { + domain_id: domain.clone(), + authority_id: NodeId::new("authority:persistence-fault").expect("authority"), + signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([AuthorityScope::PublishRevocationSnapshot]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Base]), + maximum_node_lifetime_ms: 60_000, + issued_at_ms: NOW - 1_000, + expires_at_ms: NOW + 1_000_000, + }, + ) + .expect("credential"); + (root, authority, domain, credential) + } + + #[test] + fn authority_poisoned_after_post_publish_failure_and_restart_recovers_disk_epoch() { + let (root, authority, _domain, credential) = fixture(); + let temp = tempdir().expect("tempdir"); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)).expect("chmod"); + let initial = AuthorityRevocationStore::open( + temp.path(), + root.verifying_key(), + credential.clone(), + authority.clone(), + ) + .expect("initial store"); + initial + .publish(NOW, BTreeSet::new(), BTreeSet::new()) + .expect("initial epoch"); + drop(initial); + let store = AuthorityRevocationStore::open_with_writer( + temp.path(), + root.verifying_key(), + credential.clone(), + authority.clone(), + Arc::new(PostRenameDirectorySyncFailure), + ) + .expect("store"); + assert_eq!( + store.publish(NOW + 1, BTreeSet::new(), BTreeSet::new()), + Err(RuntimeError::RevocationPersistenceUnavailable) + ); + assert_eq!( + store + .latest() + .expect("read") + .expect("old snapshot") + .claims + .epoch, + 1 + ); + assert_eq!( + store.publish(NOW + 1, BTreeSet::new(), BTreeSet::new()), + Err(RuntimeError::RevocationPersistenceUnavailable) + ); + + let reopened = AuthorityRevocationStore::open( + temp.path(), + root.verifying_key(), + credential, + authority, + ) + .expect("restart"); + assert_eq!( + reopened + .latest() + .expect("latest") + .expect("snapshot") + .claims + .epoch, + 2 + ); + } + + #[test] + fn cache_poisoned_after_post_publish_failure_and_restart_recovers_disk_epoch() { + let (root, authority, domain, credential) = fixture(); + let temp = tempdir().expect("tempdir"); + let authority_store = AuthorityRevocationStore::open( + &temp.path().join("authority"), + root.verifying_key(), + credential.clone(), + authority, + ) + .expect("authority store"); + let first = authority_store + .publish(NOW, BTreeSet::new(), BTreeSet::new()) + .expect("first snapshot"); + let second = authority_store + .publish(NOW + 1, BTreeSet::new(), BTreeSet::new()) + .expect("second snapshot"); + let cache_dir = temp.path().join("cache"); + let initial = RevocationCache::open( + &cache_dir, + root.verifying_key(), + domain.clone(), + credential.clone(), + ) + .expect("initial cache"); + initial.accept(first, NOW).expect("initial epoch"); + drop(initial); + let cache = RevocationCache::open_with_writer( + &cache_dir, + root.verifying_key(), + domain.clone(), + credential.clone(), + Arc::new(PostRenameDirectorySyncFailure), + ) + .expect("fault cache"); + assert_eq!( + cache.accept(second.clone(), NOW + 1), + Err(RuntimeError::RevocationPersistenceUnavailable) + ); + assert_eq!(cache.epoch(), Some(1)); + assert_eq!( + cache.accept(second, NOW + 1), + Err(RuntimeError::RevocationPersistenceUnavailable) + ); + + let reopened = RevocationCache::open(&cache_dir, root.verifying_key(), domain, credential) + .expect("restart"); + assert_eq!(reopened.epoch(), Some(2)); + } +} diff --git a/src/transport/directory.rs b/src/transport/directory.rs index 99fb856..6c696dc 100644 --- a/src/transport/directory.rs +++ b/src/transport/directory.rs @@ -95,19 +95,18 @@ async fn register( Ok(value) => value, Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; - let manifest: CapabilityManifest = match envelope.open( + let opened = match envelope.open_with_verified_claims::( "capability.manifest.v1", state.identity.root(), state.identity.domain_id(), crate::protocol::NodeRole::Executor, now_ms, ) { - Ok(manifest) => manifest, + Ok(opened) => opened, Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; if let Some(guard) = &state.revocations - && let Err(policy_error) = - guard.effectful_verified_credential(now_ms, &envelope.credential_chain) + && let Err(policy_error) = guard.effectful_verified_claims(now_ms, &opened.claims) { return match policy_error { RuntimeError::RevocationStateStale => { @@ -121,7 +120,7 @@ async fn register( } if state .registry - .register(&envelope.issuer_id, manifest, state.now_unix_ms) + .register_verified(&opened.claims, opened.payload, state.now_unix_ms) .await .is_err() { @@ -158,7 +157,15 @@ async fn query( Ok(query) => query, Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; - let candidates = state.registry.query(query, state.now_unix_ms).await; + let candidates = match &state.revocations { + Some(guard) => { + state + .registry + .query_with_revocation(query, state.now_unix_ms, guard) + .await + } + None => state.registry.query(query, state.now_unix_ms).await, + }; match state.identity.seal("route.candidates.v1", &candidates) { Ok(response) => (StatusCode::OK, Json(response)).into_response(), Err(_) => error_response(StatusCode::INTERNAL_SERVER_ERROR, "InternalError"), diff --git a/tests/http_revocation.rs b/tests/http_revocation.rs index 9609be8..dcd48a2 100644 --- a/tests/http_revocation.rs +++ b/tests/http_revocation.rs @@ -11,9 +11,9 @@ use std::{ use agenet::{ bootstrap::network::NetworkBoundary, protocol::{ - AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityId, CapabilityManifest, - NodeId, NodeRole, RevocationClaims, RevocationSnapshot, RouteQuery, SideEffectProfile, - SignedAuthorityCredential, + AuthorityClaims, AuthorityScope, BootstrapProfile, CandidateSet, CapabilityId, + CapabilityManifest, NodeId, NodeRole, RevocationClaims, RevocationSnapshot, RouteQuery, + SideEffectProfile, SignedAuthorityCredential, WireEnvelope, }, runtime::{ AuthorityRevocationStore, DirectoryRegistry, NodeIdentity, RevocationCache, RevocationGuard, @@ -66,6 +66,16 @@ fn snapshot( authority: &SigningKey, epoch: u64, revoked_nodes: BTreeSet, +) -> RevocationSnapshot { + snapshot_with_revocations(root, authority, epoch, BTreeSet::new(), revoked_nodes) +} + +fn snapshot_with_revocations( + root: &SigningKey, + authority: &SigningKey, + epoch: u64, + revoked_authorities: BTreeSet, + revoked_nodes: BTreeSet, ) -> RevocationSnapshot { RevocationSnapshot::sign( publisher_credential(root, authority), @@ -77,7 +87,7 @@ fn snapshot( epoch, generated_at_ms: NOW, next_update_ms: NOW + 300_000, - revoked_authorities: BTreeSet::new(), + revoked_authorities, revoked_nodes, }, &root.verifying_key(), @@ -195,11 +205,13 @@ async fn directory_registration_fails_stale_and_revoked_but_query_health_remain_ &temp.path().join("cache"), root.verifying_key(), common::domain_id(), + publisher_credential(&root, &key(6)), ) .expect("cache"); + let registry = DirectoryRegistry::new(); let app = directory_router_with_revocation( - DirectoryRegistry::new(), - directory, + registry.clone(), + directory.clone(), NOW as u64, RevocationGuard::new(cache.clone()), ); @@ -214,12 +226,20 @@ async fn directory_registration_fails_stale_and_revoked_but_query_health_remain_ side_effect: SideEffectProfile::ReadOnly, endpoint: "http://127.0.0.1:40001".to_owned(), evidence_types: vec!["metrics.v1".to_owned()], - expires_at_unix_ms: NOW as u64 + 30_000, + expires_at_unix_ms: NOW as u64 + 600_000, }; let envelope = executor .seal("capability.manifest.v1", &manifest) .expect("envelope"); + let mut forged_chain = envelope.clone(); + forged_chain.credential_chain.authority.claims.authority_id = + NodeId::new("authority:forged").expect("authority"); + assert_eq!( + register(&app, &forged_chain).await.status(), + StatusCode::UNAUTHORIZED + ); + let stale = register(&app, &envelope).await; assert_eq!(stale.status(), StatusCode::CONFLICT); assert_eq!(error_code(stale).await, "RevocationStateStale"); @@ -241,23 +261,29 @@ async fn directory_registration_fails_stale_and_revoked_but_query_health_remain_ }, ) .expect("query"); - let query_response = app - .clone() - .oneshot( - Request::post("/v0/routes/query") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&query).expect("json"))) - .expect("request"), - ) - .await - .expect("query response"); + let query_response = route_query(&app, &query).await; assert_eq!(query_response.status(), StatusCode::OK); + assert!( + route_candidates(query_response, &directory, NOW) + .await + .candidates + .is_empty() + ); let authority = key(6); cache .accept(snapshot(&root, &authority, 1, BTreeSet::new()), NOW) .expect("refresh"); assert_eq!(register(&app, &envelope).await.status(), StatusCode::OK); + let allowed = route_query(&app, &query).await; + assert_eq!(allowed.status(), StatusCode::OK); + assert_eq!( + route_candidates(allowed, &directory, NOW) + .await + .candidates + .len(), + 1 + ); cache .accept( @@ -273,6 +299,88 @@ async fn directory_registration_fails_stale_and_revoked_but_query_health_remain_ let revoked = register(&app, &envelope).await; assert_eq!(revoked.status(), StatusCode::FORBIDDEN); assert_eq!(error_code(revoked).await, "CredentialRevoked"); + let revoked_query = route_query(&app, &query).await; + assert_eq!(revoked_query.status(), StatusCode::OK); + assert!( + route_candidates(revoked_query, &directory, NOW) + .await + .candidates + .is_empty() + ); + + cache + .accept( + snapshot_with_revocations( + &root, + &authority, + 3, + BTreeSet::from([NodeId::new("authority:test").expect("authority")]), + BTreeSet::new(), + ), + NOW, + ) + .expect("authority revocation"); + let authority_revoked_query = route_query(&app, &query).await; + assert_eq!(authority_revoked_query.status(), StatusCode::OK); + assert!( + route_candidates(authority_revoked_query, &directory, NOW) + .await + .candidates + .is_empty() + ); + + let stale_directory_key = key(25); + let stale_directory = NodeIdentity::new( + stale_directory_key.clone(), + common::credential_chain( + &root, + &stale_directory_key, + "node:directory-stale", + NodeRole::Directory, + (NOW + 300_000) as u64, + ), + NodeRole::Directory, + root.verifying_key(), + (NOW + 300_000) as u64, + ) + .expect("stale directory"); + let stale_requester_key = key(26); + let stale_requester = NodeIdentity::new( + stale_requester_key.clone(), + common::credential_chain( + &root, + &stale_requester_key, + "node:requester-stale", + NodeRole::Requester, + (NOW + 300_000) as u64, + ), + NodeRole::Requester, + root.verifying_key(), + (NOW + 300_000) as u64, + ) + .expect("stale requester"); + let stale_app = directory_router_with_revocation( + registry, + stale_directory.clone(), + (NOW + 300_000) as u64, + RevocationGuard::new(cache), + ); + let stale_query = stale_requester + .seal( + "route.query.v1", + &RouteQuery { + required_capability: "source.metrics.v1".to_owned(), + }, + ) + .expect("stale query"); + let stale_response = route_query(&stale_app, &stale_query).await; + assert_eq!(stale_response.status(), StatusCode::OK); + assert!( + route_candidates(stale_response, &stale_directory, NOW + 300_000) + .await + .candidates + .is_empty() + ); } #[tokio::test] @@ -298,6 +406,7 @@ async fn refresh_client_rejects_redirects_and_oversize() { &temp.path().join("cache"), root.verifying_key(), common::domain_id(), + publisher_credential(&root, &authority), ) .expect("cache"); let client = RevocationClient::new( @@ -363,6 +472,7 @@ async fn refresh_loop_survives_transient_failure_and_later_recovers() { &temp.path().join("cache"), root.verifying_key(), common::domain_id(), + publisher_credential(&root, &authority), ) .expect("cache"); let client = RevocationClient::new( @@ -462,6 +572,7 @@ fn revocation_client_bypasses_ambient_proxy_in_isolated_child() { &temp.path().join("cache"), root.verifying_key(), common::domain_id(), + publisher_credential(&root, &authority), ) .expect("cache"); let client = RevocationClient::new( @@ -478,14 +589,19 @@ fn revocation_client_bypasses_ambient_proxy_in_isolated_child() { return; } + let proxy = StdTcpListener::bind("127.0.0.1:0").expect("proxy sentinel"); + proxy.set_nonblocking(true).expect("nonblocking proxy"); + let proxy_url = format!("http://{}", proxy.local_addr().expect("proxy address")); let output = std::process::Command::new(std::env::current_exe().expect("test binary")) .arg("--exact") .arg("revocation_client_bypasses_ambient_proxy_in_isolated_child") .arg("--nocapture") .env(MARKER, "1") - .env("HTTP_PROXY", "http://127.0.0.1:9") - .env("HTTPS_PROXY", "http://127.0.0.1:9") - .env("ALL_PROXY", "http://127.0.0.1:9") + .env("HTTP_PROXY", &proxy_url) + .env("HTTPS_PROXY", &proxy_url) + .env("ALL_PROXY", &proxy_url) + .env_remove("NO_PROXY") + .env_remove("no_proxy") .output() .expect("child"); assert!( @@ -493,6 +609,10 @@ fn revocation_client_bypasses_ambient_proxy_in_isolated_child() { "isolated proxy test failed: {}", String::from_utf8_lossy(&output.stderr) ); + assert_eq!( + proxy.accept().expect_err("proxy must remain unused").kind(), + std::io::ErrorKind::WouldBlock + ); } async fn register( @@ -510,6 +630,41 @@ async fn register( .expect("response") } +async fn route_query(app: &Router, envelope: &WireEnvelope) -> axum::response::Response { + app.clone() + .oneshot( + Request::post("/v0/routes/query") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(envelope).expect("json"))) + .expect("request"), + ) + .await + .expect("response") +} + +async fn route_candidates( + response: axum::response::Response, + directory: &NodeIdentity, + now_ms: i64, +) -> CandidateSet { + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let envelope: WireEnvelope = serde_json::from_slice(&bytes).expect("envelope"); + envelope + .open( + "route.candidates.v1", + directory.root(), + directory.domain_id(), + NodeRole::Directory, + now_ms, + ) + .expect("candidates") +} + async fn error_code(response: axum::response::Response) -> String { let bytes = response .into_body() diff --git a/tests/revocation.rs b/tests/revocation.rs index 192a8cb..3af7998 100644 --- a/tests/revocation.rs +++ b/tests/revocation.rs @@ -30,11 +30,19 @@ fn authority_id() -> NodeId { } fn authority_credential(root: &SigningKey, authority: &SigningKey) -> SignedAuthorityCredential { + authority_credential_for(root, authority, authority_id()) +} + +fn authority_credential_for( + root: &SigningKey, + authority: &SigningKey, + authority_id: NodeId, +) -> SignedAuthorityCredential { SignedAuthorityCredential::issue( root, AuthorityClaims { domain_id: domain(), - authority_id: authority_id(), + authority_id, signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), tls_ca_sha256: "ab".repeat(32), scopes: BTreeSet::from([ @@ -204,7 +212,13 @@ fn cache_rejects_rollback_and_changed_same_epoch_but_accepts_exact_replay() { let state_dir = temp.path().join("state"); let root = key(5); let authority = key(6); - let cache = RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("cache"); + let cache = RevocationCache::open( + &state_dir, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .expect("cache"); let first = signed_snapshot(&root, &authority, 1, NOW); assert_eq!(cache.accept(first.clone(), NOW), Ok(true)); assert_eq!(cache.accept(first, NOW), Ok(false)); @@ -234,13 +248,76 @@ fn cache_rejects_rollback_and_changed_same_epoch_but_accepts_exact_replay() { ); } +#[test] +fn cache_pins_publisher_but_allows_same_key_root_renewal() { + let root = key(20); + let authority = key(21); + let other = key(22); + let temp = tempdir().expect("tempdir"); + let state_dir = temp.path().join("state"); + let trusted = authority_credential(&root, &authority); + let cache = RevocationCache::open(&state_dir, root.verifying_key(), domain(), trusted.clone()) + .expect("cache"); + cache + .accept(signed_snapshot(&root, &authority, 1, NOW), NOW) + .expect("first"); + + assert_eq!( + cache.accept(signed_snapshot(&root, &other, 2, NOW + 1), NOW + 1), + Err(RuntimeError::RevocationPublisherMismatch) + ); + assert_eq!(cache.epoch(), Some(1)); + + drop(cache); + let mut renewed_claims = trusted.claims; + renewed_claims.issued_at_ms = NOW; + renewed_claims.expires_at_ms = NOW + 1_200_000; + let renewed = SignedAuthorityCredential::issue(&root, renewed_claims).expect("renewed"); + assert!(RevocationCache::open(&state_dir, root.verifying_key(), domain(), renewed).is_ok()); +} + +#[test] +fn cache_rejects_stale_and_regressing_higher_epoch_without_replacement() { + let root = key(23); + let authority = key(24); + let temp = tempdir().expect("tempdir"); + let cache = RevocationCache::open( + &temp.path().join("state"), + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .expect("cache"); + cache + .accept(signed_snapshot(&root, &authority, 1, NOW), NOW) + .expect("first"); + + let stale = signed_snapshot(&root, &authority, 2, NOW + 1); + assert_eq!( + cache.accept(stale, NOW + 300_001), + Err(RuntimeError::RevocationSnapshotStale) + ); + let historical = signed_snapshot(&root, &authority, 3, NOW - 1); + assert_eq!( + cache.accept(historical, NOW), + Err(RuntimeError::RevocationTimelineRollback) + ); + assert_eq!(cache.epoch(), Some(1)); +} + #[test] fn self_revocation_freezes_the_issuer_against_higher_epoch_recovery() { let temp = tempdir().expect("tempdir"); let state_dir = temp.path().join("state"); let root = key(13); let authority = key(14); - let cache = RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("cache"); + let cache = RevocationCache::open( + &state_dir, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .expect("cache"); let mut self_revoking_claims = signed_snapshot(&root, &authority, 1, NOW).claims; self_revoking_claims .revoked_authorities @@ -260,6 +337,31 @@ fn self_revocation_freezes_the_issuer_against_higher_epoch_recovery() { Err(RuntimeError::CredentialRevoked) ); assert_eq!(cache.epoch(), Some(1)); + + let second_id = NodeId::new("authority:second-publisher").expect("authority"); + let second_key = key(25); + let second_credential = authority_credential_for(&root, &second_key, second_id.clone()); + let second_snapshot = RevocationSnapshot::sign( + second_credential, + &second_key, + RevocationClaims { + format_version: "agenet.revocation-snapshot.v0.2".to_owned(), + domain_id: domain(), + issuer_id: second_id, + epoch: 3, + generated_at_ms: NOW + 2, + next_update_ms: NOW + 300_002, + revoked_authorities: BTreeSet::new(), + revoked_nodes: BTreeSet::new(), + }, + &root.verifying_key(), + NOW + 2, + ) + .expect("second publisher snapshot"); + assert_eq!( + cache.accept(second_snapshot, NOW + 2), + Err(RuntimeError::RevocationPublisherMismatch) + ); } #[test] @@ -268,7 +370,13 @@ fn decision_is_stale_first_and_checks_both_revocation_sets() { let state_dir = temp.path().join("state"); let root = key(7); let authority = key(8); - let cache = RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("cache"); + let cache = RevocationCache::open( + &state_dir, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .expect("cache"); let mut claims = signed_snapshot(&root, &authority, 1, NOW).claims; claims.revoked_authorities.insert(authority_id()); claims @@ -312,20 +420,36 @@ fn cache_recovers_atomically_and_rejects_unsafe_files() { let temp = tempdir().expect("tempdir"); let state_dir = temp.path().join("state"); { - let cache = - RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("cache"); + let cache = RevocationCache::open( + &state_dir, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .expect("cache"); cache .accept(signed_snapshot(&root, &authority, 1, NOW), NOW) .expect("accepted"); } - let reopened = - RevocationCache::open(&state_dir, root.verifying_key(), domain()).expect("reopen"); + let reopened = RevocationCache::open( + &state_dir, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .expect("reopen"); assert_eq!(reopened.epoch(), Some(1)); let cache_path = state_dir.join("revocation-cache.json"); fs::set_permissions(&cache_path, fs::Permissions::from_mode(0o644)).expect("chmod"); assert_eq!( - RevocationCache::open(&state_dir, root.verifying_key(), domain()).map(|_| ()), + RevocationCache::open( + &state_dir, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .map(|_| ()), Err(RuntimeError::UnsafeRevocationState) ); @@ -337,7 +461,13 @@ fn cache_recovers_atomically_and_rejects_unsafe_files() { fs::write(&target, b"{}").expect("target"); symlink(&target, symlink_state.join("revocation-cache.json")).expect("symlink"); assert_eq!( - RevocationCache::open(&symlink_state, root.verifying_key(), domain()).map(|_| ()), + RevocationCache::open( + &symlink_state, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .map(|_| ()), Err(RuntimeError::UnsafeRevocationState) ); } @@ -431,6 +561,7 @@ fn authority_restart_reverifies_persisted_snapshot_and_configured_publisher() { #[test] fn cache_rejects_corrupt_oversize_and_insecure_directory_state() { let root = key(18); + let authority = key(19); let temp = tempdir().expect("tempdir"); let corrupt_dir = temp.path().join("corrupt"); @@ -440,7 +571,13 @@ fn cache_rejects_corrupt_oversize_and_insecure_directory_state() { fs::write(&corrupt_path, b"{").expect("write"); fs::set_permissions(&corrupt_path, fs::Permissions::from_mode(0o600)).expect("chmod"); assert_eq!( - RevocationCache::open(&corrupt_dir, root.verifying_key(), domain()).map(|_| ()), + RevocationCache::open( + &corrupt_dir, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .map(|_| ()), Err(RuntimeError::CorruptRevocationState) ); @@ -451,7 +588,13 @@ fn cache_rejects_corrupt_oversize_and_insecure_directory_state() { fs::write(&oversized_path, vec![b'x'; 256 * 1024 + 1]).expect("write"); fs::set_permissions(&oversized_path, fs::Permissions::from_mode(0o600)).expect("chmod"); assert_eq!( - RevocationCache::open(&oversized_dir, root.verifying_key(), domain()).map(|_| ()), + RevocationCache::open( + &oversized_dir, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .map(|_| ()), Err(RuntimeError::UnsafeRevocationState) ); @@ -459,7 +602,13 @@ fn cache_rejects_corrupt_oversize_and_insecure_directory_state() { fs::create_dir(&insecure_dir).expect("dir"); fs::set_permissions(&insecure_dir, fs::Permissions::from_mode(0o755)).expect("chmod"); assert_eq!( - RevocationCache::open(&insecure_dir, root.verifying_key(), domain()).map(|_| ()), + RevocationCache::open( + &insecure_dir, + root.verifying_key(), + domain(), + authority_credential(&root, &authority), + ) + .map(|_| ()), Err(RuntimeError::UnsafeRevocationState) ); } From 866ef5824da3590294724f8ba741313d364146e0 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 22:27:58 +0800 Subject: [PATCH 23/67] [feat][Bootstrap][8/14] Add mutual TLS transport Root cause: NA Solution: Require Authority-issued mTLS and bind the TLS identity to every signed envelope issuer. Risks: TLS rotation must complete before certificate expiration. Dependency: Bootstrap step 7. Links: docs/superpowers/specs/ --- Cargo.lock | 1 + Cargo.toml | 3 +- README.md | 22 +- ROADMAP.md | 11 + docs/security/dependency-review-v0.2.md | 2 + src/bootstrap/pki.rs | 44 ++ src/runtime/directory.rs | 36 +- src/runtime/mod.rs | 2 + src/runtime/node.rs | 46 ++ src/runtime/revocation.rs | 4 + src/transport/client.rs | 51 +- src/transport/directory.rs | 3 + src/transport/mod.rs | 4 + src/transport/node.rs | 6 + src/transport/tls.rs | 488 +++++++++++++++ tests/http_mtls.rs | 761 ++++++++++++++++++++++++ 16 files changed, 1473 insertions(+), 11 deletions(-) create mode 100644 src/runtime/node.rs create mode 100644 src/transport/tls.rs create mode 100644 tests/http_mtls.rs diff --git a/Cargo.lock b/Cargo.lock index 9419649..765f64c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,7 @@ dependencies = [ "tempfile", "time", "tokio", + "tokio-rustls", "tower", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index a09e4c6..aa2ca3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,8 +39,10 @@ serde_json = "=1.0.151" sha2 = "=0.11.0" time = "=0.3.55" tokio = { version = "=1.53.1", features = ["full", "test-util"] } +tokio-rustls = "=0.26.4" tracing = "=0.1.44" tracing-subscriber = { version = "=0.3.20", features = ["env-filter", "fmt"] } +tower = "=0.5.3" uuid = { version = "=1.24.0", features = ["serde", "v4"] } url = { version = "=2.5.8", features = ["serde"] } x509-parser = "=0.18.1" @@ -53,7 +55,6 @@ plist = "=1.10.0" http-body-util = "=0.1.5" proptest = "=1.11.0" tempfile = "=3.27.0" -tower = "=0.5.3" [profile.release] strip = true diff --git a/README.md b/README.md index dc849d1..a525933 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,11 @@ The MVP runs four independent processes on different `127.0.0.1` ports: The first real Capability is `source.metrics.v1`: it computes a source Artifact's SHA-256 digest, byte count, line count, and non-empty line count. The Verifier independently recomputes the same metrics. Delivery does not become `Accepted` until verification succeeds. -The MVP validates loopback coordination semantics only. It does **not** validate multi-machine transport security, TLS, distributed failover, quota accounting, arbitrary code sandboxing, or Internet-scale discovery. +The original MVP validates loopback coordination semantics. The provisional +v0.2 transport now adds an Authority-CA mutual-TLS path for private-overlay +listeners, but physical multi-machine reachability is not yet verified. It does +**not** validate distributed failover, quota accounting, arbitrary code +sandboxing, or Internet-scale discovery. ## Intended command @@ -91,4 +95,18 @@ Tests include pure protocol/property checks, storage replay, Axum `oneshot` auth ## Security boundary -This milestone proves signed loopback coordination semantics. HTTP is plaintext and forcibly loopback-only. Separate OS processes and state directories are not claimed as a secure sandbox. The workload does not execute shell commands. A future `project.build_test.v1` adapter must use Docker, a VM, or a platform sandbox before accepting untrusted code. +Plain HTTP remains restricted to loopback. A non-loopback peer endpoint must +use HTTPS with one explicit Authority CA, a required client certificate, an +exact IP SAN, and one canonical noncritical AgenNet NodeId extension. Inbound +requests bind that certificate NodeId to the signed envelope issuer; outbound +clients additionally require the expected remote NodeId from a verified seed +or signed Capability Manifest. They do not use system roots, ambient proxies, +or redirects. Revoked certificates fail at the TLS boundary; stale revocation +state preserves health diagnostics while effectful handlers fail closed. + +The real TLS tests currently use dynamic loopback ports to exercise the same +Rustls/Reqwest handshake path. This is not evidence of physical multi-machine +reachability. Separate OS processes and state directories are not claimed as a +secure sandbox. The workload does not execute shell commands. A future +`project.build_test.v1` adapter must use Docker, a VM, or a platform sandbox +before accepting untrusted code. diff --git a/ROADMAP.md b/ROADMAP.md index e9aad3d..4191eef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,16 @@ # ROADMAP +## 2026-08-14 22:17 CST + +- **Change**: Added the provisional Task 8 peer mutual-TLS transport with exact private-overlay bind validation, explicit Authority roots, required client certificates, exact IP SANs, canonical NodeId certificate extensions, and bidirectional NodeId binding. +- **Files**: `src/transport/tls.rs`, `src/runtime/node.rs`, peer client/router boundaries, peer certificate issuance, Directory endpoint validation, `tests/http_mtls.rs`, `README.md`, dependency review, this roadmap, and ignored Task 8 report/evidence. +- **Trust binding**: Inbound identity comes only from the Rustls peer certificate and is compared with the signed `WireEnvelope.issuer_id`; the handler then performs the existing credential-chain and envelope signature verification. Outbound construction requires an expected peer NodeId obtained from a verified seed or signed Capability Manifest, and verifies that value against the server leaf before accepting the existing signed response envelope from the same issuer. +- **Transport policy**: Peer clients use one explicit CA, PKCS#8 identity, no system roots, no ambient proxy, no redirects, a two-second connect timeout, and a five-second request timeout. Non-loopback plaintext HTTP is rejected before connection. The server validates the declared boundary and exact listener IP before serving and requires the server leaf SAN to contain exactly that one IP. +- **Revocation policy**: An explicitly revoked client leaf is rejected during TLS. Stale state still permits TLS health/diagnostic access, while the revocation-aware effectful Directory registration returns typed `RevocationStateStale`; a rejected handshake does not terminate the listener. +- **Certificate profile**: Added a provisional dual-use private-peer leaf (`ClientAuth` + `ServerAuth`) issued to an existing node CSR with one exact IP SAN and one noncritical canonical DER UTF8String NodeId extension. Enrollment persistence/config wiring remains Task 9/10 work and certificate rotation remains deferred. +- **Memory boundary**: Project-owned PEM private-key strings remain `Zeroizing` and Debug-redacted. Rustls, Reqwest, Hyper, allocator internals, TLS record buffers, kernel socket buffers, and remote peer memory necessarily make or retain copies outside AgenNet's zeroization guarantee. +- **Validation boundary**: Real TLS behavior is exercised on dynamic loopback ports, including missing/wrong/expired/revoked identities, wrong SAN, NodeId mismatch, redirect and proxy isolation. This does not claim physical two-device reachability; that remains the later acceptance gate. + ## 2026-08-15 04:20 CST - **Change**: Added provisional v0.2 signed revocation snapshots, monotonic owner-only Authority/cache persistence, stale-first policy guards, a signed snapshot endpoint, and a resilient no-proxy refresh loop. diff --git a/docs/security/dependency-review-v0.2.md b/docs/security/dependency-review-v0.2.md index bc501a2..0c8baa4 100644 --- a/docs/security/dependency-review-v0.2.md +++ b/docs/security/dependency-review-v0.2.md @@ -15,6 +15,8 @@ so an update requires this review to be revisited. | --- | --- | --- | --- | --- | --- | | `axum-server` 0.8.0 | MIT | Maintained upstream (`programatik29/axum-server`) | HTTPS server integration for Authority and peer endpoints. | Shares the existing Axum/Hyper/Tokio stack; adds `tokio-rustls`, `rustls-pki-types`, and server support crates. | Remove if the v0.2 server transport is replaced with another reviewed server implementation. | | `rustls` 0.23.43 | Apache-2.0 OR ISC OR MIT | Maintained upstream (`rustls/rustls`) | Rust TLS implementation and the selected AWS-LC cryptographic provider. | `aws-lc-rs`, `rustls-pki-types`, `rustls-webpki`, and `zeroize`; exactly one Rustls 0.23 line is resolved. | Remove only with the reviewed replacement of all peer and enrollment TLS. | +| `tokio-rustls` 0.26.4 | MIT OR Apache-2.0 OR ISC | Maintained upstream (`rustls/tokio-rustls`) | Expose the authenticated peer certificate from the Axum Server TLS stream so request extensions can carry a connection-derived NodeId. | Shares Tokio, Rustls 0.23, and `rustls-pki-types`; it was already resolved transitively by `axum-server` and is now a direct API dependency. | Remove if the server integration provides an equivalent authenticated peer-identity extension without direct stream access. | +| `tower` 0.5.3 | MIT | Maintained upstream (`tower-rs/tower`) | Apply the connection-derived TLS identity as an Axum request extension after the Rustls handshake. | Already shared by Axum; moving it from test-only to runtime adds no resolved package. | Return to test-only if peer identity propagation moves behind an Axum-owned API. | | `rcgen` 0.14.9 | MIT OR Apache-2.0 | Maintained upstream (`rustls/rcgen`) | Create the Authority CA and leaf certificate material. | `aws-lc-rs`, `pem`, `rustls-pki-types`, `time`, and `yasna`; default `ring` support is disabled. | Remove if certificates are externally provisioned through a reviewed provider. | | `rustls-pemfile` 2.2.0 | Apache-2.0 OR ISC OR MIT | Maintained upstream (`rustls/pemfile`) | Strict PEM decoding for local TLS key and certificate loading. | `rustls-pki-types` and `zeroize`. | Remove if no supported persistence format uses PEM. | | `age` 0.12.1 | MIT OR Apache-2.0 | Maintained upstream (`str4d/rage`), explicitly beta/pre-1.0 | Encrypt the offline Domain Root material at rest. | Broad crypto and localization closure, including `age-core`, AEAD/HPKE primitives, `scrypt`, `secrecy`, and `zeroize`. | Remove only with a reviewed replacement for offline Root encryption and a versioned keystore migration. | diff --git a/src/bootstrap/pki.rs b/src/bootstrap/pki.rs index dc7e18c..8b5fd80 100644 --- a/src/bootstrap/pki.rs +++ b/src/bootstrap/pki.rs @@ -266,6 +266,50 @@ impl AuthorityPki { }) } + /// Issues a dual-use peer leaf for the private overlay transport. + /// + /// The exact IP SAN prevents a valid Domain certificate from being reused + /// at another overlay address. The NodeId extension is independently bound + /// to signed AgenNet envelopes by the peer HTTP transport. + pub fn issue_peer( + &self, + csr_pem: &str, + node_id: &NodeId, + overlay_ip: IpAddr, + not_before_ms: i64, + not_after_ms: i64, + ) -> Result { + let (not_before, not_after) = self.valid_leaf_range(not_before_ms, not_after_ms)?; + let parsed = CertificateSigningRequestParams::from_pem(csr_pem) + .map_err(|_| BootstrapError::InvalidPki)?; + let mut params = CertificateParams::default(); + params.not_before = not_before; + params.not_after = not_after; + params.subject_alt_names = vec![SanType::IpAddress(overlay_ip)]; + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ + ExtendedKeyUsagePurpose::ClientAuth, + ExtendedKeyUsagePurpose::ServerAuth, + ]; + let mut node_id_extension = CustomExtension::from_oid_content( + AGENET_NODE_ID_OID_COMPONENTS, + der_utf8_string(node_id.as_str())?, + ); + node_id_extension.set_criticality(false); + params.custom_extensions = vec![node_id_extension]; + let request = CertificateSigningRequestParams { + params, + public_key: parsed.public_key, + }; + let cert = request + .signed_by(&self.issuer) + .map_err(|_| BootstrapError::InvalidPki)?; + Ok(IssuedClientCertificate { + cert_pem: cert.pem(), + }) + } + pub fn persist_ca_key(&self, path: &Path) -> Result<(), BootstrapError> { atomic_write_owner_only(path, self.ca_key_pem.as_bytes(), false) .map_err(|_| BootstrapError::StorageFailed) diff --git a/src/runtime/directory.rs b/src/runtime/directory.rs index 258f30a..d4475a3 100644 --- a/src/runtime/directory.rs +++ b/src/runtime/directory.rs @@ -117,7 +117,7 @@ fn validate_manifest( if issuer != &manifest.provider { return Err(RuntimeError::ManifestProviderMismatch); } - validate_loopback_endpoint(&manifest.endpoint)?; + validate_peer_manifest_endpoint(&manifest.endpoint)?; if manifest.expires_at_unix_ms <= now_unix_ms { return Err(RuntimeError::Protocol( crate::protocol::ProtocolError::CredentialExpired, @@ -126,10 +126,16 @@ fn validate_manifest( Ok(()) } -fn validate_loopback_endpoint(endpoint: &str) -> Result<(), RuntimeError> { +fn validate_peer_manifest_endpoint(endpoint: &str) -> Result<(), RuntimeError> { let url = reqwest::Url::parse(endpoint).map_err(|_| RuntimeError::UnsupportedNonLoopbackTransport)?; - if url.scheme() != "http" { + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !matches!(url.path(), "" | "/") + || url.port().is_none() + { return Err(RuntimeError::UnsupportedNonLoopbackTransport); } let address: IpAddr = url @@ -137,8 +143,30 @@ fn validate_loopback_endpoint(endpoint: &str) -> Result<(), RuntimeError> { .ok_or(RuntimeError::UnsupportedNonLoopbackTransport)? .parse() .map_err(|_| RuntimeError::UnsupportedNonLoopbackTransport)?; - if !address.is_loopback() || url.port().is_none() { + let valid_scheme = if address.is_loopback() { + matches!(url.scheme(), "http" | "https") + } else { + url.scheme() == "https" && is_private_overlay_address(address) + }; + if !valid_scheme { return Err(RuntimeError::UnsupportedNonLoopbackTransport); } Ok(()) } + +fn is_private_overlay_address(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + let octets = address.octets(); + octets[0] == 10 + || (octets[0] == 172 && (16..=31).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 168) + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + } + IpAddr::V6(address) => { + let segments = address.segments(); + segments[0] & 0xfe00 == 0xfc00 + || (segments[0] == 0xfd7a && segments[1] == 0x115c && segments[2] == 0xa1e0) + } + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 7f23dd6..6f07593 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -4,6 +4,7 @@ mod directory; mod error; mod identity; pub(crate) mod key_store; +mod node; mod provider; mod recorder; mod requester; @@ -15,6 +16,7 @@ pub use directory::DirectoryRegistry; pub use error::RuntimeError; pub use identity::NodeIdentity; pub use key_store::{read_signing_key, write_signing_key}; +pub use node::serve_peer_tls; pub use provider::ProviderService; pub use recorder::ContractRecorder; pub use requester::{PursuitQuery, PursuitRequest, PursuitResult, RequesterService}; diff --git a/src/runtime/node.rs b/src/runtime/node.rs new file mode 100644 index 0000000..49639ab --- /dev/null +++ b/src/runtime/node.rs @@ -0,0 +1,46 @@ +use std::{ + net::{SocketAddr, TcpListener}, + sync::Arc, +}; + +use axum::Router; +use axum_server::{Handle, tls_rustls::RustlsConfig}; + +use crate::bootstrap::network::NetworkBoundary; +use crate::transport::{PeerTlsIdentity, TransportError, build_peer_server_config}; + +use super::RevocationCache; + +/// Serves an already-bound peer listener with mTLS and a shutdown handle. +/// +/// Binding is deliberately completed by the caller so network-boundary checks +/// happen before this function can create any transport side effect. +pub async fn serve_peer_tls( + listener: TcpListener, + app: Router, + identity: &PeerTlsIdentity, + revocations: Arc, + boundary: &NetworkBoundary, + handle: Handle, +) -> Result<(), TransportError> { + boundary + .validate_bind() + .map_err(|_| TransportError::InvalidEndpoint)?; + let address = listener + .local_addr() + .map_err(|_| TransportError::InvalidEndpoint)?; + if address.ip() != boundary.bind_ip { + return Err(TransportError::InvalidEndpoint); + } + crate::transport::tls::validate_server_identity_ip(identity, address.ip())?; + let config = build_peer_server_config(identity, revocations)?; + let tls = RustlsConfig::from_config(Arc::new(config)); + let acceptor = crate::transport::tls::PeerTlsAcceptor::new(tls); + axum_server::from_tcp(listener) + .map_err(|_| TransportError::RequestFailed)? + .acceptor(acceptor) + .handle(handle) + .serve(app.into_make_service()) + .await + .map_err(|_| TransportError::RequestFailed) +} diff --git a/src/runtime/revocation.rs b/src/runtime/revocation.rs index 7e89b38..5666d0b 100644 --- a/src/runtime/revocation.rs +++ b/src/runtime/revocation.rs @@ -391,6 +391,10 @@ impl RevocationCache { .ok() .and_then(|snapshot| snapshot.value.as_ref().map(|value| value.claims.epoch)) } + + pub(crate) fn peer_tls_decision(&self, now_ms: i64, node_id: &NodeId) -> RevocationDecision { + self.decision(now_ms, &self.publisher.authority_id, node_id) + } } #[derive(Debug, Clone)] diff --git a/src/transport/client.rs b/src/transport/client.rs index ce8ca6d..a1bf7d9 100644 --- a/src/transport/client.rs +++ b/src/transport/client.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::{ bootstrap::network::NetworkBoundary, - protocol::{DomainId, NodeRole, WireEnvelope}, + protocol::{DomainId, NodeId, NodeRole, WireEnvelope}, }; use super::MAX_JSON_BODY_BYTES; @@ -28,6 +28,9 @@ pub struct HttpStats { #[derive(Debug, Clone, PartialEq, Eq)] pub enum TransportError { InvalidEndpoint, + UnsupportedInsecureTransport, + TlsRejected, + TlsIdentityMismatch, RequestFailed, NonSuccessStatus(u16), ResponseTooLarge, @@ -50,6 +53,7 @@ pub struct PeerClient { validation_time_unix_ms: u64, counters: Arc, boundary: NetworkBoundary, + expected_tls_peer: Option, } impl Debug for PeerClient { @@ -127,6 +131,27 @@ impl PeerClient { validation_time_unix_ms, counters: Arc::new(Counters::default()), boundary, + expected_tls_peer: None, + }) + } + + pub fn new_mtls( + root: VerifyingKey, + domain_id: DomainId, + validation_time_unix_ms: u64, + boundary: NetworkBoundary, + identity: &super::tls::PeerTlsIdentity, + expected_peer: NodeId, + ) -> Result { + let client = super::tls::build_peer_client(identity, &boundary, &expected_peer)?; + Ok(Self { + client, + root, + domain_id, + validation_time_unix_ms, + counters: Arc::new(Counters::default()), + boundary, + expected_tls_peer: Some(expected_peer), }) } @@ -139,6 +164,9 @@ impl PeerClient { expected_response_role: NodeRole, ) -> Result { let url = endpoint_url(&self.boundary, endpoint, path)?; + if !url_host_is_loopback(&url) && self.expected_tls_peer.is_none() { + return Err(TransportError::UnsupportedInsecureTransport); + } let request_bytes = serde_json::to_vec(envelope).map_err(|_| TransportError::InvalidResponse)?; self.counters.requests.fetch_add(1, Ordering::Relaxed); @@ -174,6 +202,13 @@ impl PeerClient { .fetch_add(bytes.len() as u64, Ordering::Relaxed); let response_envelope: WireEnvelope = serde_json::from_slice(&bytes).map_err(|_| TransportError::InvalidResponse)?; + if self + .expected_tls_peer + .as_ref() + .is_some_and(|expected| expected != &response_envelope.issuer_id) + { + return Err(TransportError::TlsIdentityMismatch); + } response_envelope .open( expected_object_type, @@ -228,14 +263,22 @@ impl PeerClient { } } +fn url_host_is_loopback(url: &reqwest::Url) -> bool { + url.host_str() + .and_then(|host| { + host.trim_matches(['[', ']']) + .parse::() + .ok() + }) + .is_some_and(|address| address.is_loopback()) +} + fn endpoint_url( boundary: &NetworkBoundary, endpoint: &str, path: &str, ) -> Result { - boundary - .validate_peer_endpoint(endpoint) - .map_err(|_| TransportError::InvalidEndpoint)?; + super::tls::validate_peer_endpoint_transport(boundary, endpoint)?; if !path.starts_with('/') || path.contains('#') { return Err(TransportError::InvalidEndpoint); } diff --git a/src/transport/directory.rs b/src/transport/directory.rs index 6c696dc..1146753 100644 --- a/src/transport/directory.rs +++ b/src/transport/directory.rs @@ -57,6 +57,9 @@ fn directory_router_inner( .route("/v0/capabilities/register", post(register)) .route("/v0/routes/query", post(query)) .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .layer(axum::middleware::from_fn( + super::tls::enforce_tls_envelope_binding, + )) .with_state(state) } diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 24acc82..8e06695 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -3,6 +3,7 @@ mod directory; mod enrollment; mod node; mod revocation; +pub(crate) mod tls; pub use client::{HttpStats, PeerClient, TransportError}; pub use directory::{directory_router, directory_router_with_revocation}; @@ -11,5 +12,8 @@ pub use enrollment::{ }; pub use node::{artifact_router, provider_router, requester_router}; pub use revocation::{RevocationClient, RevocationTransportError, authority_revocation_router}; +pub use tls::{ + PeerTlsIdentity, build_peer_client, build_peer_server_config, validate_peer_endpoint_transport, +}; pub const MAX_JSON_BODY_BYTES: usize = 256 * 1024; diff --git a/src/transport/node.rs b/src/transport/node.rs index 9e35dfb..224a9d8 100644 --- a/src/transport/node.rs +++ b/src/transport/node.rs @@ -28,6 +28,9 @@ pub fn provider_router(service: ProviderService) -> Router { .route("/v0/contracts/events/append", post(provider_append)) .route("/v0/contracts/events/query", post(provider_query)) .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .layer(axum::middleware::from_fn( + super::tls::enforce_tls_envelope_binding, + )) .with_state(Arc::new(service)) } @@ -127,6 +130,9 @@ pub fn artifact_router(identity: NodeIdentity, access: ArtifactAccessService) -> .route("/healthz", get(health)) .route("/v0/artifacts/read", post(artifact_read)) .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .layer(axum::middleware::from_fn( + super::tls::enforce_tls_envelope_binding, + )) .with_state(Arc::new(ArtifactHttpState { identity, access })) } diff --git a/src/transport/tls.rs b/src/transport/tls.rs new file mode 100644 index 0000000..465a007 --- /dev/null +++ b/src/transport/tls.rs @@ -0,0 +1,488 @@ +use std::{ + fmt::{Debug, Formatter}, + future::Future, + io::{self, Cursor}, + pin::Pin, + sync::Arc, + time::Duration, +}; + +use axum::{ + Extension, + body::{Body, to_bytes}, + extract::Request, + http::StatusCode, + middleware::{AddExtension, Next}, + response::{IntoResponse, Response}, +}; +use axum_server::{ + accept::Accept, + tls_rustls::{RustlsAcceptor, RustlsConfig}, +}; +use reqwest::redirect::Policy; +use rustls::{ + ClientConfig, DigitallySignedStruct, DistinguishedName, RootCertStore, ServerConfig, + SignatureScheme, + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + pki_types::{CertificateDer, PrivateKeyDer}, + server::WebPkiClientVerifier, + server::danger::{ClientCertVerified, ClientCertVerifier}, +}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio_rustls::server::TlsStream; +use tower::Layer; +use x509_parser::prelude::{FromDer, X509Certificate}; +use zeroize::Zeroizing; + +use crate::{ + bootstrap::network::NetworkBoundary, + protocol::{ErrorEnvelope, NodeId, RevocationDecision, WireEnvelope}, + runtime::RevocationCache, +}; + +use super::TransportError; + +pub struct PeerTlsIdentity { + pub node_id: NodeId, + pub certificate_chain_pem: Zeroizing, + pub private_key_pem: Zeroizing, + pub authority_ca_pem: String, +} + +impl Debug for PeerTlsIdentity { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PeerTlsIdentity") + .field("node_id", &self.node_id) + .field("certificate_chain_pem", &"[CERTIFICATE]") + .field("private_key_pem", &"[REDACTED]") + .field("authority_ca_pem", &"[CERTIFICATE]") + .finish() + } +} + +pub fn build_peer_client( + identity: &PeerTlsIdentity, + boundary: &NetworkBoundary, + expected_peer: &NodeId, +) -> Result { + boundary + .validate_bind() + .map_err(|_| TransportError::InvalidEndpoint)?; + let roots = parse_roots(&identity.authority_ca_pem)?; + let certificates = parse_certificates(&identity.certificate_chain_pem)?; + validate_local_leaf(identity, &certificates)?; + let private_key = parse_pkcs8_private_key(&identity.private_key_pem)?; + let provider = rustls::crypto::aws_lc_rs::default_provider(); + let webpki = rustls::client::WebPkiServerVerifier::builder(Arc::new(roots)) + .build() + .map_err(|_| TransportError::TlsRejected)?; + let verifier = Arc::new(NodeBoundServerVerifier { + webpki, + expected_peer: expected_peer.clone(), + supported: provider.signature_verification_algorithms, + }); + let tls = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_client_auth_cert(certificates, private_key) + .map_err(|_| TransportError::TlsRejected)?; + reqwest::Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(5)) + .https_only(true) + .tls_backend_preconfigured(tls) + .build() + .map_err(|_| TransportError::TlsRejected) +} + +struct NodeBoundServerVerifier { + webpki: Arc, + expected_peer: NodeId, + supported: rustls::crypto::WebPkiSupportedAlgorithms, +} + +impl Debug for NodeBoundServerVerifier { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("NodeBoundServerVerifier") + .field("expected_peer", &self.expected_peer) + .finish_non_exhaustive() + } +} + +impl ServerCertVerifier for NodeBoundServerVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + let verified = self.webpki.verify_server_cert( + end_entity, + intermediates, + server_name, + ocsp_response, + now, + )?; + let node_id = extract_node_id(end_entity).map_err(|_| { + rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding) + })?; + if node_id != self.expected_peer { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )); + } + Ok(verified) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported) + } + + fn supported_verify_schemes(&self) -> Vec { + self.supported.supported_schemes() + } +} + +pub fn build_peer_server_config( + identity: &PeerTlsIdentity, + revocations: Arc, +) -> Result { + let roots = parse_roots(&identity.authority_ca_pem)?; + let webpki = WebPkiClientVerifier::builder(Arc::new(roots)) + .build() + .map_err(|_| TransportError::TlsRejected)?; + let verifier = Arc::new(RevokingClientVerifier { + webpki, + revocations, + }); + let certificates = parse_certificates(&identity.certificate_chain_pem)?; + validate_local_leaf(identity, &certificates)?; + let private_key = parse_private_key(&identity.private_key_pem)?; + let mut config = ServerConfig::builder() + .with_client_cert_verifier(verifier) + .with_single_cert(certificates, private_key) + .map_err(|_| TransportError::TlsRejected)?; + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Ok(config) +} + +#[derive(Clone)] +pub(crate) struct PeerTlsConnectionIdentity { + pub(crate) node_id: NodeId, +} + +pub(crate) async fn enforce_tls_envelope_binding(request: Request, next: Next) -> Response { + if !request.uri().path().starts_with("/v0/") { + return next.run(request).await; + } + let Some(identity) = request + .extensions() + .get::() + .cloned() + else { + return next.run(request).await; + }; + let (parts, body) = request.into_parts(); + let bytes = match to_bytes(body, super::MAX_JSON_BODY_BYTES).await { + Ok(bytes) => bytes, + Err(_) => return binding_error(StatusCode::PAYLOAD_TOO_LARGE, "RequestBodyTooLarge"), + }; + let envelope: WireEnvelope = match serde_json::from_slice(&bytes) { + Ok(envelope) => envelope, + Err(_) => return binding_error(StatusCode::UNAUTHORIZED, "SignedEnvelopeRequired"), + }; + if envelope.issuer_id != identity.node_id { + return binding_error(StatusCode::UNAUTHORIZED, "TlsIdentityMismatch"); + } + next.run(Request::from_parts(parts, Body::from(bytes))) + .await +} + +fn binding_error(status: StatusCode, code: &str) -> Response { + ( + status, + axum::Json(ErrorEnvelope { + code: code.to_owned(), + message: "the TLS identity does not match the signed request".to_owned(), + retryable: false, + operation_id: "http:unavailable".to_owned(), + }), + ) + .into_response() +} + +#[derive(Clone)] +pub(crate) struct PeerTlsAcceptor { + inner: RustlsAcceptor, +} + +impl PeerTlsAcceptor { + pub(crate) fn new(config: RustlsConfig) -> Self { + Self { + inner: RustlsAcceptor::new(config), + } + } +} + +impl Accept for PeerTlsAcceptor +where + I: AsyncRead + AsyncWrite + Unpin + Send + 'static, + S: Send + 'static, +{ + type Stream = TlsStream; + type Service = AddExtension; + type Future = Pin> + Send>>; + + fn accept(&self, stream: I, service: S) -> Self::Future { + let acceptor = self.inner.clone(); + Box::pin(async move { + let (stream, service) = acceptor.accept(stream, service).await?; + let certificates = stream + .get_ref() + .1 + .peer_certificates() + .ok_or_else(|| io::Error::other("peer certificate required"))?; + let [leaf, ..] = certificates else { + return Err(io::Error::other("peer certificate required")); + }; + let node_id = + extract_node_id(leaf).map_err(|_| io::Error::other("invalid peer identity"))?; + Ok(( + stream, + Extension(PeerTlsConnectionIdentity { node_id }).layer(service), + )) + }) + } +} + +struct RevokingClientVerifier { + webpki: Arc, + revocations: Arc, +} + +impl Debug for RevokingClientVerifier { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RevokingClientVerifier") + .finish_non_exhaustive() + } +} + +impl ClientCertVerifier for RevokingClientVerifier { + fn offer_client_auth(&self) -> bool { + true + } + fn client_auth_mandatory(&self) -> bool { + true + } + fn root_hint_subjects(&self) -> &[DistinguishedName] { + self.webpki.root_hint_subjects() + } + + fn verify_client_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + now: rustls::pki_types::UnixTime, + ) -> Result { + let verified = self + .webpki + .verify_client_cert(end_entity, intermediates, now)?; + let node_id = extract_node_id(end_entity).map_err(|_| { + rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding) + })?; + let now_ms = i64::try_from(now.as_secs()) + .unwrap_or(i64::MAX) + .saturating_mul(1_000); + match self.revocations.peer_tls_decision(now_ms, &node_id) { + RevocationDecision::CurrentAndAllowed | RevocationDecision::Stale => Ok(verified), + RevocationDecision::Revoked => Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::Revoked, + )), + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.webpki.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.webpki.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.webpki.supported_verify_schemes() + } +} + +fn extract_node_id(certificate: &CertificateDer<'_>) -> Result { + let (_, parsed) = + X509Certificate::from_der(certificate.as_ref()).map_err(|_| TransportError::TlsRejected)?; + let extensions: Vec<_> = parsed + .extensions() + .iter() + .filter(|extension| extension.oid.to_id_string() == crate::bootstrap::AGENET_NODE_ID_OID) + .collect(); + let [extension] = extensions.as_slice() else { + return Err(TransportError::TlsRejected); + }; + if extension.critical { + return Err(TransportError::TlsRejected); + } + let encoded = extension.value; + if encoded.len() < 2 || encoded[0] != 0x0c { + return Err(TransportError::TlsRejected); + } + let (header, length) = canonical_der_length(encoded)?; + if encoded.len() != header + length { + return Err(TransportError::TlsRejected); + } + let value = std::str::from_utf8(&encoded[header..]).map_err(|_| TransportError::TlsRejected)?; + NodeId::new(value).map_err(|_| TransportError::TlsRejected) +} + +pub(crate) fn validate_server_identity_ip( + identity: &PeerTlsIdentity, + expected_ip: std::net::IpAddr, +) -> Result<(), TransportError> { + let certificates = parse_certificates(&identity.certificate_chain_pem)?; + validate_local_leaf(identity, &certificates)?; + let (_, parsed) = X509Certificate::from_der(certificates[0].as_ref()) + .map_err(|_| TransportError::TlsRejected)?; + let san = parsed + .subject_alternative_name() + .map_err(|_| TransportError::TlsRejected)? + .ok_or(TransportError::TlsRejected)?; + let addresses: Vec<_> = san + .value + .general_names + .iter() + .filter_map(|name| match name { + x509_parser::extensions::GeneralName::IPAddress(bytes) => match bytes.len() { + 4 => Some(std::net::IpAddr::from(<[u8; 4]>::try_from(*bytes).ok()?)), + 16 => Some(std::net::IpAddr::from(<[u8; 16]>::try_from(*bytes).ok()?)), + _ => None, + }, + _ => None, + }) + .collect(); + if addresses.as_slice() != [expected_ip] { + return Err(TransportError::TlsRejected); + } + Ok(()) +} + +fn canonical_der_length(encoded: &[u8]) -> Result<(usize, usize), TransportError> { + match encoded[1] { + value @ 0..=127 => Ok((2, usize::from(value))), + 0x81 if encoded.len() >= 3 && encoded[2] >= 128 => Ok((3, usize::from(encoded[2]))), + 0x82 if encoded.len() >= 4 && encoded[2] != 0 => { + let length = (usize::from(encoded[2]) << 8) | usize::from(encoded[3]); + if length <= 255 { + return Err(TransportError::TlsRejected); + } + Ok((4, length)) + } + _ => Err(TransportError::TlsRejected), + } +} + +pub fn validate_peer_endpoint_transport( + boundary: &NetworkBoundary, + endpoint: &str, +) -> Result<(), TransportError> { + let url = reqwest::Url::parse(endpoint).map_err(|_| TransportError::InvalidEndpoint)?; + let address = url + .host_str() + .ok_or(TransportError::InvalidEndpoint)? + .trim_matches(['[', ']']) + .parse::() + .map_err(|_| TransportError::InvalidEndpoint)?; + if !address.is_loopback() && url.scheme() == "http" { + return Err(TransportError::UnsupportedInsecureTransport); + } + boundary + .validate_peer_endpoint(endpoint) + .map_err(|_| TransportError::InvalidEndpoint) +} + +fn parse_roots(pem: &str) -> Result { + let certificates = parse_certificates(pem)?; + let mut roots = RootCertStore::empty(); + let (added, ignored) = roots.add_parsable_certificates(certificates); + if added != 1 || ignored != 0 || roots.len() != 1 { + return Err(TransportError::TlsRejected); + } + Ok(roots) +} + +fn validate_local_leaf( + identity: &PeerTlsIdentity, + certificates: &[CertificateDer<'static>], +) -> Result<(), TransportError> { + let leaf = certificates.first().ok_or(TransportError::TlsRejected)?; + if extract_node_id(leaf)? != identity.node_id { + return Err(TransportError::TlsIdentityMismatch); + } + Ok(()) +} + +fn parse_certificates(pem: &str) -> Result>, TransportError> { + let certificates = rustls_pemfile::certs(&mut Cursor::new(pem.as_bytes())) + .collect::, _>>() + .map_err(|_| TransportError::TlsRejected)?; + if certificates.is_empty() { + return Err(TransportError::TlsRejected); + } + Ok(certificates) +} + +fn parse_private_key(pem: &str) -> Result, TransportError> { + rustls_pemfile::private_key(&mut Cursor::new(pem.as_bytes())) + .map_err(|_| TransportError::TlsRejected)? + .ok_or(TransportError::TlsRejected) +} + +fn parse_pkcs8_private_key(pem: &str) -> Result, TransportError> { + let mut cursor = Cursor::new(pem.as_bytes()); + let mut keys = rustls_pemfile::pkcs8_private_keys(&mut cursor); + let key = keys + .next() + .transpose() + .map_err(|_| TransportError::TlsRejected)? + .ok_or(TransportError::TlsRejected)?; + if keys.next().is_some() { + return Err(TransportError::TlsRejected); + } + Ok(PrivateKeyDer::Pkcs8(key)) +} diff --git a/tests/http_mtls.rs b/tests/http_mtls.rs new file mode 100644 index 0000000..bec3174 --- /dev/null +++ b/tests/http_mtls.rs @@ -0,0 +1,761 @@ +mod common; + +use std::{ + collections::BTreeSet, + net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, + process::Command, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use agenet::{ + bootstrap::{ + AuthorityPki, NodeTlsCsr, + network::{NetworkBoundary, OverlayKind}, + }, + protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityId, CapabilityManifest, + NodeId, NodeRole, RevocationClaims, RevocationSnapshot, SideEffectProfile, + SignedAuthorityCredential, WireEnvelope, + }, + runtime::{DirectoryRegistry, NodeIdentity, RevocationCache, RevocationGuard, serve_peer_tls}, + transport::{ + PeerTlsIdentity, TransportError, build_peer_client, directory_router_with_revocation, + validate_peer_endpoint_transport, + }, +}; +use axum::{response::Redirect, routing::get}; +use axum_server::Handle; +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; +use ipnet::IpNet; +use tempfile::{TempDir, tempdir}; +use zeroize::Zeroizing; + +fn private_boundary() -> NetworkBoundary { + NetworkBoundary { + kind: OverlayKind::WireGuard, + bind_ip: IpAddr::V4(Ipv4Addr::new(10, 40, 0, 1)), + allowed_cidrs: vec!["10.40.0.0/24".parse::().expect("CIDR")], + } +} + +#[test] +fn tls_identity_debug_redacts_private_material() { + let identity = PeerTlsIdentity { + node_id: NodeId::new("node:tls-redaction").expect("node ID"), + certificate_chain_pem: Zeroizing::new("sentinel-certificate".to_owned()), + private_key_pem: Zeroizing::new("sentinel-private-key".to_owned()), + authority_ca_pem: "sentinel-ca".to_owned(), + }; + + let debug = format!("{identity:?}"); + + assert!(!debug.contains("sentinel-certificate")); + assert!(!debug.contains("sentinel-private-key")); +} + +#[test] +fn malformed_peer_identity_is_rejected_without_network_access() { + let identity = PeerTlsIdentity { + node_id: NodeId::new("node:tls-malformed").expect("node ID"), + certificate_chain_pem: Zeroizing::new("not PEM".to_owned()), + private_key_pem: Zeroizing::new("not PEM".to_owned()), + authority_ca_pem: "not PEM".to_owned(), + }; + + assert!(matches!( + build_peer_client( + &identity, + &private_boundary(), + &NodeId::new("node:remote").expect("remote") + ), + Err(TransportError::TlsRejected) + )); +} + +#[test] +fn non_loopback_http_is_rejected_before_connect() { + assert_eq!( + validate_peer_endpoint_transport(&private_boundary(), "http://10.40.0.99:9"), + Err(TransportError::UnsupportedInsecureTransport), + ); +} + +#[test] +fn peer_certificate_is_bound_to_node_and_exact_ip() { + let pki = AuthorityPki::generate(1_799_999_000_000, 1_800_001_000_000).expect("CA"); + let csr = NodeTlsCsr::generate().expect("CSR"); + let node_id = NodeId::new("node:peer-certificate").expect("node ID"); + + let certificate = pki + .issue_peer( + &csr.csr_pem, + &node_id, + IpAddr::V4(Ipv4Addr::LOCALHOST), + 1_799_999_900_000, + 1_800_000_100_000, + ) + .expect("peer certificate"); + + assert!(certificate.cert_pem.contains("BEGIN CERTIFICATE")); +} + +#[test] +fn local_identity_node_mismatch_is_rejected_before_client_creation() { + let now = now_ms(); + let pki = AuthorityPki::generate(now - 120_000, now + 600_000).expect("CA"); + let mut identity = issue_peer_identity( + &pki, + "node:certificate-owner", + Ipv4Addr::LOCALHOST, + now - 60_000, + now + 300_000, + ); + identity.node_id = NodeId::new("node:forged-local-field").expect("node"); + + assert!(matches!( + build_peer_client( + &identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:remote").expect("remote") + ), + Err(TransportError::TlsIdentityMismatch) + )); +} + +struct TlsFixture { + _state: TempDir, + endpoint: String, + handle: Handle, + task: tokio::task::JoinHandle>, + client_identity: PeerTlsIdentity, + client_signing: SigningKey, + client_chain: agenet::protocol::CredentialChain, + server_ca_pem: String, + envelope_node: NodeId, + recovery_identity: PeerTlsIdentity, +} + +impl TlsFixture { + async fn stop(self) -> Result<(), TransportError> { + self.handle.graceful_shutdown(None); + self.task.await.expect("server task") + } +} + +#[tokio::test] +async fn real_mtls_binds_client_certificate_node_to_signed_envelope() { + let fixture = start_fixture( + Ipv4Addr::LOCALHOST, + "node:executor-mtls", + "node:executor-mtls", + -60_000, + 300_000, + BTreeSet::new(), + ) + .await; + let now = now_ms(); + let manifest = manifest("node:executor-mtls", &fixture.endpoint, now as u64); + let envelope = WireEnvelope::seal( + "capability.manifest.v1", + &manifest, + &fixture.client_signing, + fixture.client_chain.clone(), + ) + .expect("envelope"); + let client = build_peer_client( + &fixture.client_identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:directory-mtls").expect("directory node"), + ) + .expect("mTLS client"); + + let response = client + .post(format!("{}/v0/capabilities/register", fixture.endpoint)) + .json(&envelope) + .send() + .await + .expect("mTLS request"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + fixture.stop().await.expect("server stop"); +} + +#[tokio::test] +async fn missing_certificate_wrong_ca_expiry_and_revocation_are_rejected() { + let scenarios = ["missing", "wrong-ca", "expired", "revoked"]; + for scenario in scenarios { + let revoked = if scenario == "revoked" { + BTreeSet::from([NodeId::new("node:executor-mtls").expect("node")]) + } else { + BTreeSet::new() + }; + let (not_before, not_after) = match scenario { + "expired" => (-120_000, -60_000), + _ => (-60_000, 300_000), + }; + let fixture = start_fixture( + Ipv4Addr::LOCALHOST, + "node:executor-mtls", + "node:executor-mtls", + not_before, + not_after, + revoked, + ) + .await; + let request = manifest_request(&fixture); + let result = match scenario { + "missing" => { + let ca = reqwest::Certificate::from_pem(fixture.server_ca_pem.as_bytes()) + .expect("server CA"); + reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .tls_certs_only([ca]) + .build() + .expect("client without certificate") + .post(request.0) + .json(&request.1) + .send() + .await + } + "wrong-ca" => { + let now = now_ms(); + let other = AuthorityPki::generate(now - 120_000, now + 600_000).expect("other CA"); + let identity = issue_peer_identity( + &other, + "node:executor-mtls", + Ipv4Addr::LOCALHOST, + now - 60_000, + now + 300_000, + ); + build_peer_client( + &identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:directory-mtls").expect("directory"), + ) + .expect("wrong CA client") + .post(request.0) + .json(&request.1) + .send() + .await + } + _ => { + build_peer_client( + &fixture.client_identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:directory-mtls").expect("directory"), + ) + .expect("mTLS client") + .post(request.0) + .json(&request.1) + .send() + .await + } + }; + assert!(result.is_err(), "{scenario} must fail TLS"); + let recovery = build_peer_client( + &fixture.recovery_identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:directory-mtls").expect("directory"), + ) + .expect("recovery client"); + assert_eq!( + recovery + .get(format!("{}/healthz", fixture.endpoint)) + .send() + .await + .expect("server remains available after rejected handshake") + .status(), + reqwest::StatusCode::OK, + "{scenario} must not terminate the listener" + ); + fixture.stop().await.expect("server stop"); + } +} + +#[tokio::test] +async fn wrong_server_ip_san_is_rejected_before_the_listener_serves() { + let fixture = start_fixture( + Ipv4Addr::new(127, 0, 0, 2), + "node:executor-mtls", + "node:executor-mtls", + -60_000, + 300_000, + BTreeSet::new(), + ) + .await; + + assert_eq!(fixture.stop().await, Err(TransportError::TlsRejected)); +} + +#[tokio::test] +async fn envelope_node_mismatch_is_rejected_after_successful_tls() { + let fixture = start_fixture( + Ipv4Addr::LOCALHOST, + "node:certificate-owner", + "node:envelope-owner", + -60_000, + 300_000, + BTreeSet::new(), + ) + .await; + let request = manifest_request(&fixture); + let client = build_peer_client( + &fixture.client_identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:directory-mtls").expect("directory"), + ) + .expect("mTLS client"); + + let response = client + .post(request.0) + .json(&request.1) + .send() + .await + .expect("HTTP response"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + let error: serde_json::Value = response.json().await.expect("error body"); + assert_eq!(error["code"], "TlsIdentityMismatch"); + fixture.stop().await.expect("server stop"); +} + +#[tokio::test] +async fn outbound_server_certificate_node_mismatch_and_redirect_are_rejected() { + let fixture = start_fixture( + Ipv4Addr::LOCALHOST, + "node:executor-mtls", + "node:executor-mtls", + -60_000, + 300_000, + BTreeSet::new(), + ) + .await; + let wrong_peer_client = build_peer_client( + &fixture.client_identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:not-the-directory").expect("node"), + ) + .expect("client configuration"); + assert!( + wrong_peer_client + .get(format!("{}/healthz", fixture.endpoint)) + .send() + .await + .is_err(), + "the server certificate NodeId must match the verified target" + ); + + let client = build_peer_client( + &fixture.client_identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:directory-mtls").expect("directory"), + ) + .expect("mTLS client"); + let response = client + .get(format!("{}/redirect", fixture.endpoint)) + .send() + .await + .expect("redirect response"); + assert_eq!(response.status(), reqwest::StatusCode::TEMPORARY_REDIRECT); + fixture.stop().await.expect("server stop"); +} + +#[tokio::test] +async fn stable_signature_bit_mutation_is_rejected_over_mtls() { + let fixture = start_fixture( + Ipv4Addr::LOCALHOST, + "node:executor-mtls", + "node:executor-mtls", + -60_000, + 300_000, + BTreeSet::new(), + ) + .await; + let (url, mut envelope) = manifest_request(&fixture); + let mut signature = STANDARD + .decode(&envelope.signature_base64) + .expect("signature bytes"); + signature[0] ^= 1; + envelope.signature_base64 = STANDARD.encode(signature); + let client = build_peer_client( + &fixture.client_identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:directory-mtls").expect("directory"), + ) + .expect("mTLS client"); + + let response = client + .post(url) + .json(&envelope) + .send() + .await + .expect("response"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); + fixture.stop().await.expect("server stop"); +} + +#[tokio::test] +async fn stale_revocation_keeps_health_visible_but_blocks_effectful_registration() { + let fixture = start_fixture_with_staleness( + Ipv4Addr::LOCALHOST, + "node:executor-mtls", + "node:executor-mtls", + -60_000, + 300_000, + BTreeSet::new(), + true, + ) + .await; + let client = build_peer_client( + &fixture.client_identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:directory-mtls").expect("directory"), + ) + .expect("mTLS client"); + assert_eq!( + client + .get(format!("{}/healthz", fixture.endpoint)) + .send() + .await + .expect("health remains visible") + .status(), + reqwest::StatusCode::OK + ); + let request = manifest_request(&fixture); + let response = client + .post(request.0) + .json(&request.1) + .send() + .await + .expect("typed stale response"); + assert_eq!(response.status(), reqwest::StatusCode::CONFLICT); + let error: serde_json::Value = response.json().await.expect("error"); + assert_eq!(error["code"], "RevocationStateStale"); + fixture.stop().await.expect("server stop"); +} + +#[test] +fn ambient_https_proxy_is_bypassed_in_an_isolated_child() { + const CHILD: &str = "AGENET_MTLS_PROXY_CHILD"; + if std::env::var_os(CHILD).is_some() { + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + runtime.block_on(async { + let fixture = start_fixture( + Ipv4Addr::LOCALHOST, + "node:executor-mtls", + "node:executor-mtls", + -60_000, + 300_000, + BTreeSet::new(), + ) + .await; + let client = build_peer_client( + &fixture.client_identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:directory-mtls").expect("directory"), + ) + .expect("mTLS client"); + assert_eq!( + client + .get(format!("{}/healthz", fixture.endpoint)) + .send() + .await + .expect("direct request") + .status(), + reqwest::StatusCode::OK + ); + fixture.stop().await.expect("server stop"); + }); + return; + } + let sentinel = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("proxy sentinel"); + sentinel + .set_nonblocking(true) + .expect("nonblocking sentinel"); + let proxy = format!( + "http://{}", + sentinel.local_addr().expect("sentinel address") + ); + let status = Command::new(std::env::current_exe().expect("test binary")) + .args([ + "--exact", + "ambient_https_proxy_is_bypassed_in_an_isolated_child", + "--nocapture", + ]) + .env(CHILD, "1") + .env("HTTPS_PROXY", &proxy) + .env("https_proxy", &proxy) + .env("HTTP_PROXY", &proxy) + .env("http_proxy", &proxy) + .env_remove("NO_PROXY") + .env_remove("no_proxy") + .status() + .expect("proxy child"); + assert!(status.success(), "isolated proxy child must succeed"); + assert_eq!( + sentinel + .accept() + .expect_err("proxy sentinel must not be contacted") + .kind(), + std::io::ErrorKind::WouldBlock + ); +} + +async fn start_fixture( + server_ip_san: Ipv4Addr, + client_certificate_node: &str, + envelope_node: &str, + client_not_before_offset_ms: i64, + client_not_after_offset_ms: i64, + revoked_nodes: BTreeSet, +) -> TlsFixture { + start_fixture_with_staleness( + server_ip_san, + client_certificate_node, + envelope_node, + client_not_before_offset_ms, + client_not_after_offset_ms, + revoked_nodes, + false, + ) + .await +} + +async fn start_fixture_with_staleness( + server_ip_san: Ipv4Addr, + client_certificate_node: &str, + envelope_node: &str, + client_not_before_offset_ms: i64, + client_not_after_offset_ms: i64, + revoked_nodes: BTreeSet, + stale: bool, +) -> TlsFixture { + let now = now_ms(); + let root = key(31); + let directory_signing = key(32); + let executor_signing = key(33); + let pki = AuthorityPki::generate(now - 600_000, now + 600_000).expect("CA"); + let server_ca_pem = pki.ca_cert_pem.to_string(); + let directory_tls = issue_peer_identity( + &pki, + "node:directory-mtls", + server_ip_san, + now - 60_000, + now + 300_000, + ); + let executor_tls = issue_peer_identity( + &pki, + client_certificate_node, + Ipv4Addr::LOCALHOST, + now + client_not_before_offset_ms, + now + client_not_after_offset_ms, + ); + let recovery_identity = issue_peer_identity( + &pki, + "node:recovery-mtls", + Ipv4Addr::LOCALHOST, + now - 60_000, + now + 300_000, + ); + let state = tempdir().expect("state"); + let cache = revocation_cache(&state, &root, now, revoked_nodes, stale); + let directory = NodeIdentity::new( + directory_signing.clone(), + common::credential_chain( + &root, + &directory_signing, + "node:directory-mtls", + NodeRole::Directory, + now as u64, + ), + NodeRole::Directory, + root.verifying_key(), + now as u64, + ) + .expect("directory identity"); + let client_chain = common::credential_chain( + &root, + &executor_signing, + envelope_node, + NodeRole::Executor, + now as u64, + ); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("listener"); + listener.set_nonblocking(true).expect("nonblocking"); + let address = listener.local_addr().expect("address"); + let handle = Handle::new(); + let server_handle = handle.clone(); + let guard = RevocationGuard::new((*cache).clone()); + let app = + directory_router_with_revocation(DirectoryRegistry::new(), directory, now as u64, guard) + .route( + "/redirect", + get(|| async { Redirect::temporary("/healthz") }), + ); + let task = tokio::spawn(async move { + serve_peer_tls( + listener, + app, + &directory_tls, + cache, + &NetworkBoundary::loopback_ipv4(), + server_handle, + ) + .await + }); + TlsFixture { + _state: state, + endpoint: format!("https://{address}"), + handle, + task, + client_identity: executor_tls, + client_signing: executor_signing, + client_chain, + server_ca_pem, + envelope_node: NodeId::new(envelope_node).expect("envelope node"), + recovery_identity, + } +} + +fn manifest_request(fixture: &TlsFixture) -> (String, WireEnvelope) { + let manifest = manifest( + fixture.envelope_node.as_str(), + &fixture.endpoint, + now_ms() as u64, + ); + let envelope = WireEnvelope::seal( + "capability.manifest.v1", + &manifest, + &fixture.client_signing, + fixture.client_chain.clone(), + ) + .expect("envelope"); + ( + format!("{}/v0/capabilities/register", fixture.endpoint), + envelope, + ) +} + +fn issue_peer_identity( + pki: &AuthorityPki, + node_id: &str, + ip: Ipv4Addr, + not_before_ms: i64, + not_after_ms: i64, +) -> PeerTlsIdentity { + let csr = NodeTlsCsr::generate().expect("CSR"); + let node_id = NodeId::new(node_id).expect("node ID"); + let certificate = pki + .issue_peer( + &csr.csr_pem, + &node_id, + IpAddr::V4(ip), + not_before_ms, + not_after_ms, + ) + .expect("peer certificate"); + PeerTlsIdentity { + node_id, + certificate_chain_pem: Zeroizing::new(format!( + "{}{}", + certificate.cert_pem, + pki.ca_cert_pem.as_str() + )), + private_key_pem: csr.private_key_pem, + authority_ca_pem: pki.ca_cert_pem.to_string(), + } +} + +fn revocation_cache( + state: &TempDir, + root: &SigningKey, + now: i64, + revoked_nodes: BTreeSet, + stale: bool, +) -> Arc { + let authority = key(98); + let credential = publisher_credential(root, &authority, now); + let cache = RevocationCache::open( + &state.path().join("revocations"), + root.verifying_key(), + common::domain_id(), + credential.clone(), + ) + .expect("cache"); + let generated_at_ms = if stale { now - 2_000 } else { now - 1_000 }; + let next_update_ms = if stale { now - 1_000 } else { now + 299_000 }; + let snapshot = RevocationSnapshot::sign( + credential, + &authority, + RevocationClaims { + format_version: "agenet.revocation-snapshot.v0.2".to_owned(), + domain_id: common::domain_id(), + issuer_id: NodeId::new("authority:test").expect("authority ID"), + epoch: 1, + generated_at_ms, + next_update_ms, + revoked_authorities: BTreeSet::new(), + revoked_nodes, + }, + &root.verifying_key(), + generated_at_ms, + ) + .expect("snapshot"); + cache + .accept(snapshot, generated_at_ms) + .expect("accept snapshot"); + Arc::new(cache) +} + +fn publisher_credential( + root: &SigningKey, + authority: &SigningKey, + now: i64, +) -> SignedAuthorityCredential { + SignedAuthorityCredential::issue( + root, + AuthorityClaims { + domain_id: common::domain_id(), + authority_id: NodeId::new("authority:test").expect("authority ID"), + signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([AuthorityScope::PublishRevocationSnapshot]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Base]), + maximum_node_lifetime_ms: 60_000, + issued_at_ms: now - 60_000, + expires_at_ms: now + 600_000, + }, + ) + .expect("publisher credential") +} + +fn manifest(provider: &str, endpoint: &str, now: u64) -> CapabilityManifest { + CapabilityManifest { + capability_id: CapabilityId::new("capability:mtls-test").expect("capability ID"), + provider: NodeId::new(provider).expect("provider"), + kind: "source.metrics".to_owned(), + version: "v1".to_owned(), + description: "mTLS behavior test".to_owned(), + input_profile: "artifact.source.utf8.v1".to_owned(), + output_profile: "source.metrics.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: endpoint.to_owned(), + evidence_types: vec!["source.metrics.evidence.v1".to_owned()], + expires_at_unix_ms: now + 60_000, + } +} + +fn key(byte: u8) -> SigningKey { + SigningKey::from_bytes(&[byte; 32]) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_millis() as i64 +} From 255be5de0b4643fc39d1ef6d89927098ad52f065 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 22:33:21 +0800 Subject: [PATCH 24/67] [bug] Bind client certificate to local IP Root cause: Client SAN was not bound to the configured local IP. Solution: Reject local peer leaves whose sole IP SAN differs from the network boundary before constructing Reqwest. Risks: Existing identities issued for a different bind IP must rotate. Dependency: 866ef58. Links: plan/01-v1-multi-host-node-bootstrap.md Post-mortem: Review dual-use certificates in both TLS directions. --- README.md | 3 +++ ROADMAP.md | 1 + src/transport/tls.rs | 12 ++++++++++-- tests/http_mtls.rs | 23 +++++++++++++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a525933..579e0be 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,9 @@ clients additionally require the expected remote NodeId from a verified seed or signed Capability Manifest. They do not use system roots, ambient proxies, or redirects. Revoked certificates fail at the TLS boundary; stale revocation state preserves health diagnostics while effectful handlers fail closed. +Client construction also requires its own leaf to contain exactly the declared +local boundary IP, preventing a valid peer identity issued for one overlay +address from being reused from another configured address. The real TLS tests currently use dynamic loopback ports to exercise the same Rustls/Reqwest handshake path. This is not evidence of physical multi-machine diff --git a/ROADMAP.md b/ROADMAP.md index 4191eef..ee818ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,6 +10,7 @@ - **Certificate profile**: Added a provisional dual-use private-peer leaf (`ClientAuth` + `ServerAuth`) issued to an existing node CSR with one exact IP SAN and one noncritical canonical DER UTF8String NodeId extension. Enrollment persistence/config wiring remains Task 9/10 work and certificate rotation remains deferred. - **Memory boundary**: Project-owned PEM private-key strings remain `Zeroizing` and Debug-redacted. Rustls, Reqwest, Hyper, allocator internals, TLS record buffers, kernel socket buffers, and remote peer memory necessarily make or retain copies outside AgenNet's zeroization guarantee. - **Validation boundary**: Real TLS behavior is exercised on dynamic loopback ports, including missing/wrong/expired/revoked identities, wrong SAN, NodeId mismatch, redirect and proxy isolation. This does not claim physical two-device reachability; that remains the later acceptance gate. +- **Post-mortem**: Security boundary omission — the first client builder verified its leaf NodeId but only applied WebPKI IP SAN verification to the remote server. Because TLS client authentication does not compare a client SAN with its source address, that allowed a locally loaded peer leaf issued for boundary IP A to authenticate while configured for IP B. Client construction now requires exactly one IP SAN equal to `NetworkBoundary.bind_ip` before Reqwest is created; a test records the pre-network rejection. Future dual-use certificate reviews must enumerate both directions independently rather than assuming server-name verification is symmetric. ## 2026-08-15 04:20 CST diff --git a/src/transport/tls.rs b/src/transport/tls.rs index 465a007..a52c9da 100644 --- a/src/transport/tls.rs +++ b/src/transport/tls.rs @@ -72,6 +72,7 @@ pub fn build_peer_client( let roots = parse_roots(&identity.authority_ca_pem)?; let certificates = parse_certificates(&identity.certificate_chain_pem)?; validate_local_leaf(identity, &certificates)?; + validate_leaf_ip(&certificates[0], boundary.bind_ip)?; let private_key = parse_pkcs8_private_key(&identity.private_key_pem)?; let provider = rustls::crypto::aws_lc_rs::default_provider(); let webpki = rustls::client::WebPkiServerVerifier::builder(Arc::new(roots)) @@ -377,8 +378,15 @@ pub(crate) fn validate_server_identity_ip( ) -> Result<(), TransportError> { let certificates = parse_certificates(&identity.certificate_chain_pem)?; validate_local_leaf(identity, &certificates)?; - let (_, parsed) = X509Certificate::from_der(certificates[0].as_ref()) - .map_err(|_| TransportError::TlsRejected)?; + validate_leaf_ip(&certificates[0], expected_ip) +} + +fn validate_leaf_ip( + certificate: &CertificateDer<'_>, + expected_ip: std::net::IpAddr, +) -> Result<(), TransportError> { + let (_, parsed) = + X509Certificate::from_der(certificate.as_ref()).map_err(|_| TransportError::TlsRejected)?; let san = parsed .subject_alternative_name() .map_err(|_| TransportError::TlsRejected)? diff --git a/tests/http_mtls.rs b/tests/http_mtls.rs index bec3174..996b1fe 100644 --- a/tests/http_mtls.rs +++ b/tests/http_mtls.rs @@ -124,6 +124,29 @@ fn local_identity_node_mismatch_is_rejected_before_client_creation() { )); } +#[test] +fn local_client_certificate_wrong_ip_san_is_rejected_before_network_access() { + let now = now_ms(); + let pki = AuthorityPki::generate(now - 120_000, now + 600_000).expect("CA"); + let identity = issue_peer_identity( + &pki, + "node:wrong-local-ip", + Ipv4Addr::new(127, 0, 0, 2), + now - 60_000, + now + 300_000, + ); + + assert_eq!( + build_peer_client( + &identity, + &NetworkBoundary::loopback_ipv4(), + &NodeId::new("node:remote").expect("remote"), + ) + .expect_err("local certificate SAN must match the declared bind IP"), + TransportError::TlsRejected, + ); +} + struct TlsFixture { _state: TempDir, endpoint: String, From e200132a4ae59675dcefe6f56f5afa8ee61ef4d9 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 22:52:51 +0800 Subject: [PATCH 25/67] [bug] Close mutual TLS identity gaps Root cause: Peer routers treated missing TLS metadata as loopback, and certificate and key parsing accepted ambiguous identities. Solution: Require connection-derived loopback or TLS identity, exact singleton IP SANs, strict PEM and PKCS8, and bind signed responses to TLS peers. Risks: Embedded loopback peer routers must supply Axum ConnectInfo. Dependency: 866ef5824da3590294724f8ba741313d364146e0 and 255be5de0b4643fc39d1ef6d89927098ad52f065. Links: plan/01-v1-multi-host-node-bootstrap.md Post-mortem: Test missing metadata and multi-valued identity fields at every transport boundary. --- README.md | 7 + ROADMAP.md | 9 ++ src/node.rs | 11 +- src/transport/tls.rs | 108 ++++++++++++---- tests/http_artifact.rs | 10 +- tests/http_directory.rs | 87 +++++++++++-- tests/http_mtls.rs | 272 +++++++++++++++++++++++++++++++++++++-- tests/http_revocation.rs | 40 +++--- 8 files changed, 474 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 579e0be..6df5b07 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,13 @@ state preserves health diagnostics while effectful handlers fail closed. Client construction also requires its own leaf to contain exactly the declared local boundary IP, preventing a valid peer identity issued for one overlay address from being reused from another configured address. +Both directions require the SAN extension to contain exactly one entry: the +expected IP. A matching IP plus any additional IP or DNS identity is rejected. +Peer private-key PEM contains exactly one PKCS#8 block; mixed or trailing key +blocks fail configuration. Public `/v0` routers also fail closed when neither +Rustls certificate metadata nor connection-layer loopback metadata is present, +so embedding a peer router in a plain non-loopback server cannot silently skip +the mTLS identity boundary. The real TLS tests currently use dynamic loopback ports to exercise the same Rustls/Reqwest handshake path. This is not evidence of physical multi-machine diff --git a/ROADMAP.md b/ROADMAP.md index ee818ba..170b85f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-14 23:10 CST + +- **Change**: Closed the Task 8 review gaps by making every public `/v0` peer route fail closed unless the connection carries either Rustls-derived certificate identity or loopback `ConnectInfo`, enforcing a single exact server SAN in both client and server directions, and requiring strict one-block PKCS#8 server keys. +- **Files**: `src/transport/tls.rs`, `src/node.rs`, peer HTTP tests, `tests/http_mtls.rs`, this roadmap, and the ignored Task 8 report/evidence. +- **Root cause**: Security boundary omission — the first middleware treated absence of the private TLS extension as loopback compatibility without proving the connection was loopback; WebPKI accepted a matching target IP even when additional SAN identities were present; and the server key parser accepted the first supported key while ignoring additional PEM blocks. +- **Solution**: Plain HTTP compatibility now depends on connection-layer loopback metadata and the root node server supplies it with Axum `ConnectInfo`; missing or non-loopback metadata is rejected before request parsing and registry mutation. Both local and remote leaves must have exactly one SAN entry and it must equal the expected IP. Certificate/CA/key PEM inputs use strict framing and type checks; a server key is exactly one PKCS#8 block. `PeerClient` tests now prove both the server-certificate NodeId and signed-response issuer must equal the verified expected peer. +- **Post-mortem**: Future transport reviews must enumerate missing metadata, ambiguous credential containers, and multi-valued identity fields as explicit negative cases. Compatibility exceptions require a connection-derived proof rather than absence of security metadata. +- **Verification**: Real dynamic-port TLS tests cover extra IP/DNS SANs, mixed/duplicate key blocks, listener survival after rejected handshakes, and signed response/TLS identity mismatch. Exact commands and binary output are retained under the ignored Task 8 evidence directory. + ## 2026-08-14 22:17 CST - **Change**: Added the provisional Task 8 peer mutual-TLS transport with exact private-overlay bind validation, explicit Authority roots, required client certificates, exact IP SANs, canonical NodeId certificate extensions, and bidirectional NodeId binding. diff --git a/src/node.rs b/src/node.rs index b2c361a..a0baf39 100644 --- a/src/node.rs +++ b/src/node.rs @@ -178,10 +178,13 @@ pub async fn run(options: NodeOptions) -> Result { directory_seed: options.directory_seed, }; write_ready(&options.ready_file, &ready)?; - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await - .map_err(sanitized)?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await + .map_err(sanitized)?; Ok(ready) } diff --git a/src/transport/tls.rs b/src/transport/tls.rs index a52c9da..edc80b5 100644 --- a/src/transport/tls.rs +++ b/src/transport/tls.rs @@ -2,6 +2,7 @@ use std::{ fmt::{Debug, Formatter}, future::Future, io::{self, Cursor}, + net::SocketAddr, pin::Pin, sync::Arc, time::Duration, @@ -10,7 +11,7 @@ use std::{ use axum::{ Extension, body::{Body, to_bytes}, - extract::Request, + extract::{ConnectInfo, Request}, http::StatusCode, middleware::{AddExtension, Next}, response::{IntoResponse, Response}, @@ -138,6 +139,16 @@ impl ServerCertVerifier for NodeBoundServerVerifier { rustls::CertificateError::ApplicationVerificationFailure, )); } + let rustls::pki_types::ServerName::IpAddress(expected_ip) = server_name else { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )); + }; + validate_leaf_ip(end_entity, std::net::IpAddr::from(*expected_ip)).map_err(|_| { + rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + ) + })?; Ok(verified) } @@ -178,7 +189,7 @@ pub fn build_peer_server_config( }); let certificates = parse_certificates(&identity.certificate_chain_pem)?; validate_local_leaf(identity, &certificates)?; - let private_key = parse_private_key(&identity.private_key_pem)?; + let private_key = parse_pkcs8_private_key(&identity.private_key_pem)?; let mut config = ServerConfig::builder() .with_client_cert_verifier(verifier) .with_single_cert(certificates, private_key) @@ -196,12 +207,20 @@ pub(crate) async fn enforce_tls_envelope_binding(request: Request, next: Next) - if !request.uri().path().starts_with("/v0/") { return next.run(request).await; } - let Some(identity) = request + let identity = request .extensions() .get::() - .cloned() - else { + .cloned(); + if identity.is_none() + && request + .extensions() + .get::>() + .is_some_and(|ConnectInfo(remote)| remote.ip().is_loopback()) + { return next.run(request).await; + } + let Some(identity) = identity else { + return binding_error(StatusCode::UNAUTHORIZED, "UnsupportedInsecureTransport"); }; let (parts, body) = request.into_parts(); let bytes = match to_bytes(body, super::MAX_JSON_BODY_BYTES).await { @@ -391,23 +410,23 @@ fn validate_leaf_ip( .subject_alternative_name() .map_err(|_| TransportError::TlsRejected)? .ok_or(TransportError::TlsRejected)?; - let addresses: Vec<_> = san - .value - .general_names - .iter() - .filter_map(|name| match name { - x509_parser::extensions::GeneralName::IPAddress(bytes) => match bytes.len() { - 4 => Some(std::net::IpAddr::from(<[u8; 4]>::try_from(*bytes).ok()?)), - 16 => Some(std::net::IpAddr::from(<[u8; 16]>::try_from(*bytes).ok()?)), - _ => None, - }, - _ => None, - }) - .collect(); - if addresses.as_slice() != [expected_ip] { + let [x509_parser::extensions::GeneralName::IPAddress(bytes)] = + san.value.general_names.as_slice() + else { return Err(TransportError::TlsRejected); - } - Ok(()) + }; + let actual_ip = match bytes.len() { + 4 => std::net::IpAddr::from( + <[u8; 4]>::try_from(*bytes).map_err(|_| TransportError::TlsRejected)?, + ), + 16 => std::net::IpAddr::from( + <[u8; 16]>::try_from(*bytes).map_err(|_| TransportError::TlsRejected)?, + ), + _ => return Err(TransportError::TlsRejected), + }; + (actual_ip == expected_ip) + .then_some(()) + .ok_or(TransportError::TlsRejected) } fn canonical_der_length(encoded: &[u8]) -> Result<(usize, usize), TransportError> { @@ -446,6 +465,9 @@ pub fn validate_peer_endpoint_transport( fn parse_roots(pem: &str) -> Result { let certificates = parse_certificates(pem)?; + if certificates.len() != 1 { + return Err(TransportError::TlsRejected); + } let mut roots = RootCertStore::empty(); let (added, ignored) = roots.add_parsable_certificates(certificates); if added != 1 || ignored != 0 || roots.len() != 1 { @@ -466,6 +488,10 @@ fn validate_local_leaf( } fn parse_certificates(pem: &str) -> Result>, TransportError> { + let labels = strict_pem_labels(pem)?; + if labels.is_empty() || labels.iter().any(|label| *label != "CERTIFICATE") { + return Err(TransportError::TlsRejected); + } let certificates = rustls_pemfile::certs(&mut Cursor::new(pem.as_bytes())) .collect::, _>>() .map_err(|_| TransportError::TlsRejected)?; @@ -475,13 +501,10 @@ fn parse_certificates(pem: &str) -> Result>, Transpo Ok(certificates) } -fn parse_private_key(pem: &str) -> Result, TransportError> { - rustls_pemfile::private_key(&mut Cursor::new(pem.as_bytes())) - .map_err(|_| TransportError::TlsRejected)? - .ok_or(TransportError::TlsRejected) -} - fn parse_pkcs8_private_key(pem: &str) -> Result, TransportError> { + if strict_pem_labels(pem)?.as_slice() != ["PRIVATE KEY"] { + return Err(TransportError::TlsRejected); + } let mut cursor = Cursor::new(pem.as_bytes()); let mut keys = rustls_pemfile::pkcs8_private_keys(&mut cursor); let key = keys @@ -494,3 +517,34 @@ fn parse_pkcs8_private_key(pem: &str) -> Result, Transpor } Ok(PrivateKeyDer::Pkcs8(key)) } + +fn strict_pem_labels(pem: &str) -> Result, TransportError> { + let mut labels = Vec::new(); + let mut open_label = None; + for line in pem.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Some(label) = open_label { + let expected_end = format!("-----END {label}-----"); + if line == expected_end { + labels.push(label); + open_label = None; + } else if line.starts_with("-----BEGIN ") || line.starts_with("-----END ") { + return Err(TransportError::TlsRejected); + } + continue; + } + let label = line + .strip_prefix("-----BEGIN ") + .and_then(|value| value.strip_suffix("-----")) + .filter(|value| !value.is_empty()) + .ok_or(TransportError::TlsRejected)?; + open_label = Some(label); + } + if open_label.is_some() { + return Err(TransportError::TlsRejected); + } + Ok(labels) +} diff --git a/tests/http_artifact.rs b/tests/http_artifact.rs index 61ac519..d608339 100644 --- a/tests/http_artifact.rs +++ b/tests/http_artifact.rs @@ -11,9 +11,11 @@ use agenet::{ }; use axum::{ body::Body, + extract::ConnectInfo, http::{Request, StatusCode}, }; use ed25519_dalek::SigningKey; +use std::net::{Ipv4Addr, SocketAddr}; use tempfile::TempDir; use tower::ServiceExt; @@ -138,10 +140,14 @@ fn signed_request( }, ) .unwrap(); - Request::post("/v0/artifacts/read") + let mut request = Request::post("/v0/artifacts/read") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&envelope).unwrap())) - .unwrap() + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from((Ipv4Addr::LOCALHOST, 44000)))); + request } async fn send(app: axum::Router, request: Request) -> StatusCode { diff --git a/tests/http_directory.rs b/tests/http_directory.rs index f5e4713..bc765c0 100644 --- a/tests/http_directory.rs +++ b/tests/http_directory.rs @@ -10,10 +10,12 @@ use agenet::{ }; use axum::{ body::Body, + extract::ConnectInfo, http::{Request, StatusCode}, }; use ed25519_dalek::SigningKey; use http_body_util::BodyExt; +use std::net::{Ipv4Addr, SocketAddr}; use tower::ServiceExt; const NOW: u64 = 1_800_000_000; @@ -70,12 +72,12 @@ async fn signed_manifest_registration_and_deterministic_query_round_trip() { let register = executor.seal("capability.manifest.v1", &manifest).unwrap(); let register_response = app .clone() - .oneshot( + .oneshot(loopback_request( Request::post("/v0/capabilities/register") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(®ister).unwrap())) .unwrap(), - ) + )) .await .unwrap(); assert_eq!(register_response.status(), StatusCode::OK); @@ -89,12 +91,12 @@ async fn signed_manifest_registration_and_deterministic_query_round_trip() { ) .unwrap(); let query_response = app - .oneshot( + .oneshot(loopback_request( Request::post("/v0/routes/query") .header("content-type", "application/json") .body(Body::from(serde_json::to_vec(&query).unwrap())) .unwrap(), - ) + )) .await .unwrap(); assert_eq!(query_response.status(), StatusCode::OK); @@ -130,12 +132,12 @@ async fn unsigned_and_oversized_directory_requests_are_rejected() { let unsigned = app .clone() - .oneshot( + .oneshot(loopback_request( Request::post("/v0/routes/query") .header("content-type", "application/json") .body(Body::from("{}")) .unwrap(), - ) + )) .await .unwrap(); assert_eq!(unsigned.status(), StatusCode::UNAUTHORIZED); @@ -145,13 +147,82 @@ async fn unsigned_and_oversized_directory_requests_are_rejected() { ); let oversized = app - .oneshot( + .oneshot(loopback_request( Request::post("/v0/routes/query") .header("content-type", "application/json") .body(Body::from(vec![b'x'; MAX_JSON_BODY_BYTES + 1])) .unwrap(), - ) + )) .await .unwrap(); assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); } + +#[tokio::test] +async fn missing_or_nonloopback_connection_identity_cannot_register() { + let root = signing_key(60); + let directory = identity( + &root, + signing_key(61), + "node:directory", + NodeRole::Directory, + ); + let executor = identity(&root, signing_key(62), "node:executor", NodeRole::Executor); + let registry = DirectoryRegistry::new(); + let app = directory_router(registry.clone(), directory, NOW); + let manifest = CapabilityManifest { + capability_id: CapabilityId::new("capability:identity-boundary").unwrap(), + provider: executor.node_id().clone(), + kind: "source.metrics".to_owned(), + version: "v1".to_owned(), + description: "identity boundary".to_owned(), + input_profile: "artifact.source.utf8.v1".to_owned(), + output_profile: "source.metrics.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: "http://127.0.0.1:41414".to_owned(), + evidence_types: vec![], + expires_at_unix_ms: NOW + 60_000, + }; + let envelope = executor.seal("capability.manifest.v1", &manifest).unwrap(); + + let missing = app + .clone() + .oneshot(envelope_request("/v0/capabilities/register", &envelope)) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::UNAUTHORIZED); + + let mut nonloopback = envelope_request("/v0/capabilities/register", &envelope); + nonloopback + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(( + Ipv4Addr::new(10, 0, 0, 9), + 44000, + )))); + let response = app.oneshot(nonloopback).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let candidates = registry + .query( + RouteQuery { + required_capability: "source.metrics.v1".to_owned(), + }, + NOW, + ) + .await; + assert!(candidates.candidates.is_empty()); +} + +fn envelope_request(path: &str, envelope: &WireEnvelope) -> Request { + Request::post(path) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(envelope).unwrap())) + .unwrap() +} + +fn loopback_request(mut request: Request) -> Request { + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from((Ipv4Addr::LOCALHOST, 44000)))); + request +} diff --git a/tests/http_mtls.rs b/tests/http_mtls.rs index 996b1fe..b902a6a 100644 --- a/tests/http_mtls.rs +++ b/tests/http_mtls.rs @@ -20,16 +20,21 @@ use agenet::{ }, runtime::{DirectoryRegistry, NodeIdentity, RevocationCache, RevocationGuard, serve_peer_tls}, transport::{ - PeerTlsIdentity, TransportError, build_peer_client, directory_router_with_revocation, - validate_peer_endpoint_transport, + PeerClient, PeerTlsIdentity, TransportError, build_peer_client, build_peer_server_config, + directory_router_with_revocation, validate_peer_endpoint_transport, }, }; -use axum::{response::Redirect, routing::get}; +use axum::{Router, response::Redirect, routing::get}; use axum_server::Handle; use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::SigningKey; use ipnet::IpNet; +use rcgen::{ + BasicConstraints, CertificateParams, CertifiedIssuer, CustomExtension, DnType, + ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType, +}; use tempfile::{TempDir, tempdir}; +use time::OffsetDateTime; use zeroize::Zeroizing; fn private_boundary() -> NetworkBoundary { @@ -147,6 +152,103 @@ fn local_client_certificate_wrong_ip_san_is_rejected_before_network_access() { ); } +#[test] +fn server_private_key_requires_exactly_one_pkcs8_block() { + let now = now_ms(); + let pki = AuthorityPki::generate(now - 120_000, now + 600_000).expect("CA"); + let identity = issue_peer_identity( + &pki, + "node:server-key-format", + Ipv4Addr::LOCALHOST, + now - 60_000, + now + 300_000, + ); + let state = tempdir().expect("state"); + let cache = revocation_cache(&state, &key(31), now, BTreeSet::new(), false); + let key_pem = identity.private_key_pem.to_string(); + let parsed = pem::parse(&key_pem).expect("PKCS8 PEM"); + + let rejected_keys = [ + format!("{key_pem}{key_pem}"), + format!( + "{key_pem}{}", + pem::encode(&pem::Pem::new( + "RSA PRIVATE KEY", + parsed.contents().to_vec(), + )) + ), + pem::encode(&pem::Pem::new( + "RSA PRIVATE KEY", + parsed.contents().to_vec(), + )), + pem::encode(&pem::Pem::new("EC PRIVATE KEY", parsed.contents().to_vec())), + ]; + for rejected_key in rejected_keys { + let malformed = PeerTlsIdentity { + node_id: identity.node_id.clone(), + certificate_chain_pem: identity.certificate_chain_pem.clone(), + private_key_pem: Zeroizing::new(rejected_key), + authority_ca_pem: identity.authority_ca_pem.clone(), + }; + assert!(matches!( + build_peer_server_config(&malformed, cache.clone()), + Err(TransportError::TlsRejected) + )); + } +} + +#[tokio::test] +async fn remote_server_leaf_rejects_every_additional_san() { + for extra_san in [ + SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))), + SanType::DnsName("extra.invalid".try_into().expect("DNS SAN")), + ] { + let now = now_ms(); + let (server_identity, client_identity) = independent_identity_pair(extra_san, now); + let state = tempdir().expect("state"); + let cache = revocation_cache(&state, &key(31), now, BTreeSet::new(), false); + let config = build_peer_server_config(&server_identity, cache).expect("server config"); + let tls = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(config)); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("listener"); + listener.set_nonblocking(true).expect("nonblocking"); + let address = listener.local_addr().expect("address"); + let handle = Handle::new(); + let server_handle = handle.clone(); + let task = tokio::spawn(async move { + axum_server::from_tcp_rustls(listener, tls) + .expect("TLS listener") + .handle(server_handle) + .serve( + Router::new() + .route("/healthz", get(|| async { "ok" })) + .into_make_service(), + ) + .await + }); + let client = build_peer_client( + &client_identity, + &NetworkBoundary::loopback_ipv4(), + &server_identity.node_id, + ) + .expect("client config"); + + assert!( + client + .get(format!("https://{address}/healthz")) + .send() + .await + .is_err(), + "the client verifier must reject a target IP plus any extra SAN" + ); + assert!( + !task.is_finished(), + "a rejected handshake must not stop the listener" + ); + handle.graceful_shutdown(None); + task.await.expect("server task").expect("graceful stop"); + } +} + struct TlsFixture { _state: TempDir, endpoint: String, @@ -160,6 +262,12 @@ struct TlsFixture { recovery_identity: PeerTlsIdentity, } +struct FixturePolicy<'a> { + revoked_nodes: BTreeSet, + stale: bool, + directory_envelope_node: &'a str, +} + impl TlsFixture { async fn stop(self) -> Result<(), TransportError> { self.handle.graceful_shutdown(None); @@ -202,6 +310,68 @@ async fn real_mtls_binds_client_certificate_node_to_signed_envelope() { .expect("mTLS request"); assert_eq!(response.status(), reqwest::StatusCode::OK); + + let peer_client = PeerClient::new_mtls( + key(31).verifying_key(), + common::domain_id(), + now as u64, + NetworkBoundary::loopback_ipv4(), + &fixture.client_identity, + NodeId::new("node:directory-mtls").expect("directory node"), + ) + .expect("signed mTLS client"); + let registered: serde_json::Value = peer_client + .post_signed( + &fixture.endpoint, + "/v0/capabilities/register", + &envelope, + "capability.registration.v1", + NodeRole::Directory, + ) + .await + .expect("signed response over mTLS"); + assert_eq!(registered["registered"], true); + fixture.stop().await.expect("server stop"); +} + +#[tokio::test] +async fn peer_client_rejects_signed_response_from_a_different_tls_node() { + let fixture = start_fixture_with_staleness( + Ipv4Addr::LOCALHOST, + "node:executor-mtls", + "node:executor-mtls", + -60_000, + 300_000, + FixturePolicy { + revoked_nodes: BTreeSet::new(), + stale: false, + directory_envelope_node: "node:wrong-response-signer", + }, + ) + .await; + let (_, envelope) = manifest_request(&fixture); + let client = PeerClient::new_mtls( + key(31).verifying_key(), + common::domain_id(), + now_ms() as u64, + NetworkBoundary::loopback_ipv4(), + &fixture.client_identity, + NodeId::new("node:directory-mtls").expect("TLS peer"), + ) + .expect("mTLS client"); + + assert_eq!( + client + .post_signed::( + &fixture.endpoint, + "/v0/capabilities/register", + &envelope, + "capability.registration.v1", + NodeRole::Directory, + ) + .await, + Err(TransportError::TlsIdentityMismatch), + ); fixture.stop().await.expect("server stop"); } @@ -429,8 +599,11 @@ async fn stale_revocation_keeps_health_visible_but_blocks_effectful_registration "node:executor-mtls", -60_000, 300_000, - BTreeSet::new(), - true, + FixturePolicy { + revoked_nodes: BTreeSet::new(), + stale: true, + directory_envelope_node: "node:directory-mtls", + }, ) .await; let client = build_peer_client( @@ -542,8 +715,11 @@ async fn start_fixture( envelope_node, client_not_before_offset_ms, client_not_after_offset_ms, - revoked_nodes, - false, + FixturePolicy { + revoked_nodes, + stale: false, + directory_envelope_node: "node:directory-mtls", + }, ) .await } @@ -554,8 +730,7 @@ async fn start_fixture_with_staleness( envelope_node: &str, client_not_before_offset_ms: i64, client_not_after_offset_ms: i64, - revoked_nodes: BTreeSet, - stale: bool, + policy: FixturePolicy<'_>, ) -> TlsFixture { let now = now_ms(); let root = key(31); @@ -585,13 +760,13 @@ async fn start_fixture_with_staleness( now + 300_000, ); let state = tempdir().expect("state"); - let cache = revocation_cache(&state, &root, now, revoked_nodes, stale); + let cache = revocation_cache(&state, &root, now, policy.revoked_nodes, policy.stale); let directory = NodeIdentity::new( directory_signing.clone(), common::credential_chain( &root, &directory_signing, - "node:directory-mtls", + policy.directory_envelope_node, NodeRole::Directory, now as u64, ), @@ -693,6 +868,81 @@ fn issue_peer_identity( } } +fn independent_identity_pair( + extra_server_san: SanType, + now_ms: i64, +) -> (PeerTlsIdentity, PeerTlsIdentity) { + let mut ca_params = CertificateParams::default(); + ca_params + .distinguished_name + .push(DnType::CommonName, "independent mTLS test CA"); + ca_params.not_before = + OffsetDateTime::from_unix_timestamp((now_ms - 120_000) / 1_000).expect("CA not before"); + ca_params.not_after = + OffsetDateTime::from_unix_timestamp((now_ms + 600_000) / 1_000).expect("CA not after"); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let issuer = CertifiedIssuer::self_signed(ca_params, KeyPair::generate().expect("CA key")) + .expect("test CA"); + let ca_pem = issuer.pem(); + let server = independent_peer_identity( + &issuer, + &ca_pem, + "node:independent-server", + vec![ + SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST)), + extra_server_san, + ], + now_ms, + ); + let client = independent_peer_identity( + &issuer, + &ca_pem, + "node:independent-client", + vec![SanType::IpAddress(IpAddr::V4(Ipv4Addr::LOCALHOST))], + now_ms, + ); + (server, client) +} + +fn independent_peer_identity( + issuer: &CertifiedIssuer<'_, KeyPair>, + ca_pem: &str, + node_id: &str, + subject_alt_names: Vec, + now_ms: i64, +) -> PeerTlsIdentity { + let key = KeyPair::generate().expect("peer key"); + let mut params = CertificateParams::default(); + params.distinguished_name.push(DnType::CommonName, node_id); + params.not_before = + OffsetDateTime::from_unix_timestamp((now_ms - 60_000) / 1_000).expect("peer not before"); + params.not_after = + OffsetDateTime::from_unix_timestamp((now_ms + 300_000) / 1_000).expect("peer not after"); + params.subject_alt_names = subject_alt_names; + params.is_ca = IsCa::ExplicitNoCa; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ + ExtendedKeyUsagePurpose::ClientAuth, + ExtendedKeyUsagePurpose::ServerAuth, + ]; + let bytes = node_id.as_bytes(); + let mut der_node_id = Vec::with_capacity(bytes.len() + 2); + der_node_id.extend([0x0c, u8::try_from(bytes.len()).expect("short node ID")]); + der_node_id.extend(bytes); + params.custom_extensions = vec![CustomExtension::from_oid_content( + &[2, 25, 9029276719620050359], + der_node_id, + )]; + let certificate = params.signed_by(&key, issuer).expect("peer certificate"); + PeerTlsIdentity { + node_id: NodeId::new(node_id).expect("node ID"), + certificate_chain_pem: Zeroizing::new(format!("{}{ca_pem}", certificate.pem())), + private_key_pem: Zeroizing::new(key.serialize_pem()), + authority_ca_pem: ca_pem.to_owned(), + } +} + fn revocation_cache( state: &TempDir, root: &SigningKey, diff --git a/tests/http_revocation.rs b/tests/http_revocation.rs index dcd48a2..f077abc 100644 --- a/tests/http_revocation.rs +++ b/tests/http_revocation.rs @@ -619,27 +619,31 @@ async fn register( app: &Router, envelope: &agenet::protocol::WireEnvelope, ) -> axum::response::Response { - app.clone() - .oneshot( - Request::post("/v0/capabilities/register") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(envelope).expect("json"))) - .expect("request"), - ) - .await - .expect("response") + let mut request = Request::post("/v0/capabilities/register") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(envelope).expect("json"))) + .expect("request"); + request + .extensions_mut() + .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(( + std::net::Ipv4Addr::LOCALHOST, + 44000, + )))); + app.clone().oneshot(request).await.expect("response") } async fn route_query(app: &Router, envelope: &WireEnvelope) -> axum::response::Response { - app.clone() - .oneshot( - Request::post("/v0/routes/query") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(envelope).expect("json"))) - .expect("request"), - ) - .await - .expect("response") + let mut request = Request::post("/v0/routes/query") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(envelope).expect("json"))) + .expect("request"); + request + .extensions_mut() + .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(( + std::net::Ipv4Addr::LOCALHOST, + 44000, + )))); + app.clone().oneshot(request).await.expect("response") } async fn route_candidates( From 69a1e98a3b1b8ff3810d728d851953fcf4f4ef50 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 23:01:35 +0800 Subject: [PATCH 26/67] [bug] Verify signed response before TLS binding Root cause: PeerClient compared an unverified response issuer with the TLS peer before validating the credential chain and envelope signature. Solution: Open each response exactly once, classify cryptographic failures as InvalidSignedResponse, then bind the verified issuer to the TLS peer. Risks: Invalid responses with multiple faults now report signature validation before TLS identity mismatch. Dependency: e200132a4ae59675dcefe6f56f5afa8ee61ef4d9. Links: plan/01-v1-multi-host-node-bootstrap.md Post-mortem: Never authorize or classify identity from parsed claims before their enclosing cryptographic object verifies. --- README.md | 3 ++ ROADMAP.md | 9 +++++ src/transport/client.rs | 19 ++++----- tests/http_mtls.rs | 90 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6df5b07..64488b2 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,9 @@ clients additionally require the expected remote NodeId from a verified seed or signed Capability Manifest. They do not use system roots, ambient proxies, or redirects. Revoked certificates fail at the TLS boundary; stale revocation state preserves health diagnostics while effectful handlers fail closed. +Signed responses complete credential-chain, role, domain, expiry, and signature +verification before their verified issuer is compared with the TLS peer NodeId; +an invalid signed response is never classified as a TLS identity mismatch. Client construction also requires its own leaf to contain exactly the declared local boundary IP, preventing a valid peer identity issued for one overlay address from being reused from another configured address. diff --git a/ROADMAP.md b/ROADMAP.md index 170b85f..c046df5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-14 23:38 CST + +- **Change**: Corrected the Task 8 outbound response-verification order so cryptographic validation always precedes TLS NodeId binding classification. +- **Files**: `src/transport/client.rs`, `tests/http_mtls.rs`, `README.md`, this roadmap, and ignored Task 8 report/evidence. +- **Root cause**: Security validation-order omission — `PeerClient::post_signed` compared the unverified JSON `issuer_id` with the TLS peer before opening the envelope, so a response with both a wrong issuer and invalid signature returned `TlsIdentityMismatch` without exercising credential or signature verification. +- **Solution**: Open the response envelope exactly once and map every credential, role, domain, expiry, or signature failure to `InvalidSignedResponse`; only then compare the now-verified envelope issuer with the expected TLS peer and return `TlsIdentityMismatch` for an otherwise valid response from the wrong node. +- **Post-mortem**: Treat every parsed identity claim as attacker-controlled until its enclosing cryptographic object has verified. Error classification and identity binding must consume verified claims, never pre-validation JSON fields. +- **Verification**: A real mTLS fixture now flips one stable response-signature bit while also using a different signed issuer and requires `InvalidSignedResponse`; the existing valid wrong-issuer fixture still requires `TlsIdentityMismatch`. + ## 2026-08-14 23:10 CST - **Change**: Closed the Task 8 review gaps by making every public `/v0` peer route fail closed unless the connection carries either Rustls-derived certificate identity or loopback `ConnectInfo`, enforcing a single exact server SAN in both client and server directions, and requiring strict one-block PKCS#8 server keys. diff --git a/src/transport/client.rs b/src/transport/client.rs index a1bf7d9..5fc84e5 100644 --- a/src/transport/client.rs +++ b/src/transport/client.rs @@ -202,14 +202,7 @@ impl PeerClient { .fetch_add(bytes.len() as u64, Ordering::Relaxed); let response_envelope: WireEnvelope = serde_json::from_slice(&bytes).map_err(|_| TransportError::InvalidResponse)?; - if self - .expected_tls_peer - .as_ref() - .is_some_and(|expected| expected != &response_envelope.issuer_id) - { - return Err(TransportError::TlsIdentityMismatch); - } - response_envelope + let payload = response_envelope .open( expected_object_type, &self.root, @@ -218,7 +211,15 @@ impl PeerClient { i64::try_from(self.validation_time_unix_ms) .map_err(|_| TransportError::InvalidSignedResponse)?, ) - .map_err(|_| TransportError::InvalidSignedResponse) + .map_err(|_| TransportError::InvalidSignedResponse)?; + if self + .expected_tls_peer + .as_ref() + .is_some_and(|expected| expected != &response_envelope.issuer_id) + { + return Err(TransportError::TlsIdentityMismatch); + } + Ok(payload) } pub async fn post_signed_read( diff --git a/tests/http_mtls.rs b/tests/http_mtls.rs index b902a6a..91e66f1 100644 --- a/tests/http_mtls.rs +++ b/tests/http_mtls.rs @@ -24,7 +24,14 @@ use agenet::{ directory_router_with_revocation, validate_peer_endpoint_transport, }, }; -use axum::{Router, response::Redirect, routing::get}; +use axum::{ + Router, + body::{Body, to_bytes}, + extract::Request, + middleware::{Next, from_fn}, + response::{Redirect, Response}, + routing::get, +}; use axum_server::Handle; use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::SigningKey; @@ -266,6 +273,7 @@ struct FixturePolicy<'a> { revoked_nodes: BTreeSet, stale: bool, directory_envelope_node: &'a str, + tamper_response_signature: bool, } impl TlsFixture { @@ -346,6 +354,7 @@ async fn peer_client_rejects_signed_response_from_a_different_tls_node() { revoked_nodes: BTreeSet::new(), stale: false, directory_envelope_node: "node:wrong-response-signer", + tamper_response_signature: false, }, ) .await; @@ -375,6 +384,48 @@ async fn peer_client_rejects_signed_response_from_a_different_tls_node() { fixture.stop().await.expect("server stop"); } +#[tokio::test] +async fn peer_client_verifies_an_invalid_response_before_tls_node_binding() { + let fixture = start_fixture_with_staleness( + Ipv4Addr::LOCALHOST, + "node:executor-mtls", + "node:executor-mtls", + -60_000, + 300_000, + FixturePolicy { + revoked_nodes: BTreeSet::new(), + stale: false, + directory_envelope_node: "node:wrong-response-signer", + tamper_response_signature: true, + }, + ) + .await; + let (_, envelope) = manifest_request(&fixture); + let client = PeerClient::new_mtls( + key(31).verifying_key(), + common::domain_id(), + now_ms() as u64, + NetworkBoundary::loopback_ipv4(), + &fixture.client_identity, + NodeId::new("node:directory-mtls").expect("TLS peer"), + ) + .expect("mTLS client"); + + assert_eq!( + client + .post_signed::( + &fixture.endpoint, + "/v0/capabilities/register", + &envelope, + "capability.registration.v1", + NodeRole::Directory, + ) + .await, + Err(TransportError::InvalidSignedResponse), + ); + fixture.stop().await.expect("server stop"); +} + #[tokio::test] async fn missing_certificate_wrong_ca_expiry_and_revocation_are_rejected() { let scenarios = ["missing", "wrong-ca", "expired", "revoked"]; @@ -603,6 +654,7 @@ async fn stale_revocation_keeps_health_visible_but_blocks_effectful_registration revoked_nodes: BTreeSet::new(), stale: true, directory_envelope_node: "node:directory-mtls", + tamper_response_signature: false, }, ) .await; @@ -719,6 +771,7 @@ async fn start_fixture( revoked_nodes, stale: false, directory_envelope_node: "node:directory-mtls", + tamper_response_signature: false, }, ) .await @@ -732,6 +785,12 @@ async fn start_fixture_with_staleness( client_not_after_offset_ms: i64, policy: FixturePolicy<'_>, ) -> TlsFixture { + let FixturePolicy { + revoked_nodes, + stale, + directory_envelope_node, + tamper_response_signature, + } = policy; let now = now_ms(); let root = key(31); let directory_signing = key(32); @@ -760,13 +819,13 @@ async fn start_fixture_with_staleness( now + 300_000, ); let state = tempdir().expect("state"); - let cache = revocation_cache(&state, &root, now, policy.revoked_nodes, policy.stale); + let cache = revocation_cache(&state, &root, now, revoked_nodes, stale); let directory = NodeIdentity::new( directory_signing.clone(), common::credential_chain( &root, &directory_signing, - policy.directory_envelope_node, + directory_envelope_node, NodeRole::Directory, now as u64, ), @@ -794,6 +853,11 @@ async fn start_fixture_with_staleness( "/redirect", get(|| async { Redirect::temporary("/healthz") }), ); + let app = if tamper_response_signature { + app.layer(from_fn(tamper_registration_response_signature)) + } else { + app + }; let task = tokio::spawn(async move { serve_peer_tls( listener, @@ -819,6 +883,26 @@ async fn start_fixture_with_staleness( } } +async fn tamper_registration_response_signature(request: Request, next: Next) -> Response { + let tamper = request.uri().path() == "/v0/capabilities/register"; + let response = next.run(request).await; + if !tamper || !response.status().is_success() { + return response; + } + let (parts, body) = response.into_parts(); + let bytes = to_bytes(body, 256 * 1024).await.expect("bounded response"); + let mut envelope: WireEnvelope = serde_json::from_slice(&bytes).expect("signed response"); + let mut signature = STANDARD + .decode(&envelope.signature_base64) + .expect("signature bytes"); + signature[0] ^= 1; + envelope.signature_base64 = STANDARD.encode(signature); + Response::from_parts( + parts, + Body::from(serde_json::to_vec(&envelope).expect("tampered response")), + ) +} + fn manifest_request(fixture: &TlsFixture) -> (String, WireEnvelope) { let manifest = manifest( fixture.envelope_node.as_str(), From 392ff4b563e32f9ab999e3cc2dc46f93333fffdc Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Fri, 14 Aug 2026 23:47:02 +0800 Subject: [PATCH 27/67] [feat][Bootstrap][9/14] Persist bootstrap state Root cause: NA Solution: Add versioned user configuration and a crash-recoverable bootstrap phase journal. Risks: Cross-device atomic rename is rejected. Dependency: Bootstrap step 8. Links: plan/01-v1-multi-host-node-bootstrap.md --- README.md | 43 ++++ ROADMAP.md | 11 + src/bootstrap/config.rs | 186 +++++++++++++++ src/bootstrap/mod.rs | 13 ++ src/bootstrap/network.rs | 18 +- src/bootstrap/paths.rs | 201 ++++++++++++++++ src/bootstrap/state.rs | 453 ++++++++++++++++++++++++++++++++++++ src/demo.rs | 5 +- src/runtime/key_store.rs | 288 ++++++++++++++++++++++- src/transport/tls.rs | 77 ++++++ tests/bootstrap_config.rs | 417 +++++++++++++++++++++++++++++++++ tests/bootstrap_recovery.rs | 240 +++++++++++++++++++ 12 files changed, 1943 insertions(+), 9 deletions(-) create mode 100644 src/bootstrap/config.rs create mode 100644 src/bootstrap/paths.rs create mode 100644 src/bootstrap/state.rs create mode 100644 tests/bootstrap_config.rs create mode 100644 tests/bootstrap_recovery.rs diff --git a/README.md b/README.md index 64488b2..26a1674 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,49 @@ dependencies required by the subsequent bootstrap tasks. The review record, including licenses, transitive footprint, and removal boundaries, is in [`docs/security/dependency-review-v0.2.md`](docs/security/dependency-review-v0.2.md). +## Provisional local bootstrap state + +Task 9 introduces a deliberately versioned local persistence boundary; it is +not a promise that these schemas or phase choices will never change. +`agenet.node-config` schema 1 is a bounded, deny-unknown-fields JSON document. +It carries the Domain, bootstrap profile, network boundary, Directory seeds, +Authority endpoint, and revocation endpoint. Private-overlay endpoints are +exact IP-literal HTTPS origins with no credentials, query, fragment, DNS name, +or non-root path. Plain HTTP is accepted only when the persisted boundary is +explicitly loopback. + +Current-host roots are resolved through the `directories` crate. macOS uses +`~/Library/Application Support/AgenNet/`; Linux uses +`${XDG_CONFIG_HOME:-~/.config}/agenet/` and +`${XDG_STATE_HOME:-~/.local/state}/agenet/`. Config, credential chain, Ed25519 +signing key, TLS certificate/key/CA, service metadata, revocation cache, +journal, and process lock have separate versioned names. Managed directories +must be current-user `0700` non-symlink directories; owner-only files must be +regular current-user `0600` files and are opened with no-follow, nonblocking, +bounded reads. Startup material is returned only after credential +Root/domain/role/profile/time, signing-key, TLS chain/key, NodeId, exact IP SAN, +and certificate-time validation succeeds. Building the live service from this +validated bundle remains Task 12. + +The local journal records this forward path: + +```text +Absent → BinaryInstalled → ServicePrepared → ReadyForEnrollment + → CredentialIssued → Registered → Healthy +``` + +The only backward compensation is `ServicePrepared` or `ReadyForEnrollment` +to `BinaryInstalled`; it is forbidden after `CredentialIssued`, so interrupted +enrollment never guesses that credentials should be deleted. `Leave` moves any +installed, non-Left phase to `Left`. Every record contains a unique operation +ID, sequence, previous hash, transition, and checksum beneath a separate +versioned/checksummed header. Exact operation replay is idempotent; changed +reuse, illegal transitions, incomplete lines, unknown versions, corruption, +and bounds violations fail closed. One owner-only nonblocking process lock is +held for the store lifetime. An uncertain durable append poisons further +mutation until restart; atomic replacement failures after publish are likewise +reported as uncertain so restart can reconcile the visible final file. + ## MVP boundary The MVP runs four independent processes on different `127.0.0.1` ports: diff --git a/ROADMAP.md b/ROADMAP.md index c046df5..06ade09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,16 @@ # ROADMAP +## 2026-08-14 23:24 CST + +- **Change**: Added Task 9's provisional versioned node configuration, deterministic macOS/Linux user paths, owner-only startup material loader, cross-process state-root lock, and crash-recoverable bootstrap phase journal. +- **Files**: `src/bootstrap/config.rs`, `src/bootstrap/paths.rs`, `src/bootstrap/state.rs`, hardened key/TLS helpers, focused bootstrap tests, `README.md`, this roadmap, and ignored Task 9 report/evidence. +- **Configuration boundary**: `agenet.node-config` schema 1 denies unknown fields, is bounded to 64 KiB, rejects empty/duplicate/excess Directory seeds, and permits only exact IP-literal root endpoints inside the selected boundary. Private-overlay endpoints require HTTPS; plaintext compatibility is explicit loopback policy only. DNS, userinfo, query, fragment, and non-root paths fail before startup. +- **Filesystem boundary**: Current-host roots come only from `directories`; deterministic tests inject explicit platform/root values without global environment mutation. Managed directories are non-symlink, current-euid `0700`; credentials, Ed25519/TLS keys, certificates, CA, config, journal, and lock are separate owner-only regular files. Reads use `O_NOFOLLOW|O_NONBLOCK`, bounded length, and pre/post-open identity checks. The validated startup bundle re-verifies Root/domain/role/profile/time, Ed25519 public-key binding, Authority CA chain, TLS key, NodeId, exact IP SAN, and certificate time before returning material to a later runtime wiring task. +- **Recovery decision**: The JSONL journal has an independently versioned/checksummed header plus bounded complete-newline records with sequence, previous hash, operation ID, transition, and checksum. Only the documented forward path is legal; `RollbackService` is limited to `ServicePrepared`/`ReadyForEnrollment → BinaryInstalled`, retains credentials by being forbidden after `CredentialIssued`, and `Leave` moves any installed non-Left phase to `Left`. Exact operation replay is idempotent; changed reuse conflicts. +- **Durability and writer policy**: One owner-only nonblocking `flock` per state root is held for the store lifetime. Append validates before writing, flushes and syncs before advancing memory, poisons mutation after uncertain persistence, and requires restart reconciliation. Atomic replacement uses same-directory `0600` temporary files, file sync, publish, and parent-directory sync; a post-publish sync error is reported as uncertain and tests prove the published final is visible after restart. +- **Provisional boundary**: These file names, schema, and transitions are explicit v1 choices, not permanent invariants. Task 10 may consume them but must not silently reinterpret unknown versions. Task 12 still owns full runtime/service wiring and live-clock enforcement. The previously recorded Task 5 `encode_handoff` growable serializer remains a must-fix before Task 10. +- **Post-mortem**: Security-hardening integration gap — the first complete gate showed the existing macOS multiprocess demo timing out before `ready.json`. The hardened signing-key reader correctly rejected every symlink component, but the demo passed TempDir's `/var/...` spelling through the system `/var → /private/var` symlink to children. Provisioning now canonicalizes the just-created trusted run root before deriving or passing any child path; the Task 9 managed-root symlink policy remains strict. A second gate exposed that applying the same component policy to the pre-existing public signing-key API broke its established `/var/...` consumer contract. The public API now retains bounded final-file owner/mode/`O_NOFOLLOW` compatibility, while Task 9 startup uses a separate crate-private component-hardened reader. Future filesystem hardening must test both hostile user symlinks, platform path aliases, and existing public consumers before broadening a helper's policy. + ## 2026-08-14 23:38 CST - **Change**: Corrected the Task 8 outbound response-verification order so cryptographic validation always precedes TLS NodeId binding classification. diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs new file mode 100644 index 0000000..38db8dd --- /dev/null +++ b/src/bootstrap/config.rs @@ -0,0 +1,186 @@ +use std::fmt::{Debug, Formatter}; +use std::{collections::BTreeSet, net::IpAddr}; + +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use url::Url; + +use crate::{ + protocol::{BootstrapProfile, CredentialChain, DomainId, NodeRole, verify_credential_chain}, + transport::{PeerTlsIdentity, tls::validate_persisted_peer_identity}, +}; +use zeroize::Zeroizing; + +use super::{ + BootstrapError, NodePaths, + network::{NetworkBoundary, OverlayKind}, +}; + +const CONFIG_FORMAT: &str = "agenet.node-config"; +const CONFIG_SCHEMA_VERSION: u32 = 1; +const MAX_CONFIG_BYTES: usize = 64 * 1024; +const MAX_DIRECTORY_SEEDS: usize = 8; + +/// Provisional v1 node configuration. Future revisions must use a new schema. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NodeConfigV1 { + pub format: String, + pub schema_version: u32, + pub domain_id: DomainId, + pub profile: BootstrapProfile, + pub network: NetworkBoundary, + pub directory_seeds: Vec, + pub authority_endpoint: Url, + pub revocation_endpoint: Url, +} + +pub struct PersistedStartupBundle { + pub config: NodeConfigV1, + pub credential: CredentialChain, + pub signing_key: SigningKey, + pub tls_identity: PeerTlsIdentity, +} + +impl Debug for PersistedStartupBundle { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PersistedStartupBundle") + .field("config", &self.config) + .field("credential", &"[VERIFIED CREDENTIAL]") + .field("signing_key", &"[REDACTED]") + .field("tls_identity", &self.tls_identity) + .finish() + } +} + +pub fn load_startup_bundle( + paths: &NodePaths, + trusted_root: &VerifyingKey, + expected_role: NodeRole, + now_ms: i64, +) -> Result { + let config = paths.read_config()?; + let credential_bytes = paths.read_material(&paths.credential_file, 64 * 1024)?; + let credential: CredentialChain = + serde_json::from_slice(&credential_bytes).map_err(|_| BootstrapError::InvalidConfig)?; + let verified = verify_credential_chain( + trusted_root, + &credential, + &config.domain_id, + expected_role, + now_ms, + ) + .map_err(|_| BootstrapError::InvalidConfig)?; + if verified.bootstrap_profile != config.profile { + return Err(BootstrapError::InvalidConfig); + } + let signing_key = + crate::runtime::key_store::read_signing_key_hardened(&paths.signing_private_key_file) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + if signing_key.verifying_key() != verified.signing_public_key { + return Err(BootstrapError::InvalidConfig); + } + let certificate = read_utf8(&paths.tls_certificate_file, 64 * 1024)?; + let private_key = read_utf8(&paths.tls_private_key_file, 16 * 1024)?; + let authority_ca = read_utf8(&paths.tls_authority_ca_file, 64 * 1024)?; + let tls_identity = PeerTlsIdentity { + node_id: verified.node_id, + certificate_chain_pem: certificate, + private_key_pem: private_key, + authority_ca_pem: authority_ca.to_string(), + }; + validate_persisted_peer_identity(&tls_identity, &config.network, now_ms) + .map_err(|_| BootstrapError::InvalidPki)?; + Ok(PersistedStartupBundle { + config, + credential, + signing_key, + tls_identity, + }) +} + +fn read_utf8(path: &std::path::Path, limit: usize) -> Result, BootstrapError> { + let bytes = crate::runtime::key_store::read_owner_only(path, limit) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + match String::from_utf8(bytes) { + Ok(value) => Ok(Zeroizing::new(value)), + Err(error) => { + let _rejected = Zeroizing::new(error.into_bytes()); + Err(BootstrapError::InvalidPki) + } + } +} + +impl NodeConfigV1 { + pub fn parse_json(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_CONFIG_BYTES { + return Err(BootstrapError::ResourceLimitExceeded); + } + let value: Self = + serde_json::from_slice(bytes).map_err(|_| BootstrapError::UnsupportedConfigFormat)?; + if value.format != CONFIG_FORMAT || value.schema_version != CONFIG_SCHEMA_VERSION { + return Err(BootstrapError::UnsupportedConfigFormat); + } + value.validate()?; + Ok(value) + } + + pub fn validate(&self) -> Result<(), BootstrapError> { + if self.format != CONFIG_FORMAT + || self.schema_version != CONFIG_SCHEMA_VERSION + || DomainId::new(self.domain_id.as_str()).is_err() + || self.directory_seeds.is_empty() + || self.directory_seeds.len() > MAX_DIRECTORY_SEEDS + { + return Err(BootstrapError::InvalidConfig); + } + self.network + .validate_bind_shape() + .map_err(|_| BootstrapError::InvalidConfig)?; + if self.network.allowed_cidrs.is_empty() + || self.network.allowed_cidrs.len() > 16 + || self + .network + .allowed_cidrs + .iter() + .collect::>() + .len() + != self.network.allowed_cidrs.len() + { + return Err(BootstrapError::InvalidConfig); + } + let mut unique = BTreeSet::new(); + for endpoint in &self.directory_seeds { + if !unique.insert(endpoint.as_str()) { + return Err(BootstrapError::InvalidConfig); + } + validate_endpoint(&self.network, endpoint)?; + } + validate_endpoint(&self.network, &self.authority_endpoint)?; + validate_endpoint(&self.network, &self.revocation_endpoint) + } +} + +fn validate_endpoint(boundary: &NetworkBoundary, endpoint: &Url) -> Result<(), BootstrapError> { + if !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || endpoint.path() != "/" + { + return Err(BootstrapError::InvalidConfig); + } + let address = endpoint + .host_str() + .and_then(|host| host.trim_matches(['[', ']']).parse::().ok()) + .ok_or(BootstrapError::InvalidConfig)?; + let valid_scheme = match boundary.kind { + OverlayKind::Loopback => endpoint.scheme() == "http" || endpoint.scheme() == "https", + OverlayKind::Tailscale | OverlayKind::WireGuard => endpoint.scheme() == "https", + }; + if !valid_scheme || !boundary.allows_peer(address) { + return Err(BootstrapError::InvalidConfig); + } + Ok(()) +} diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 2c5a74e..d2e1444 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -1,11 +1,14 @@ //! Bootstrap orchestration boundary for the v0.2 multi-host preview. +mod config; mod enrollment; mod invitation; mod journal; mod keystore; pub mod network; +mod paths; mod pki; +mod state; use std::fmt::{Display, Formatter}; @@ -24,9 +27,11 @@ pub use keystore::{ AgeRootKeystore, DomainRootMaterial, LegacyV1MigrationPolicy, RootKeystore, RootKeystoreFormatVersion, UnlockedRootKeystore, prompt_root_passphrase, }; +pub use paths::{NodePathEnvironment, NodePaths, UserPlatform}; pub use pki::{ AGENET_NODE_ID_OID, AuthorityPki, IssuedClientCertificate, IssuedServerIdentity, NodeTlsCsr, }; +pub use state::{BootstrapPhase, BootstrapStateStore, BootstrapTransition, TransitionOutcome}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BootstrapError { @@ -49,6 +54,13 @@ pub enum BootstrapError { InvalidOperationId, InvalidTimestamp, UnsupportedInvitationFormat, + UnsupportedConfigFormat, + InvalidConfig, + UnsafeStatePath, + InvalidBootstrapJournal, + InvalidBootstrapTransition, + OperationConflict, + StateLocked, } impl Display for BootstrapError { @@ -58,3 +70,4 @@ impl Display for BootstrapError { } impl std::error::Error for BootstrapError {} +pub use config::{NodeConfigV1, PersistedStartupBundle, load_startup_bundle}; diff --git a/src/bootstrap/network.rs b/src/bootstrap/network.rs index 7f9b29d..28c4435 100644 --- a/src/bootstrap/network.rs +++ b/src/bootstrap/network.rs @@ -8,17 +8,20 @@ use std::{ }; use ipnet::IpNet; +use serde::{Deserialize, Serialize}; use crate::{protocol::ProtocolError, runtime::RuntimeError}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum OverlayKind { Loopback, Tailscale, WireGuard, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct NetworkBoundary { pub kind: OverlayKind, pub bind_ip: IpAddr, @@ -122,6 +125,17 @@ impl NetworkBoundary { self.validate_bind_with(&TailscaleAddressVerifier::default()) } + /// Validates persisted policy shape without consulting host network state. + pub fn validate_bind_shape(&self) -> Result<(), RuntimeError> { + if (self.kind == OverlayKind::WireGuard && self.allowed_cidrs.is_empty()) + || !address_matches_overlay(self.kind, self.bind_ip) + || !self.contains(self.bind_ip) + { + return Err(unsupported_boundary()); + } + Ok(()) + } + pub fn validate_bind_with( &self, verifier: &dyn AssignedAddressVerifier, diff --git a/src/bootstrap/paths.rs b/src/bootstrap/paths.rs new file mode 100644 index 0000000..77ae7c8 --- /dev/null +++ b/src/bootstrap/paths.rs @@ -0,0 +1,201 @@ +use std::path::{Component, Path, PathBuf}; + +use crate::runtime::key_store::{ + atomic_write_owner_only, ensure_owner_only_dir, read_owner_only, write_signing_key, +}; +use crate::{protocol::CredentialChain, transport::PeerTlsIdentity}; +use ed25519_dalek::SigningKey; +use zeroize::Zeroizing; + +use super::{BootstrapError, NodeConfigV1}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UserPlatform { + MacOs, + Linux, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodePathEnvironment { + pub home: PathBuf, + pub xdg_config_home: Option, + pub xdg_state_home: Option, +} + +impl NodePathEnvironment { + pub fn new( + home: PathBuf, + xdg_config_home: Option, + xdg_state_home: Option, + ) -> Self { + Self { + home, + xdg_config_home, + xdg_state_home, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodePaths { + pub config_dir: PathBuf, + pub state_dir: PathBuf, + pub config_file: PathBuf, + pub credential_file: PathBuf, + pub signing_private_key_file: PathBuf, + pub tls_certificate_file: PathBuf, + pub tls_private_key_file: PathBuf, + pub tls_authority_ca_file: PathBuf, + pub service_metadata_file: PathBuf, + pub revocation_file: PathBuf, + pub journal_file: PathBuf, + pub lock_file: PathBuf, + pub service_definition: PathBuf, +} + +impl NodePaths { + pub fn for_current_user() -> Result { + let base = directories::BaseDirs::new().ok_or(BootstrapError::InvalidStatePath)?; + #[cfg(target_os = "macos")] + let (platform, environment) = ( + UserPlatform::MacOs, + NodePathEnvironment::new(base.home_dir().to_path_buf(), None, None), + ); + #[cfg(target_os = "linux")] + let (platform, environment) = ( + UserPlatform::Linux, + NodePathEnvironment::new( + base.home_dir().to_path_buf(), + Some(base.config_dir().to_path_buf()), + base.state_dir().map(Path::to_path_buf), + ), + ); + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + return Err(BootstrapError::InvalidStatePath); + Self::resolve(platform, &environment) + } + + pub fn resolve( + platform: UserPlatform, + environment: &NodePathEnvironment, + ) -> Result { + validate_root(&environment.home)?; + let (config_dir, state_dir, service_definition) = match platform { + UserPlatform::MacOs => { + let root = environment.home.join("Library/Application Support/AgenNet"); + ( + root.clone(), + root, + environment + .home + .join("Library/LaunchAgents/org.nexa-language.agenet.plist"), + ) + } + UserPlatform::Linux => { + let config_root = environment + .xdg_config_home + .clone() + .unwrap_or_else(|| environment.home.join(".config")); + let state_root = environment + .xdg_state_home + .clone() + .unwrap_or_else(|| environment.home.join(".local/state")); + validate_root(&config_root)?; + validate_root(&state_root)?; + ( + config_root.join("agenet"), + state_root.join("agenet"), + environment.home.join(".config/systemd/user/agenet.service"), + ) + } + }; + Ok(Self { + config_file: config_dir.join("node-config-v1.json"), + credential_file: state_dir.join("node-credential-v1.json"), + signing_private_key_file: state_dir.join("node-signing-key-v1.key"), + tls_certificate_file: state_dir.join("peer-certificate-v1.pem"), + tls_private_key_file: state_dir.join("peer-private-key-v1.pem"), + tls_authority_ca_file: state_dir.join("authority-ca-v1.pem"), + service_metadata_file: state_dir.join("service-metadata-v1.json"), + revocation_file: state_dir.join("revocation-cache-v1.json"), + journal_file: state_dir.join("bootstrap-state-v1.jsonl"), + lock_file: state_dir.join("bootstrap-state-v1.lock"), + config_dir, + state_dir, + service_definition, + }) + } + + pub fn ensure_secure_layout(&self) -> Result<(), BootstrapError> { + ensure_owner_only_dir(&self.config_dir).map_err(|_| BootstrapError::UnsafeStatePath)?; + ensure_owner_only_dir(&self.state_dir).map_err(|_| BootstrapError::UnsafeStatePath) + } + + pub fn write_config(&self, config: &NodeConfigV1) -> Result<(), BootstrapError> { + config.validate()?; + self.ensure_secure_layout()?; + let mut bytes = serde_json::to_vec(config).map_err(|_| BootstrapError::InvalidConfig)?; + bytes.push(b'\n'); + atomic_write_owner_only(&self.config_file, &bytes, true) + .map_err(|_| BootstrapError::PersistenceUnavailable) + } + + pub fn read_config(&self) -> Result { + let bytes = read_owner_only(&self.config_file, 64 * 1024) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + NodeConfigV1::parse_json(&bytes) + } + + pub fn write_startup_material( + &self, + credential: &CredentialChain, + signing_key: &SigningKey, + identity: &PeerTlsIdentity, + ) -> Result<(), BootstrapError> { + self.ensure_secure_layout()?; + let mut credential_bytes = + serde_json::to_vec(credential).map_err(|_| BootstrapError::InvalidConfig)?; + credential_bytes.push(b'\n'); + write_material(&self.credential_file, &credential_bytes)?; + write_signing_key(&self.signing_private_key_file, signing_key) + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + write_material( + &self.tls_certificate_file, + identity.certificate_chain_pem.as_bytes(), + )?; + write_material( + &self.tls_private_key_file, + identity.private_key_pem.as_bytes(), + )?; + write_material( + &self.tls_authority_ca_file, + identity.authority_ca_pem.as_bytes(), + ) + } + + pub(crate) fn read_material( + &self, + path: &Path, + limit: usize, + ) -> Result>, BootstrapError> { + read_owner_only(path, limit) + .map(Zeroizing::new) + .map_err(|_| BootstrapError::UnsafeStatePath) + } +} + +fn write_material(path: &Path, bytes: &[u8]) -> Result<(), BootstrapError> { + atomic_write_owner_only(path, bytes, true).map_err(|_| BootstrapError::PersistenceUnavailable) +} + +fn validate_root(path: &Path) -> Result<(), BootstrapError> { + if !path.is_absolute() + || path == Path::new("/") + || path + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(BootstrapError::InvalidStatePath); + } + Ok(()) +} diff --git a/src/bootstrap/state.rs b/src/bootstrap/state.rs new file mode 100644 index 0000000..cebd525 --- /dev/null +++ b/src/bootstrap/state.rs @@ -0,0 +1,453 @@ +use std::{ + collections::BTreeMap, + fs::File, + io::{Seek, SeekFrom, Write}, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::runtime::key_store::{ + OwnerOnlyLockError, ensure_owner_only_dir, open_owner_only_append, open_owner_only_lock, + read_owner_only, +}; + +use super::BootstrapError; + +const JOURNAL_FORMAT: &str = "agenet.bootstrap-journal"; +const JOURNAL_SCHEMA: u32 = 1; +const MAX_JOURNAL_BYTES: usize = 256 * 1024; +const MAX_RECORDS: usize = 1024; +const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BootstrapPhase { + Absent, + BinaryInstalled, + ServicePrepared, + ReadyForEnrollment, + CredentialIssued, + Registered, + Healthy, + Left, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BootstrapTransition { + Advance(BootstrapPhase), + RollbackService, + Leave, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransitionOutcome { + Applied(BootstrapPhase), + AlreadyApplied(BootstrapPhase), +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct JournalHeader { + format: String, + schema_version: u32, + checksum: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct JournalRecord { + schema_version: u32, + sequence: u64, + previous_hash: String, + operation_id: String, + transition: BootstrapTransition, + from: BootstrapPhase, + to: BootstrapPhase, + checksum: String, +} + +#[derive(Debug, Serialize)] +struct RecordClaims<'a> { + schema_version: u32, + sequence: u64, + previous_hash: &'a str, + operation_id: &'a str, + transition: &'a BootstrapTransition, + from: BootstrapPhase, + to: BootstrapPhase, +} + +pub struct BootstrapStateStore { + _lock: File, + file: File, + phase: BootstrapPhase, + sequence: u64, + previous_hash: String, + operations: BTreeMap, + poisoned: bool, + sync_record: fn(&File) -> std::io::Result<()>, +} + +impl BootstrapStateStore { + pub fn open(journal_path: &Path) -> Result { + Self::open_with_sync(journal_path, File::sync_data) + } + + fn open_with_sync( + journal_path: &Path, + sync_record: fn(&File) -> std::io::Result<()>, + ) -> Result { + if !journal_path.is_absolute() { + return Err(BootstrapError::InvalidStatePath); + } + let parent = journal_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + ensure_owner_only_dir(parent).map_err(|_| BootstrapError::UnsafeStatePath)?; + let lock_path = lock_path(journal_path)?; + let lock = open_owner_only_lock(&lock_path).map_err(|error| match error { + OwnerOnlyLockError::Locked => BootstrapError::StateLocked, + OwnerOnlyLockError::Unsafe => BootstrapError::UnsafeStatePath, + })?; + let existing_identity = match std::fs::symlink_metadata(journal_path) { + Ok(metadata) => Some((metadata.dev(), metadata.ino())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(BootstrapError::UnsafeStatePath), + }; + let exists = existing_identity.is_some(); + let (phase, sequence, previous_hash, operations) = if exists { + replay(journal_path)? + } else { + ( + BootstrapPhase::Absent, + 0, + ZERO_HASH.to_owned(), + BTreeMap::new(), + ) + }; + let mut file = open_owner_only_append(journal_path, !exists) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + if let Some((expected_dev, expected_ino)) = existing_identity { + let opened = file + .metadata() + .map_err(|_| BootstrapError::UnsafeStatePath)?; + if opened.dev() != expected_dev || opened.ino() != expected_ino { + return Err(BootstrapError::UnsafeStatePath); + } + } + if !exists { + write_header(&mut file)?; + sync_parent(journal_path)?; + } + Ok(Self { + _lock: lock, + file, + phase, + sequence, + previous_hash, + operations, + poisoned: false, + sync_record, + }) + } + + pub fn phase(&self) -> BootstrapPhase { + self.phase + } + + pub fn apply( + &mut self, + operation_id: &str, + transition: BootstrapTransition, + ) -> Result { + if self.poisoned { + return Err(BootstrapError::PersistenceUnavailable); + } + validate_operation_id(operation_id)?; + if let Some((existing, phase)) = self.operations.get(operation_id) { + return if existing == &transition { + Ok(TransitionOutcome::AlreadyApplied(*phase)) + } else { + Err(BootstrapError::OperationConflict) + }; + } + let next = next_phase(self.phase, &transition)?; + let sequence = self + .sequence + .checked_add(1) + .ok_or(BootstrapError::ResourceLimitExceeded)?; + let checksum = record_hash( + sequence, + &self.previous_hash, + operation_id, + &transition, + self.phase, + next, + )?; + let record = JournalRecord { + schema_version: JOURNAL_SCHEMA, + sequence, + previous_hash: self.previous_hash.clone(), + operation_id: operation_id.to_owned(), + transition: transition.clone(), + from: self.phase, + to: next, + checksum: checksum.clone(), + }; + if self.append_record(&record).is_err() { + self.poisoned = true; + return Err(BootstrapError::PersistenceUnavailable); + } + self.phase = next; + self.sequence = sequence; + self.previous_hash = checksum; + self.operations + .insert(operation_id.to_owned(), (transition, next)); + Ok(TransitionOutcome::Applied(next)) + } + + fn append_record(&mut self, record: &JournalRecord) -> Result<(), BootstrapError> { + let mut bytes = + serde_json::to_vec(record).map_err(|_| BootstrapError::InvalidBootstrapJournal)?; + bytes.push(b'\n'); + if bytes.len() > 8192 { + return Err(BootstrapError::ResourceLimitExceeded); + } + self.file + .seek(SeekFrom::End(0)) + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + self.file + .write_all(&bytes) + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + self.file + .flush() + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + (self.sync_record)(&self.file).map_err(|_| BootstrapError::PersistenceUnavailable) + } +} + +fn write_header(file: &mut File) -> Result<(), BootstrapError> { + let mut bytes = serde_json::to_vec(&JournalHeader { + format: JOURNAL_FORMAT.to_owned(), + schema_version: JOURNAL_SCHEMA, + checksum: header_hash(), + }) + .map_err(|_| BootstrapError::InvalidBootstrapJournal)?; + bytes.push(b'\n'); + file.write_all(&bytes) + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + file.flush() + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + file.sync_data() + .map_err(|_| BootstrapError::PersistenceUnavailable) +} + +type Replay = ( + BootstrapPhase, + u64, + String, + BTreeMap, +); + +fn replay(path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(path).map_err(|_| BootstrapError::UnsafeStatePath)?; + if metadata.len() > MAX_JOURNAL_BYTES as u64 { + return Err(BootstrapError::ResourceLimitExceeded); + } + let bytes = + read_owner_only(path, MAX_JOURNAL_BYTES).map_err(|_| BootstrapError::UnsafeStatePath)?; + if bytes.is_empty() || !bytes.ends_with(b"\n") { + return Err(BootstrapError::InvalidBootstrapJournal); + } + let mut lines = bytes.split(|byte| *byte == b'\n'); + let header: JournalHeader = serde_json::from_slice(lines.next().unwrap_or_default()) + .map_err(|_| BootstrapError::InvalidBootstrapJournal)?; + if header.format != JOURNAL_FORMAT + || header.schema_version != JOURNAL_SCHEMA + || header.checksum != header_hash() + { + return Err(BootstrapError::InvalidBootstrapJournal); + } + let mut phase = BootstrapPhase::Absent; + let mut sequence = 0; + let mut previous_hash = ZERO_HASH.to_owned(); + let mut operations = BTreeMap::new(); + for line in lines.filter(|line| !line.is_empty()) { + if operations.len() >= MAX_RECORDS || line.len() > 8192 { + return Err(BootstrapError::ResourceLimitExceeded); + } + let record: JournalRecord = + serde_json::from_slice(line).map_err(|_| BootstrapError::InvalidBootstrapJournal)?; + let expected_to = next_phase(phase, &record.transition)?; + let expected_hash = record_hash( + record.sequence, + &record.previous_hash, + &record.operation_id, + &record.transition, + record.from, + record.to, + )?; + if record.schema_version != JOURNAL_SCHEMA + || record.sequence != sequence + 1 + || record.previous_hash != previous_hash + || record.from != phase + || record.to != expected_to + || record.checksum != expected_hash + || operations.contains_key(&record.operation_id) + { + return Err(BootstrapError::InvalidBootstrapJournal); + } + phase = record.to; + sequence = record.sequence; + previous_hash = record.checksum.clone(); + operations.insert(record.operation_id, (record.transition, record.to)); + } + Ok((phase, sequence, previous_hash, operations)) +} + +fn sync_parent(path: &Path) -> Result<(), BootstrapError> { + File::open(path.parent().unwrap_or(Path::new("."))) + .and_then(|directory| directory.sync_all()) + .map_err(|_| BootstrapError::PersistenceUnavailable) +} + +fn next_phase( + current: BootstrapPhase, + transition: &BootstrapTransition, +) -> Result { + match (current, transition) { + (BootstrapPhase::Absent, BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled)) => { + Ok(BootstrapPhase::BinaryInstalled) + } + ( + BootstrapPhase::BinaryInstalled, + BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), + ) => Ok(BootstrapPhase::ServicePrepared), + ( + BootstrapPhase::ServicePrepared, + BootstrapTransition::Advance(BootstrapPhase::ReadyForEnrollment), + ) => Ok(BootstrapPhase::ReadyForEnrollment), + ( + BootstrapPhase::ReadyForEnrollment, + BootstrapTransition::Advance(BootstrapPhase::CredentialIssued), + ) => Ok(BootstrapPhase::CredentialIssued), + ( + BootstrapPhase::CredentialIssued, + BootstrapTransition::Advance(BootstrapPhase::Registered), + ) => Ok(BootstrapPhase::Registered), + (BootstrapPhase::Registered, BootstrapTransition::Advance(BootstrapPhase::Healthy)) => { + Ok(BootstrapPhase::Healthy) + } + ( + BootstrapPhase::ServicePrepared | BootstrapPhase::ReadyForEnrollment, + BootstrapTransition::RollbackService, + ) => Ok(BootstrapPhase::BinaryInstalled), + ( + BootstrapPhase::BinaryInstalled + | BootstrapPhase::ServicePrepared + | BootstrapPhase::ReadyForEnrollment + | BootstrapPhase::CredentialIssued + | BootstrapPhase::Registered + | BootstrapPhase::Healthy, + BootstrapTransition::Leave, + ) => Ok(BootstrapPhase::Left), + _ => Err(BootstrapError::InvalidBootstrapTransition), + } +} + +fn record_hash( + sequence: u64, + previous_hash: &str, + operation_id: &str, + transition: &BootstrapTransition, + from: BootstrapPhase, + to: BootstrapPhase, +) -> Result { + let claims = RecordClaims { + schema_version: JOURNAL_SCHEMA, + sequence, + previous_hash, + operation_id, + transition, + from, + to, + }; + let bytes = serde_json::to_vec(&claims).map_err(|_| BootstrapError::InvalidBootstrapJournal)?; + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").map_err(|_| BootstrapError::InvalidBootstrapJournal)?; + } + Ok(encoded) +} + +fn validate_operation_id(value: &str) -> Result<(), BootstrapError> { + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_whitespace) { + return Err(BootstrapError::InvalidOperationId); + } + Ok(()) +} + +fn header_hash() -> String { + hex_digest(format!( + "AGENET\0bootstrap-journal-header\0{JOURNAL_FORMAT}\0{JOURNAL_SCHEMA}" + )) +} + +fn lock_path(journal: &Path) -> Result { + let parent = journal.parent().ok_or(BootstrapError::InvalidStatePath)?; + Ok(parent.join("bootstrap-state-v1.lock")) +} + +fn hex_digest(bytes: impl AsRef<[u8]>) -> String { + let digest = Sha256::digest(bytes.as_ref()); + let mut encoded = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut encoded, "{byte:02x}"); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fail_sync(_: &File) -> std::io::Result<()> { + Err(std::io::Error::other("injected post-write sync failure")) + } + + #[test] + fn uncertain_append_poisons_memory_and_restart_replays_published_record() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap().join("state"); + let journal = root.join("journal"); + let mut store = BootstrapStateStore::open_with_sync(&journal, fail_sync).unwrap(); + assert_eq!( + store.apply( + "install", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled) + ), + Err(BootstrapError::PersistenceUnavailable) + ); + assert_eq!(store.phase(), BootstrapPhase::Absent); + assert_eq!( + store.apply( + "retry", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled) + ), + Err(BootstrapError::PersistenceUnavailable) + ); + drop(store); + assert_eq!( + BootstrapStateStore::open(&journal).unwrap().phase(), + BootstrapPhase::BinaryInstalled + ); + } +} diff --git a/src/demo.rs b/src/demo.rs index 4de9ab9..cc32209 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -97,11 +97,12 @@ async fn run_inner(options: DemoOptions) -> Result { return Err("ArtifactTooLarge".to_owned()); } let run_id = uuid::Uuid::new_v4().to_string(); - let root_dir = match options.state_dir { + let requested_root_dir = match options.state_dir { Some(directory) => directory.join(&run_id), None => PathBuf::from(".local/demo").join(&run_id), }; - fs::create_dir_all(&root_dir).map_err(sanitized)?; + fs::create_dir_all(&requested_root_dir).map_err(sanitized)?; + let root_dir = fs::canonicalize(&requested_root_dir).map_err(sanitized)?; let executable = std::env::current_exe().map_err(sanitized)?; let now = unix_ms(); let root_key = random_signing_key()?; diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index 6d487f7..42be1b4 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -1,7 +1,10 @@ use std::{ - fs::{self, File, OpenOptions}, - io::Write, - os::unix::fs::OpenOptionsExt, + fs::{self, DirBuilder, File, OpenOptions}, + io::{Read, Write}, + os::unix::{ + fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}, + io::{AsRawFd, FromRawFd}, + }, path::{Path, PathBuf}, }; @@ -19,7 +22,21 @@ pub fn write_signing_key(path: &Path, key: &SigningKey) -> Result<(), RuntimeErr } pub fn read_signing_key(path: &Path) -> Result { - let encoded = Zeroizing::new(fs::read_to_string(path)?); + decode_signing_key(read_owner_only_final(path, 4096)?) +} + +pub(crate) fn read_signing_key_hardened(path: &Path) -> Result { + decode_signing_key(read_owner_only(path, 4096)?) +} + +fn decode_signing_key(encoded_bytes: Vec) -> Result { + let encoded = match String::from_utf8(encoded_bytes) { + Ok(value) => Zeroizing::new(value), + Err(error) => { + let _rejected = Zeroizing::new(error.into_bytes()); + return Err(RuntimeError::InvalidPrivateKey); + } + }; let bytes = Zeroizing::new( STANDARD .decode(encoded.trim()) @@ -34,6 +51,208 @@ pub fn read_signing_key(path: &Path) -> Result { Ok(SigningKey::from_bytes(&secret)) } +fn read_owner_only_final(path: &Path, limit: usize) -> Result, RuntimeError> { + read_owner_only_impl(path, limit, false) +} + +pub(crate) fn ensure_owner_only_dir(path: &Path) -> Result<(), RuntimeError> { + reject_symlink_components(path)?; + if !path.exists() { + create_owner_only_components(path)?; + } + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + { + return Err(RuntimeError::Io); + } + if fs::canonicalize(path)? != path { + return Err(RuntimeError::Io); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OwnerOnlyLockError { + Unsafe, + Locked, +} + +pub(crate) fn read_owner_only(path: &Path, limit: usize) -> Result, RuntimeError> { + read_owner_only_impl(path, limit, true) +} + +fn read_owner_only_impl( + path: &Path, + limit: usize, + reject_components: bool, +) -> Result, RuntimeError> { + if reject_components { + reject_symlink_components(path)?; + } + let before = fs::symlink_metadata(path)?; + if !safe_regular(&before) { + return Err(RuntimeError::Io); + } + let raw = unsafe { + libc::open( + std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) + .map_err(|_| RuntimeError::Io)? + .as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if raw < 0 { + return Err(RuntimeError::Io); + } + let mut file = unsafe { File::from_raw_fd(raw) }; + let after = file.metadata()?; + if !safe_regular(&after) || before.dev() != after.dev() || before.ino() != after.ino() { + return Err(RuntimeError::Io); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take((limit + 1) as u64) + .read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(RuntimeError::Io); + } + Ok(bytes) +} + +pub(crate) fn open_owner_only_lock(path: &Path) -> Result { + reject_symlink_components(path).map_err(|_| OwnerOnlyLockError::Unsafe)?; + let (file, before, created) = open_lock_file(path)?; + let after = file.metadata().map_err(|_| OwnerOnlyLockError::Unsafe)?; + if !safe_regular(&after) + || before + .as_ref() + .is_some_and(|metadata| metadata.dev() != after.dev() || metadata.ino() != after.ino()) + { + return Err(OwnerOnlyLockError::Unsafe); + } + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result != 0 { + return Err(OwnerOnlyLockError::Locked); + } + if created { + file.sync_data().map_err(|_| OwnerOnlyLockError::Unsafe)?; + sync_directory(path.parent().unwrap_or(Path::new("."))) + .map_err(|_| OwnerOnlyLockError::Unsafe)?; + } + Ok(file) +} + +fn open_lock_file(path: &Path) -> Result<(File, Option, bool), OwnerOnlyLockError> { + for _ in 0..2 { + match fs::symlink_metadata(path) { + Ok(before) => { + let file = lock_options(false) + .open(path) + .map_err(|_| OwnerOnlyLockError::Unsafe)?; + return Ok((file, Some(before), false)); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match lock_options(true).open(path) { + Ok(file) => return Ok((file, None, true)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => return Err(OwnerOnlyLockError::Unsafe), + } + } + Err(_) => return Err(OwnerOnlyLockError::Unsafe), + } + } + Err(OwnerOnlyLockError::Unsafe) +} + +fn lock_options(create_new: bool) -> OpenOptions { + let mut options = OpenOptions::new(); + options + .read(true) + .write(true) + .create_new(create_new) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC); + options +} + +pub(crate) fn open_owner_only_append(path: &Path, create_new: bool) -> Result { + reject_symlink_components(path)?; + let before = fs::symlink_metadata(path).ok(); + let file = OpenOptions::new() + .read(true) + .append(true) + .create_new(create_new) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path)?; + let after = file.metadata()?; + if !safe_regular(&after) + || before + .as_ref() + .is_some_and(|metadata| metadata.dev() != after.dev() || metadata.ino() != after.ino()) + { + return Err(RuntimeError::Io); + } + Ok(file) +} + +fn safe_regular(metadata: &fs::Metadata) -> bool { + safe_regular_values( + metadata.file_type().is_file() && !metadata.file_type().is_symlink(), + metadata.uid(), + metadata.mode(), + ) +} + +fn safe_regular_values(is_regular: bool, uid: u32, mode: u32) -> bool { + is_regular && uid == unsafe { libc::geteuid() } && mode & 0o777 == 0o600 +} + +fn reject_symlink_components(path: &Path) -> Result<(), RuntimeError> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => return Err(RuntimeError::Io), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(RuntimeError::Io), + } + } + Ok(()) +} + +fn create_owner_only_components(path: &Path) -> Result<(), RuntimeError> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => return Err(RuntimeError::Io), + Ok(_) => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match DirBuilder::new().mode(0o700).create(¤t) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + let metadata = fs::symlink_metadata(¤t)?; + if !metadata.file_type().is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + { + return Err(RuntimeError::Io); + } + } + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + pub(crate) fn atomic_write_owner_only( path: &Path, bytes: &[u8], @@ -60,6 +279,17 @@ fn write_and_publish( path: &Path, bytes: &[u8], replace: bool, +) -> Result<(), RuntimeError> { + write_and_publish_with_sync(parent, temp_path, path, bytes, replace, sync_directory) +} + +fn write_and_publish_with_sync( + parent: &Path, + temp_path: &Path, + path: &Path, + bytes: &[u8], + replace: bool, + sync_parent: fn(&Path) -> Result<(), RuntimeError>, ) -> Result<(), RuntimeError> { let mut file = OpenOptions::new() .create_new(true) @@ -77,7 +307,7 @@ fn write_and_publish( fs::hard_link(temp_path, path)?; fs::remove_file(temp_path)?; } - sync_directory(parent) + sync_parent(parent) } fn normalized_parent(path: &Path) -> Result<&Path, RuntimeError> { @@ -92,3 +322,51 @@ fn sync_directory(path: &Path) -> Result<(), RuntimeError> { File::open(PathBuf::from(path))?.sync_all()?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn fail_directory_sync(_: &Path) -> Result<(), RuntimeError> { + Err(RuntimeError::Io) + } + + #[test] + fn post_rename_directory_sync_error_reports_uncertain_but_restart_sees_final() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().canonicalize().unwrap(); + let temporary = parent.join(".state.tmp"); + let final_path = parent.join("state"); + assert_eq!( + write_and_publish_with_sync( + &parent, + &temporary, + &final_path, + b"committed", + true, + fail_directory_sync, + ), + Err(RuntimeError::Io) + ); + assert_eq!(std::fs::read(&final_path).unwrap(), b"committed"); + } + + #[test] + fn owner_mode_validator_rejects_foreign_owner_and_nonregular_inputs() { + let temp = tempfile::NamedTempFile::new().unwrap(); + std::fs::set_permissions( + temp.path(), + std::os::unix::fs::PermissionsExt::from_mode(0o600), + ) + .unwrap(); + let metadata = temp.as_file().metadata().unwrap(); + assert!(safe_regular(&metadata)); + assert_ne!(metadata.uid().wrapping_add(1), unsafe { libc::geteuid() }); + assert!(!safe_regular_values( + true, + metadata.uid().wrapping_add(1), + metadata.mode(), + )); + assert!(!safe_regular_values(false, metadata.uid(), metadata.mode())); + } +} diff --git a/src/transport/tls.rs b/src/transport/tls.rs index edc80b5..f611b98 100644 --- a/src/transport/tls.rs +++ b/src/transport/tls.rs @@ -100,6 +100,69 @@ pub fn build_peer_client( .map_err(|_| TransportError::TlsRejected) } +pub(crate) fn validate_persisted_peer_identity( + identity: &PeerTlsIdentity, + boundary: &NetworkBoundary, + now_ms: i64, +) -> Result<(), TransportError> { + boundary + .validate_bind_shape() + .map_err(|_| TransportError::InvalidEndpoint)?; + let roots = parse_roots(&identity.authority_ca_pem)?; + let certificates = parse_certificates(&identity.certificate_chain_pem)?; + validate_local_leaf(identity, &certificates)?; + validate_leaf_ip(&certificates[0], boundary.bind_ip)?; + validate_leaf_time(&certificates[0], now_ms)?; + let verifier = WebPkiClientVerifier::builder(Arc::new(roots.clone())) + .build() + .map_err(|_| TransportError::TlsRejected)?; + let timestamp = unix_time_seconds(now_ms)?; + verifier + .verify_client_cert( + &certificates[0], + &certificates[1..], + rustls::pki_types::UnixTime::since_unix_epoch(Duration::from_secs(timestamp)), + ) + .map_err(|_| TransportError::TlsRejected)?; + let private_key = parse_pkcs8_private_key(&identity.private_key_pem)?; + ClientConfig::builder() + .with_root_certificates(roots) + .with_client_auth_cert(certificates, private_key) + .map_err(|_| TransportError::TlsRejected)?; + Ok(()) +} + +fn unix_time_seconds(now_ms: i64) -> Result { + if now_ms < 0 { + return Err(TransportError::TlsRejected); + } + u64::try_from(now_ms / 1_000).map_err(|_| TransportError::TlsRejected) +} + +fn validate_leaf_time(certificate: &CertificateDer<'_>, now_ms: i64) -> Result<(), TransportError> { + if now_ms < 0 { + return Err(TransportError::TlsRejected); + } + let (_, parsed) = + X509Certificate::from_der(certificate.as_ref()).map_err(|_| TransportError::TlsRejected)?; + let not_before = parsed + .validity() + .not_before + .timestamp() + .checked_mul(1_000) + .ok_or(TransportError::TlsRejected)?; + let not_after = parsed + .validity() + .not_after + .timestamp() + .checked_mul(1_000) + .ok_or(TransportError::TlsRejected)?; + if now_ms < not_before || now_ms >= not_after { + return Err(TransportError::TlsRejected); + } + Ok(()) +} + struct NodeBoundServerVerifier { webpki: Arc, expected_peer: NodeId, @@ -548,3 +611,17 @@ fn strict_pem_labels(pem: &str) -> Result, TransportError> { } Ok(labels) } + +#[cfg(test)] +mod persisted_time_tests { + use super::*; + + #[test] + fn persisted_tls_time_rejects_negative_values_without_truncating_to_epoch() { + assert_eq!(unix_time_seconds(-1), Err(TransportError::TlsRejected)); + assert_eq!(unix_time_seconds(-999), Err(TransportError::TlsRejected)); + assert_eq!(unix_time_seconds(0), Ok(0)); + assert_eq!(unix_time_seconds(999), Ok(0)); + assert_eq!(unix_time_seconds(1_000), Ok(1)); + } +} diff --git a/tests/bootstrap_config.rs b/tests/bootstrap_config.rs new file mode 100644 index 0000000..25c1c15 --- /dev/null +++ b/tests/bootstrap_config.rs @@ -0,0 +1,417 @@ +use std::{net::IpAddr, path::PathBuf}; + +use agenet::{ + bootstrap::{ + AuthorityPki, BootstrapError, NodeConfigV1, NodePathEnvironment, NodePaths, NodeTlsCsr, + UserPlatform, load_startup_bundle, + network::{NetworkBoundary, OverlayKind}, + }, + protocol::{BootstrapProfile, DomainId, NodeId, NodeRole}, + transport::PeerTlsIdentity, +}; +use ed25519_dalek::SigningKey; +use ipnet::IpNet; +use tempfile::TempDir; +use url::Url; +use zeroize::Zeroizing; + +mod common; + +fn private_config() -> NodeConfigV1 { + let ip: IpAddr = "100.64.0.10".parse().unwrap(); + NodeConfigV1 { + format: "agenet.node-config".to_owned(), + schema_version: 1, + domain_id: DomainId::new("domain-a").unwrap(), + profile: BootstrapProfile::Provider, + network: NetworkBoundary { + kind: OverlayKind::Tailscale, + bind_ip: ip, + allowed_cidrs: vec!["100.64.0.0/10".parse::().unwrap()], + }, + directory_seeds: vec![Url::parse("https://100.64.0.2:7443/").unwrap()], + authority_endpoint: Url::parse("https://100.64.0.3:7443/").unwrap(), + revocation_endpoint: Url::parse("https://100.64.0.3:7443/").unwrap(), + } +} + +#[test] +fn config_parser_accepts_v1_and_rejects_unknown_or_future_schema() { + let encoded = serde_json::to_vec(&private_config()).unwrap(); + let parsed = NodeConfigV1::parse_json(&encoded).unwrap(); + assert_eq!(parsed.domain_id.as_str(), "domain-a"); + + let mut unknown: serde_json::Value = serde_json::from_slice(&encoded).unwrap(); + unknown["surprise"] = serde_json::json!(true); + assert_eq!( + NodeConfigV1::parse_json(&serde_json::to_vec(&unknown).unwrap()), + Err(BootstrapError::UnsupportedConfigFormat) + ); + assert_eq!( + NodeConfigV1::parse_json(&vec![b' '; 64 * 1024 + 1]), + Err(BootstrapError::ResourceLimitExceeded) + ); + unknown.as_object_mut().unwrap().remove("surprise"); + unknown["schema_version"] = serde_json::json!(2); + assert_eq!( + NodeConfigV1::parse_json(&serde_json::to_vec(&unknown).unwrap()), + Err(BootstrapError::UnsupportedConfigFormat) + ); +} + +#[test] +fn config_accepts_exact_ipv6_private_overlay_and_rejects_relative_xdg() { + let bind: IpAddr = "fd00::10".parse().unwrap(); + let config = NodeConfigV1 { + network: NetworkBoundary { + kind: OverlayKind::WireGuard, + bind_ip: bind, + allowed_cidrs: vec!["fd00::/8".parse().unwrap()], + }, + directory_seeds: vec![Url::parse("https://[fd00::20]:7443/").unwrap()], + authority_endpoint: Url::parse("https://[fd00::30]:7443/").unwrap(), + revocation_endpoint: Url::parse("https://[fd00::30]:7443/").unwrap(), + ..private_config() + }; + assert_eq!(config.validate(), Ok(())); + assert_eq!( + NodePaths::resolve( + UserPlatform::Linux, + &NodePathEnvironment::new( + PathBuf::from("/home/alice"), + Some(PathBuf::from("relative")), + None, + ), + ), + Err(BootstrapError::InvalidStatePath) + ); +} + +#[test] +fn config_rejects_unbounded_or_ambiguous_peer_endpoints() { + let mut config = private_config(); + config + .directory_seeds + .push(config.directory_seeds[0].clone()); + assert_eq!(config.validate(), Err(BootstrapError::InvalidConfig)); + + for endpoint in [ + "http://100.64.0.2:7443/", + "https://user@100.64.0.2:7443/", + "https://100.64.0.2:7443/path", + "https://100.64.0.2:7443/?token=x", + "https://directory.internal:7443/", + ] { + let mut changed = private_config(); + changed.directory_seeds = vec![Url::parse(endpoint).unwrap()]; + assert_eq!( + changed.validate(), + Err(BootstrapError::InvalidConfig), + "{endpoint}" + ); + } + + let loopback = NodeConfigV1 { + network: NetworkBoundary::loopback_ipv4(), + directory_seeds: vec![Url::parse("http://127.0.0.1:7443/").unwrap()], + authority_endpoint: Url::parse("http://127.0.0.1:7444/").unwrap(), + revocation_endpoint: Url::parse("http://127.0.0.1:7444/").unwrap(), + ..private_config() + }; + assert_eq!(loopback.validate(), Ok(())); +} + +#[test] +fn path_resolution_is_deterministic_for_macos_and_linux() { + let mac = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(PathBuf::from("/Users/alice"), None, None), + ) + .unwrap(); + assert_eq!( + mac.config_dir, + PathBuf::from("/Users/alice/Library/Application Support/AgenNet") + ); + assert_eq!(mac.state_dir, mac.config_dir); + assert_eq!( + mac.service_definition, + PathBuf::from("/Users/alice/Library/LaunchAgents/org.nexa-language.agenet.plist") + ); + + let linux = NodePaths::resolve( + UserPlatform::Linux, + &NodePathEnvironment::new( + PathBuf::from("/home/alice"), + Some(PathBuf::from("/cfg")), + Some(PathBuf::from("/state")), + ), + ) + .unwrap(); + assert_eq!(linux.config_dir, PathBuf::from("/cfg/agenet")); + assert_eq!(linux.state_dir, PathBuf::from("/state/agenet")); + assert_eq!( + linux.service_definition, + PathBuf::from("/home/alice/.config/systemd/user/agenet.service") + ); + assert_eq!( + linux.config_file.file_name().unwrap(), + "node-config-v1.json" + ); + assert_ne!(linux.credential_file, linux.signing_private_key_file); + assert_ne!(linux.signing_private_key_file, linux.tls_private_key_file); + assert_ne!(linux.revocation_file, linux.journal_file); +} + +#[test] +fn path_resolution_and_layout_reject_unsafe_roots_and_symlinks() { + assert_eq!( + NodePaths::resolve( + UserPlatform::Linux, + &NodePathEnvironment::new(PathBuf::from("relative"), None, None), + ), + Err(BootstrapError::InvalidStatePath) + ); + + let temp = TempDir::new().unwrap(); + let real = temp.path().join("real"); + std::fs::create_dir(&real).unwrap(); + let linked = temp.path().join("linked"); + std::os::unix::fs::symlink(&real, &linked).unwrap(); + let paths = NodePaths::resolve( + UserPlatform::Linux, + &NodePathEnvironment::new(linked, None, None), + ) + .unwrap(); + assert_eq!( + paths.ensure_secure_layout(), + Err(BootstrapError::UnsafeStatePath) + ); +} + +#[test] +fn config_store_rejects_wrong_mode_and_final_symlink() { + use std::os::unix::fs::PermissionsExt; + + let temp = TempDir::new().unwrap(); + let paths = NodePaths::resolve( + UserPlatform::Linux, + &NodePathEnvironment::new(temp.path().canonicalize().unwrap(), None, None), + ) + .unwrap(); + paths.ensure_secure_layout().unwrap(); + paths.write_config(&private_config()).unwrap(); + assert_eq!(paths.read_config().unwrap(), private_config()); + + std::fs::set_permissions(&paths.config_file, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(paths.read_config(), Err(BootstrapError::UnsafeStatePath)); + std::fs::remove_file(&paths.config_file).unwrap(); + std::os::unix::fs::symlink(&paths.journal_file, &paths.config_file).unwrap(); + assert_eq!(paths.read_config(), Err(BootstrapError::UnsafeStatePath)); + + std::fs::remove_file(&paths.config_file).unwrap(); + let fifo_c = std::ffi::CString::new(paths.config_file.as_os_str().as_encoded_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(fifo_c.as_ptr(), 0o600) }, 0); + assert_eq!(paths.read_config(), Err(BootstrapError::UnsafeStatePath)); +} + +#[test] +fn startup_loader_verifies_credential_role_time_domain_and_tls_identity() { + let now = 1_800_000_000_000_i64; + let temp = TempDir::new().unwrap(); + let paths = NodePaths::resolve( + UserPlatform::Linux, + &NodePathEnvironment::new(temp.path().canonicalize().unwrap().join("home"), None, None), + ) + .unwrap(); + let config = NodeConfigV1 { + format: "agenet.node-config".to_owned(), + schema_version: 1, + domain_id: common::domain_id(), + profile: BootstrapProfile::Base, + network: NetworkBoundary::loopback_ipv4(), + directory_seeds: vec![Url::parse("https://127.0.0.1:7443/").unwrap()], + authority_endpoint: Url::parse("https://127.0.0.1:7444/").unwrap(), + revocation_endpoint: Url::parse("https://127.0.0.1:7444/").unwrap(), + }; + paths.write_config(&config).unwrap(); + let root = SigningKey::from_bytes(&[41; 32]); + let node = SigningKey::from_bytes(&[42; 32]); + let chain = common::credential_chain( + &root, + &node, + "node:startup", + NodeRole::Requester, + now as u64, + ); + let ca = AuthorityPki::generate(now - 10_000, now + 120_000).unwrap(); + let csr = NodeTlsCsr::generate().unwrap(); + let certificate = ca + .issue_peer( + &csr.csr_pem, + &NodeId::new("node:startup").unwrap(), + "127.0.0.1".parse().unwrap(), + now - 1_000, + now + 30_000, + ) + .unwrap(); + let identity = PeerTlsIdentity { + node_id: NodeId::new("node:startup").unwrap(), + certificate_chain_pem: Zeroizing::new(certificate.cert_pem), + private_key_pem: csr.private_key_pem, + authority_ca_pem: ca.ca_cert_pem.to_string(), + }; + paths + .write_startup_material(&chain, &node, &identity) + .unwrap(); + + let loaded = + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now).unwrap(); + assert_eq!(loaded.tls_identity.node_id.as_str(), "node:startup"); + assert!(!format!("{loaded:?}").contains("BEGIN PRIVATE KEY")); + let wrong_root = SigningKey::from_bytes(&[43; 32]); + assert!(matches!( + load_startup_bundle( + &paths, + &wrong_root.verifying_key(), + NodeRole::Requester, + now + ), + Err(BootstrapError::InvalidConfig) + )); + let mut wrong_domain = config.clone(); + wrong_domain.domain_id = DomainId::new("domain:other").unwrap(); + paths.write_config(&wrong_domain).unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::InvalidConfig) + )); + let mut wrong_profile = config.clone(); + wrong_profile.profile = BootstrapProfile::Provider; + paths.write_config(&wrong_profile).unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::InvalidConfig) + )); + paths.write_config(&config).unwrap(); + let wrong_signing = SigningKey::from_bytes(&[99; 32]); + paths + .write_startup_material(&chain, &wrong_signing, &identity) + .unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::InvalidConfig) + )); + paths + .write_startup_material(&chain, &node, &identity) + .unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Executor, now), + Err(BootstrapError::InvalidConfig) + )); + assert!(matches!( + load_startup_bundle( + &paths, + &root.verifying_key(), + NodeRole::Requester, + now + 61_000 + ), + Err(BootstrapError::InvalidConfig) + )); + assert!(matches!( + load_startup_bundle( + &paths, + &root.verifying_key(), + NodeRole::Requester, + now + 30_000 + ), + Err(BootstrapError::InvalidPki) + )); + + let mismatch_csr = NodeTlsCsr::generate().unwrap(); + let key_mismatch_identity = PeerTlsIdentity { + node_id: NodeId::new("node:startup").unwrap(), + certificate_chain_pem: identity.certificate_chain_pem.clone(), + private_key_pem: mismatch_csr.private_key_pem, + authority_ca_pem: ca.ca_cert_pem.to_string(), + }; + paths + .write_startup_material(&chain, &node, &key_mismatch_identity) + .unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::InvalidPki) + )); + + let ip_csr = NodeTlsCsr::generate().unwrap(); + let ip_certificate = ca + .issue_peer( + &ip_csr.csr_pem, + &NodeId::new("node:startup").unwrap(), + "127.0.0.2".parse().unwrap(), + now - 1_000, + now + 30_000, + ) + .unwrap(); + let ip_mismatch_identity = PeerTlsIdentity { + node_id: NodeId::new("node:startup").unwrap(), + certificate_chain_pem: Zeroizing::new(ip_certificate.cert_pem), + private_key_pem: ip_csr.private_key_pem, + authority_ca_pem: ca.ca_cert_pem.to_string(), + }; + paths + .write_startup_material(&chain, &node, &ip_mismatch_identity) + .unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::InvalidPki) + )); + + paths + .write_startup_material(&chain, &node, &identity) + .unwrap(); + std::fs::write(&paths.tls_private_key_file, [0xff, 0xfe]).unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::InvalidPki) + )); + paths + .write_startup_material(&chain, &node, &identity) + .unwrap(); + + let wrong_csr = NodeTlsCsr::generate().unwrap(); + let wrong_certificate = ca + .issue_peer( + &wrong_csr.csr_pem, + &NodeId::new("node:other").unwrap(), + "127.0.0.1".parse().unwrap(), + now - 1_000, + now + 60_000, + ) + .unwrap(); + let wrong_identity = PeerTlsIdentity { + node_id: NodeId::new("node:other").unwrap(), + certificate_chain_pem: Zeroizing::new(wrong_certificate.cert_pem), + private_key_pem: wrong_csr.private_key_pem, + authority_ca_pem: ca.ca_cert_pem.to_string(), + }; + paths + .write_startup_material(&chain, &node, &wrong_identity) + .unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::InvalidPki) + )); + + let other_ca = AuthorityPki::generate(now - 10_000, now + 120_000).unwrap(); + let mismatched_ca_identity = PeerTlsIdentity { + authority_ca_pem: other_ca.ca_cert_pem.to_string(), + ..identity + }; + paths + .write_startup_material(&chain, &node, &mismatched_ca_identity) + .unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::InvalidPki) + )); +} diff --git a/tests/bootstrap_recovery.rs b/tests/bootstrap_recovery.rs new file mode 100644 index 0000000..68d1e7e --- /dev/null +++ b/tests/bootstrap_recovery.rs @@ -0,0 +1,240 @@ +use std::os::unix::fs::PermissionsExt; +use std::{ + io::{BufRead, BufReader, Write}, + process::{Command, Stdio}, +}; + +use agenet::bootstrap::{ + BootstrapError, BootstrapPhase, BootstrapStateStore, BootstrapTransition, TransitionOutcome, +}; +use tempfile::TempDir; + +#[test] +fn journal_replays_exact_committed_phase_and_idempotency() { + let temp = TempDir::new().unwrap(); + let journal = temp + .path() + .canonicalize() + .unwrap() + .join("state/bootstrap-state-v1.jsonl"); + let mut store = BootstrapStateStore::open(&journal).unwrap(); + assert_eq!(store.phase(), BootstrapPhase::Absent); + assert_eq!( + store + .apply( + "install-1", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled) + ) + .unwrap(), + TransitionOutcome::Applied(BootstrapPhase::BinaryInstalled) + ); + assert_eq!( + store + .apply( + "install-1", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled) + ) + .unwrap(), + TransitionOutcome::AlreadyApplied(BootstrapPhase::BinaryInstalled) + ); + assert_eq!( + store.apply( + "install-1", + BootstrapTransition::Advance(BootstrapPhase::ServicePrepared) + ), + Err(BootstrapError::OperationConflict) + ); + store + .apply( + "service-1", + BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), + ) + .unwrap(); + drop(store); + assert_eq!( + BootstrapStateStore::open(&journal).unwrap().phase(), + BootstrapPhase::ServicePrepared + ); +} + +#[test] +fn state_machine_retains_credentials_and_allows_only_explicit_compensation() { + let temp = TempDir::new().unwrap(); + let journal = temp.path().canonicalize().unwrap().join("state-a/journal"); + let mut store = BootstrapStateStore::open(&journal).unwrap(); + for (operation, phase) in [ + ("binary", BootstrapPhase::BinaryInstalled), + ("service", BootstrapPhase::ServicePrepared), + ("ready", BootstrapPhase::ReadyForEnrollment), + ("credential", BootstrapPhase::CredentialIssued), + ] { + store + .apply(operation, BootstrapTransition::Advance(phase)) + .unwrap(); + } + assert_eq!( + store.apply("unsafe-back", BootstrapTransition::RollbackService), + Err(BootstrapError::InvalidBootstrapTransition) + ); + drop(store); + let mut resumed = BootstrapStateStore::open(&journal).unwrap(); + assert_eq!(resumed.phase(), BootstrapPhase::CredentialIssued); + resumed + .apply( + "registered", + BootstrapTransition::Advance(BootstrapPhase::Registered), + ) + .unwrap(); + resumed + .apply( + "healthy", + BootstrapTransition::Advance(BootstrapPhase::Healthy), + ) + .unwrap(); + assert_eq!( + resumed + .apply("leave-healthy", BootstrapTransition::Leave) + .unwrap(), + TransitionOutcome::Applied(BootstrapPhase::Left) + ); + + let second = temp + .path() + .canonicalize() + .unwrap() + .join("state-b/service-journal"); + let mut service = BootstrapStateStore::open(&second).unwrap(); + service + .apply( + "binary", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled), + ) + .unwrap(); + service + .apply( + "service", + BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), + ) + .unwrap(); + assert_eq!( + service + .apply("rollback", BootstrapTransition::RollbackService) + .unwrap(), + TransitionOutcome::Applied(BootstrapPhase::BinaryInstalled) + ); + assert_eq!( + service.apply("leave", BootstrapTransition::Leave).unwrap(), + TransitionOutcome::Applied(BootstrapPhase::Left) + ); +} + +#[test] +fn journal_fails_closed_on_torn_future_corrupt_or_oversized_input() { + let temp = TempDir::new().unwrap(); + let state = temp.path().canonicalize().unwrap().join("state"); + std::fs::create_dir(&state).unwrap(); + std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o700)).unwrap(); + for (name, bytes) in [ + ( + "torn", + b"{\"format\":\"agenet.bootstrap-journal\"".as_slice(), + ), + ( + "future", + b"{\"format\":\"agenet.bootstrap-journal\",\"schema_version\":2}\n".as_slice(), + ), + ("corrupt", b"not-json\n".as_slice()), + ] { + let path = state.join(name); + std::fs::write(&path, bytes).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(matches!( + BootstrapStateStore::open(&path), + Err(BootstrapError::InvalidBootstrapJournal) + )); + } + let path = state.join("oversized"); + std::fs::write(&path, vec![b'x'; 300_000]).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(matches!( + BootstrapStateStore::open(&path), + Err(BootstrapError::ResourceLimitExceeded) + )); +} + +#[test] +fn journal_hash_chain_rejects_a_stable_bit_flip() { + let temp = TempDir::new().unwrap(); + let journal = temp.path().canonicalize().unwrap().join("state/journal"); + let mut store = BootstrapStateStore::open(&journal).unwrap(); + store + .apply( + "binary", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled), + ) + .unwrap(); + drop(store); + let mut bytes = std::fs::read(&journal).unwrap(); + let marker = b"\"checksum\":\""; + let offset = bytes + .windows(marker.len()) + .position(|window| window == marker) + .unwrap() + + marker.len(); + bytes[offset] = if bytes[offset] == b'a' { b'b' } else { b'a' }; + std::fs::write(&journal, bytes).unwrap(); + std::fs::set_permissions(&journal, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(matches!( + BootstrapStateStore::open(&journal), + Err(BootstrapError::InvalidBootstrapJournal) + )); +} + +#[test] +fn a_real_child_process_cannot_take_the_state_lock() { + let temp = TempDir::new().unwrap(); + let journal = temp.path().canonicalize().unwrap().join("state/journal"); + let mut child = Command::new(std::env::current_exe().unwrap()) + .arg("--ignored") + .arg("--exact") + .arg("bootstrap_lock_child") + .arg("--nocapture") + .env("AGENET_TEST_LOCK_JOURNAL", &journal) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut output = BufReader::new(child.stdout.take().unwrap()); + let mut marker = String::new(); + loop { + marker.clear(); + assert_ne!( + output.read_line(&mut marker).unwrap(), + 0, + "child exited before lock marker" + ); + if marker.trim() == "LOCKED" { + break; + } + } + assert!(matches!( + BootstrapStateStore::open(&journal), + Err(BootstrapError::StateLocked) + )); + child.stdin.take().unwrap().write_all(b"x").unwrap(); + assert!(child.wait().unwrap().success()); + assert!(BootstrapStateStore::open(&journal).is_ok()); +} + +#[test] +#[ignore] +fn bootstrap_lock_child() { + let Some(path) = std::env::var_os("AGENET_TEST_LOCK_JOURNAL") else { + return; + }; + let _store = BootstrapStateStore::open(std::path::Path::new(&path)).unwrap(); + println!("LOCKED"); + std::io::stdout().flush().unwrap(); + let mut byte = [0_u8; 1]; + std::io::Read::read_exact(&mut std::io::stdin(), &mut byte).unwrap(); +} From 0196b5facad7d6e187e43af1fb8e62905de23ebc Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 00:15:19 +0800 Subject: [PATCH 28/67] [bug] Fix bootstrap persistence races Root cause: Atomic publication re-resolved a validated parent path, while runtime journal bounds and replay validation diverged. Solution: Pin publication and journal I/O to verified descriptors and enforce symmetric validation and append limits. Risks: Managed Task 9 parent directories require exact mode 0700. Dependency: Bootstrap step 9. Links: plan/01-v1-multi-host-node-bootstrap.md Post-mortem: Validate and mutate through one pinned filesystem object. --- README.md | 12 ++ ROADMAP.md | 11 + src/bootstrap/paths.rs | 10 +- src/bootstrap/state.rs | 261 +++++++++++++++++++----- src/runtime/key_store.rs | 391 +++++++++++++++++++++++++----------- tests/bootstrap_config.rs | 10 + tests/bootstrap_recovery.rs | 44 ++++ 7 files changed, 562 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index 26a1674..22b36ca 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,14 @@ Root/domain/role/profile/time, signing-key, TLS chain/key, NodeId, exact IP SAN, and certificate-time validation succeeds. Building the live service from this validated bundle remains Task 12. +The credential, signing key, certificate, TLS key, and CA are intentionally +separate files, not a claimed multi-file transaction. A writer must hold the +bootstrap state lock, publish and validate the complete set, and only then +append `CredentialIssued`. A crash may leave a partial set, but restart loads +every file and fails closed instead of deleting credentials or guessing which +file won. Task 10 owns command-level retry/reconciliation of that incomplete +set; Task 9 provides only the validated persisted-bundle boundary. + The local journal records this forward path: ```text @@ -67,6 +75,10 @@ and bounds violations fail closed. One owner-only nonblocking process lock is held for the store lifetime. An uncertain durable append poisons further mutation until restart; atomic replacement failures after publish are likewise reported as uncertain so restart can reconcile the visible final file. +The journal is provisional newline-framed JSONL, not binary framing. Its lock, +create/open, replay, header sync, and later appends remain anchored to one +verified owner-only directory descriptor and one journal descriptor, so a +pathname replacement cannot redirect publication between validation and sync. ## MVP boundary diff --git a/ROADMAP.md b/ROADMAP.md index 06ade09..2febb33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,16 @@ # ROADMAP +## 2026-08-15 01:10 CST + +- **Change**: Closed Task 9 review-round-one persistence races without changing the provisional schema or entering Task 10. +- **Files**: `src/runtime/key_store.rs`, `src/bootstrap/paths.rs`, `src/bootstrap/state.rs`, adjacent compatibility fixtures, focused bootstrap tests, `README.md`, this roadmap, and ignored Task 9 report/evidence. +- **Root cause**: Security-hardening implementation gap — atomic publication validated a parent pathname but then re-resolved it for temporary creation, publication, and directory sync. The bootstrap journal separately replayed one path-opened file and appended through a later open. Replay also omitted the live `operation_id` validator, and live append omitted replay's total-record/total-byte limits. +- **Solution**: Pin publication to a verified parent directory descriptor and perform temporary creation, rename/link, cleanup, and parent sync with fd-relative syscalls. Bootstrap lock creation, journal create/open, header sync, replay, and append now share one verified `0700` directory fd and one owner-only journal fd. Replay validates operation IDs identically; append preflights exact next record count and serialized journal length before any write or memory transition. +- **Compatibility decision**: Task 9 managed config/credential/TLS paths use the exact-`0700`, reject-all-symlink-components writer. The established public signing-key/keystore writer retains its owned, non-group/world-writable parent contract (including macOS `/var` platform aliases) while still pinning the opened parent inode and using fd-relative publication. This avoids silently broadening Task 9 trust while preserving reviewed legacy consumers. +- **Recovery decision**: Resource-limit rejection is certain: it appends no bytes, advances no in-memory phase, and does not poison the store; restart replays the prior state. Write/flush/sync uncertainty still poisons until restart. A deterministic directory-replacement seam proves lock, header, and append stay in the original directory and leave the attacker target unchanged. +- **Multi-file boundary**: Startup credential/TLS files are separate atomic publications, not one transaction. A missing partial file makes `load_startup_bundle` fail closed; callers must publish and validate the full set before `CredentialIssued`. Task 10 owns command-level retry/reconciliation and must not delete credentials based on an incomplete set. +- **Post-mortem**: Filesystem validation and mutation must consume one pinned object; live admission and replay must share validators and bounds. Future persistence changes require deterministic replacement/fault seams plus tests that compare pre-failure memory, bytes on disk, and restart projection. + ## 2026-08-14 23:24 CST - **Change**: Added Task 9's provisional versioned node configuration, deterministic macOS/Linux user paths, owner-only startup material loader, cross-process state-root lock, and crash-recoverable bootstrap phase journal. diff --git a/src/bootstrap/paths.rs b/src/bootstrap/paths.rs index 77ae7c8..42c7cc4 100644 --- a/src/bootstrap/paths.rs +++ b/src/bootstrap/paths.rs @@ -1,7 +1,8 @@ use std::path::{Component, Path, PathBuf}; use crate::runtime::key_store::{ - atomic_write_owner_only, ensure_owner_only_dir, read_owner_only, write_signing_key, + atomic_write_owner_only_strict, ensure_owner_only_dir, read_owner_only, + write_signing_key_strict, }; use crate::{protocol::CredentialChain, transport::PeerTlsIdentity}; use ed25519_dalek::SigningKey; @@ -136,7 +137,7 @@ impl NodePaths { self.ensure_secure_layout()?; let mut bytes = serde_json::to_vec(config).map_err(|_| BootstrapError::InvalidConfig)?; bytes.push(b'\n'); - atomic_write_owner_only(&self.config_file, &bytes, true) + atomic_write_owner_only_strict(&self.config_file, &bytes, true) .map_err(|_| BootstrapError::PersistenceUnavailable) } @@ -157,7 +158,7 @@ impl NodePaths { serde_json::to_vec(credential).map_err(|_| BootstrapError::InvalidConfig)?; credential_bytes.push(b'\n'); write_material(&self.credential_file, &credential_bytes)?; - write_signing_key(&self.signing_private_key_file, signing_key) + write_signing_key_strict(&self.signing_private_key_file, signing_key) .map_err(|_| BootstrapError::PersistenceUnavailable)?; write_material( &self.tls_certificate_file, @@ -185,7 +186,8 @@ impl NodePaths { } fn write_material(path: &Path, bytes: &[u8]) -> Result<(), BootstrapError> { - atomic_write_owner_only(path, bytes, true).map_err(|_| BootstrapError::PersistenceUnavailable) + atomic_write_owner_only_strict(path, bytes, true) + .map_err(|_| BootstrapError::PersistenceUnavailable) } fn validate_root(path: &Path) -> Result<(), BootstrapError> { diff --git a/src/bootstrap/state.rs b/src/bootstrap/state.rs index cebd525..c1a32eb 100644 --- a/src/bootstrap/state.rs +++ b/src/bootstrap/state.rs @@ -1,17 +1,16 @@ use std::{ collections::BTreeMap, fs::File, - io::{Seek, SeekFrom, Write}, - os::unix::fs::MetadataExt, - path::{Path, PathBuf}, + io::{Read, Seek, SeekFrom, Write}, + path::Path, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::runtime::key_store::{ - OwnerOnlyLockError, ensure_owner_only_dir, open_owner_only_append, open_owner_only_lock, - read_owner_only, + OwnerOnlyLockError, ensure_owner_only_dir, open_owner_only_append_at, open_owner_only_lock_at, + open_verified_owner_directory, }; use super::BootstrapError; @@ -88,6 +87,7 @@ pub struct BootstrapStateStore { sequence: u64, previous_hash: String, operations: BTreeMap, + journal_bytes: usize, poisoned: bool, sync_record: fn(&File) -> std::io::Result<()>, } @@ -101,6 +101,17 @@ impl BootstrapStateStore { journal_path: &Path, sync_record: fn(&File) -> std::io::Result<()>, ) -> Result { + Self::open_with_sync_and_hook(journal_path, sync_record, || {}) + } + + fn open_with_sync_and_hook( + journal_path: &Path, + sync_record: fn(&File) -> std::io::Result<()>, + after_parent_open: AfterParentOpen, + ) -> Result + where + AfterParentOpen: FnOnce(), + { if !journal_path.is_absolute() { return Err(BootstrapError::InvalidStatePath); } @@ -109,41 +120,40 @@ impl BootstrapStateStore { .filter(|path| !path.as_os_str().is_empty()) .unwrap_or(Path::new(".")); ensure_owner_only_dir(parent).map_err(|_| BootstrapError::UnsafeStatePath)?; - let lock_path = lock_path(journal_path)?; - let lock = open_owner_only_lock(&lock_path).map_err(|error| match error { - OwnerOnlyLockError::Locked => BootstrapError::StateLocked, - OwnerOnlyLockError::Unsafe => BootstrapError::UnsafeStatePath, - })?; - let existing_identity = match std::fs::symlink_metadata(journal_path) { - Ok(metadata) => Some((metadata.dev(), metadata.ino())), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - Err(_) => return Err(BootstrapError::UnsafeStatePath), - }; - let exists = existing_identity.is_some(); - let (phase, sequence, previous_hash, operations) = if exists { - replay(journal_path)? - } else { + let parent = open_verified_owner_directory(parent, true) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + after_parent_open(); + let lock = + open_owner_only_lock_at(&parent, std::ffi::OsStr::new("bootstrap-state-v1.lock")) + .map_err(|error| match error { + OwnerOnlyLockError::Locked => BootstrapError::StateLocked, + OwnerOnlyLockError::Unsafe => BootstrapError::UnsafeStatePath, + })?; + let journal_name = journal_path + .file_name() + .ok_or(BootstrapError::InvalidStatePath)?; + let (mut file, created) = open_owner_only_append_at(&parent, journal_name) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + let (phase, sequence, previous_hash, operations) = if created { + write_header(&mut file)?; + parent + .sync_all() + .map_err(|_| BootstrapError::PersistenceUnavailable)?; ( BootstrapPhase::Absent, 0, ZERO_HASH.to_owned(), BTreeMap::new(), ) + } else { + replay(&mut file)? }; - let mut file = open_owner_only_append(journal_path, !exists) - .map_err(|_| BootstrapError::UnsafeStatePath)?; - if let Some((expected_dev, expected_ino)) = existing_identity { - let opened = file - .metadata() - .map_err(|_| BootstrapError::UnsafeStatePath)?; - if opened.dev() != expected_dev || opened.ino() != expected_ino { - return Err(BootstrapError::UnsafeStatePath); - } - } - if !exists { - write_header(&mut file)?; - sync_parent(journal_path)?; - } + let journal_bytes = usize::try_from( + file.metadata() + .map_err(|_| BootstrapError::UnsafeStatePath)? + .len(), + ) + .map_err(|_| BootstrapError::ResourceLimitExceeded)?; Ok(Self { _lock: lock, file, @@ -151,6 +161,7 @@ impl BootstrapStateStore { sequence, previous_hash, operations, + journal_bytes, poisoned: false, sync_record, }) @@ -199,9 +210,15 @@ impl BootstrapStateStore { to: next, checksum: checksum.clone(), }; - if self.append_record(&record).is_err() { - self.poisoned = true; - return Err(BootstrapError::PersistenceUnavailable); + match self.append_record(&record) { + Ok(()) => {} + Err(BootstrapError::ResourceLimitExceeded) => { + return Err(BootstrapError::ResourceLimitExceeded); + } + Err(_) => { + self.poisoned = true; + return Err(BootstrapError::PersistenceUnavailable); + } } self.phase = next; self.sequence = sequence; @@ -215,7 +232,14 @@ impl BootstrapStateStore { let mut bytes = serde_json::to_vec(record).map_err(|_| BootstrapError::InvalidBootstrapJournal)?; bytes.push(b'\n'); - if bytes.len() > 8192 { + let next_length = self + .journal_bytes + .checked_add(bytes.len()) + .ok_or(BootstrapError::ResourceLimitExceeded)?; + if self.operations.len() >= MAX_RECORDS + || bytes.len() > 8192 + || next_length > MAX_JOURNAL_BYTES + { return Err(BootstrapError::ResourceLimitExceeded); } self.file @@ -227,7 +251,9 @@ impl BootstrapStateStore { self.file .flush() .map_err(|_| BootstrapError::PersistenceUnavailable)?; - (self.sync_record)(&self.file).map_err(|_| BootstrapError::PersistenceUnavailable) + (self.sync_record)(&self.file).map_err(|_| BootstrapError::PersistenceUnavailable)?; + self.journal_bytes = next_length; + Ok(()) } } @@ -254,13 +280,23 @@ type Replay = ( BTreeMap, ); -fn replay(path: &Path) -> Result { - let metadata = std::fs::symlink_metadata(path).map_err(|_| BootstrapError::UnsafeStatePath)?; +fn replay(file: &mut File) -> Result { + let metadata = file + .metadata() + .map_err(|_| BootstrapError::UnsafeStatePath)?; if metadata.len() > MAX_JOURNAL_BYTES as u64 { return Err(BootstrapError::ResourceLimitExceeded); } - let bytes = - read_owner_only(path, MAX_JOURNAL_BYTES).map_err(|_| BootstrapError::UnsafeStatePath)?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + let mut bytes = Vec::new(); + Read::by_ref(file) + .take((MAX_JOURNAL_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + if bytes.len() > MAX_JOURNAL_BYTES { + return Err(BootstrapError::ResourceLimitExceeded); + } if bytes.is_empty() || !bytes.ends_with(b"\n") { return Err(BootstrapError::InvalidBootstrapJournal); } @@ -283,6 +319,8 @@ fn replay(path: &Path) -> Result { } let record: JournalRecord = serde_json::from_slice(line).map_err(|_| BootstrapError::InvalidBootstrapJournal)?; + validate_operation_id(&record.operation_id) + .map_err(|_| BootstrapError::InvalidBootstrapJournal)?; let expected_to = next_phase(phase, &record.transition)?; let expected_hash = record_hash( record.sequence, @@ -310,12 +348,6 @@ fn replay(path: &Path) -> Result { Ok((phase, sequence, previous_hash, operations)) } -fn sync_parent(path: &Path) -> Result<(), BootstrapError> { - File::open(path.parent().unwrap_or(Path::new("."))) - .and_then(|directory| directory.sync_all()) - .map_err(|_| BootstrapError::PersistenceUnavailable) -} - fn next_phase( current: BootstrapPhase, transition: &BootstrapTransition, @@ -400,11 +432,6 @@ fn header_hash() -> String { )) } -fn lock_path(journal: &Path) -> Result { - let parent = journal.parent().ok_or(BootstrapError::InvalidStatePath)?; - Ok(parent.join("bootstrap-state-v1.lock")) -} - fn hex_digest(bytes: impl AsRef<[u8]>) -> String { let digest = Sha256::digest(bytes.as_ref()); let mut encoded = String::with_capacity(64); @@ -418,6 +445,8 @@ fn hex_digest(bytes: impl AsRef<[u8]>) -> String { #[cfg(test)] mod tests { use super::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; fn fail_sync(_: &File) -> std::io::Result<()> { Err(std::io::Error::other("injected post-write sync failure")) @@ -450,4 +479,130 @@ mod tests { BootstrapPhase::BinaryInstalled ); } + + #[test] + fn replay_rejects_validly_checksummed_invalid_operation_ids() { + for (index, operation_id) in ["", "contains space", &"x".repeat(129)] + .into_iter() + .enumerate() + { + let temp = tempfile::tempdir().unwrap(); + let root = temp + .path() + .canonicalize() + .unwrap() + .join(format!("state-{index}")); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let journal = root.join("journal"); + let transition = BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled); + let checksum = record_hash( + 1, + ZERO_HASH, + operation_id, + &transition, + BootstrapPhase::Absent, + BootstrapPhase::BinaryInstalled, + ) + .unwrap(); + let header = JournalHeader { + format: JOURNAL_FORMAT.to_owned(), + schema_version: JOURNAL_SCHEMA, + checksum: header_hash(), + }; + let record = JournalRecord { + schema_version: JOURNAL_SCHEMA, + sequence: 1, + previous_hash: ZERO_HASH.to_owned(), + operation_id: operation_id.to_owned(), + transition, + from: BootstrapPhase::Absent, + to: BootstrapPhase::BinaryInstalled, + checksum, + }; + let bytes = format!( + "{}\n{}\n", + serde_json::to_string(&header).unwrap(), + serde_json::to_string(&record).unwrap() + ); + fs::write(&journal, bytes).unwrap(); + fs::set_permissions(&journal, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(matches!( + BootstrapStateStore::open(&journal), + Err(BootstrapError::InvalidBootstrapJournal) + )); + } + } + + #[test] + fn record_count_limit_rejects_before_append_without_poisoning_store() { + let temp = tempfile::tempdir().unwrap(); + let journal = temp.path().canonicalize().unwrap().join("state/journal"); + let mut store = BootstrapStateStore::open(&journal).unwrap(); + for index in 0..MAX_RECORDS { + store.operations.insert( + format!("existing-{index}"), + ( + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled), + BootstrapPhase::BinaryInstalled, + ), + ); + } + assert_eq!( + store.apply( + "install", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled) + ), + Err(BootstrapError::ResourceLimitExceeded) + ); + assert_eq!(store.phase(), BootstrapPhase::Absent); + assert_eq!( + store.apply( + "install-retry", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled) + ), + Err(BootstrapError::ResourceLimitExceeded) + ); + drop(store); + assert_eq!( + BootstrapStateStore::open(&journal).unwrap().phase(), + BootstrapPhase::Absent + ); + } + + #[test] + fn parent_replacement_cannot_redirect_lock_header_or_journal_append() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let managed = root.join("managed"); + let detached = root.join("detached"); + let attacker = root.join("attacker"); + fs::create_dir(&managed).unwrap(); + fs::create_dir(&attacker).unwrap(); + fs::set_permissions(&managed, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&attacker, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(attacker.join("journal"), b"attacker-sentinel").unwrap(); + + let journal = managed.join("journal"); + let mut store = + BootstrapStateStore::open_with_sync_and_hook(&journal, File::sync_data, || { + fs::rename(&managed, &detached).unwrap(); + std::os::unix::fs::symlink(&attacker, &managed).unwrap(); + }) + .unwrap(); + store + .apply( + "install", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled), + ) + .unwrap(); + drop(store); + + assert_eq!( + fs::read(attacker.join("journal")).unwrap(), + b"attacker-sentinel" + ); + assert!(detached.join("bootstrap-state-v1.lock").is_file()); + assert!(detached.join("journal").is_file()); + } } diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index 42be1b4..d146144 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -1,8 +1,8 @@ use std::{ - fs::{self, DirBuilder, File, OpenOptions}, + fs::{self, DirBuilder, File}, io::{Read, Write}, os::unix::{ - fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}, + fs::{DirBuilderExt, MetadataExt}, io::{AsRawFd, FromRawFd}, }, path::{Path, PathBuf}, @@ -15,10 +15,22 @@ use zeroize::Zeroizing; use super::RuntimeError; pub fn write_signing_key(path: &Path, key: &SigningKey) -> Result<(), RuntimeError> { + write_signing_key_with_policy(path, key, false) +} + +pub(crate) fn write_signing_key_strict(path: &Path, key: &SigningKey) -> Result<(), RuntimeError> { + write_signing_key_with_policy(path, key, true) +} + +fn write_signing_key_with_policy( + path: &Path, + key: &SigningKey, + strict_parent: bool, +) -> Result<(), RuntimeError> { let key_bytes = Zeroizing::new(key.to_bytes()); let mut encoded = Zeroizing::new(STANDARD.encode(&key_bytes[..])); encoded.push('\n'); - atomic_write_owner_only(path, encoded.as_bytes(), true) + atomic_write_owner_only_with_policy(path, encoded.as_bytes(), true, strict_parent) } pub fn read_signing_key(path: &Path) -> Result { @@ -122,83 +134,6 @@ fn read_owner_only_impl( Ok(bytes) } -pub(crate) fn open_owner_only_lock(path: &Path) -> Result { - reject_symlink_components(path).map_err(|_| OwnerOnlyLockError::Unsafe)?; - let (file, before, created) = open_lock_file(path)?; - let after = file.metadata().map_err(|_| OwnerOnlyLockError::Unsafe)?; - if !safe_regular(&after) - || before - .as_ref() - .is_some_and(|metadata| metadata.dev() != after.dev() || metadata.ino() != after.ino()) - { - return Err(OwnerOnlyLockError::Unsafe); - } - let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; - if result != 0 { - return Err(OwnerOnlyLockError::Locked); - } - if created { - file.sync_data().map_err(|_| OwnerOnlyLockError::Unsafe)?; - sync_directory(path.parent().unwrap_or(Path::new("."))) - .map_err(|_| OwnerOnlyLockError::Unsafe)?; - } - Ok(file) -} - -fn open_lock_file(path: &Path) -> Result<(File, Option, bool), OwnerOnlyLockError> { - for _ in 0..2 { - match fs::symlink_metadata(path) { - Ok(before) => { - let file = lock_options(false) - .open(path) - .map_err(|_| OwnerOnlyLockError::Unsafe)?; - return Ok((file, Some(before), false)); - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - match lock_options(true).open(path) { - Ok(file) => return Ok((file, None, true)), - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(_) => return Err(OwnerOnlyLockError::Unsafe), - } - } - Err(_) => return Err(OwnerOnlyLockError::Unsafe), - } - } - Err(OwnerOnlyLockError::Unsafe) -} - -fn lock_options(create_new: bool) -> OpenOptions { - let mut options = OpenOptions::new(); - options - .read(true) - .write(true) - .create_new(create_new) - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC); - options -} - -pub(crate) fn open_owner_only_append(path: &Path, create_new: bool) -> Result { - reject_symlink_components(path)?; - let before = fs::symlink_metadata(path).ok(); - let file = OpenOptions::new() - .read(true) - .append(true) - .create_new(create_new) - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) - .open(path)?; - let after = file.metadata()?; - if !safe_regular(&after) - || before - .as_ref() - .is_some_and(|metadata| metadata.dev() != after.dev() || metadata.ino() != after.ino()) - { - return Err(RuntimeError::Io); - } - Ok(file) -} - fn safe_regular(metadata: &fs::Metadata) -> bool { safe_regular_values( metadata.file_type().is_file() && !metadata.file_type().is_symlink(), @@ -258,56 +193,99 @@ pub(crate) fn atomic_write_owner_only( bytes: &[u8], replace: bool, ) -> Result<(), RuntimeError> { - let parent = normalized_parent(path)?; - let file_name = path.file_name().ok_or(RuntimeError::Io)?; - let final_path = parent.join(file_name); - let temp_path = parent.join(format!( - ".{}.{}.tmp", - file_name.to_string_lossy(), - uuid::Uuid::new_v4() - )); - let result = write_and_publish(parent, &temp_path, &final_path, bytes, replace); - if result.is_err() { - let _ = fs::remove_file(&temp_path); - } - result + atomic_write_owner_only_with_policy(path, bytes, replace, false) } -fn write_and_publish( - parent: &Path, - temp_path: &Path, +pub(crate) fn atomic_write_owner_only_strict( path: &Path, bytes: &[u8], replace: bool, ) -> Result<(), RuntimeError> { - write_and_publish_with_sync(parent, temp_path, path, bytes, replace, sync_directory) + atomic_write_owner_only_with_policy(path, bytes, replace, true) } -fn write_and_publish_with_sync( - parent: &Path, - temp_path: &Path, +fn atomic_write_owner_only_with_policy( path: &Path, bytes: &[u8], replace: bool, - sync_parent: fn(&Path) -> Result<(), RuntimeError>, + strict_parent: bool, ) -> Result<(), RuntimeError> { - let mut file = OpenOptions::new() - .create_new(true) - .write(true) - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) - .open(temp_path)?; + atomic_write_owner_only_with_hooks( + path, + bytes, + replace, + strict_parent, + || {}, + |parent| parent.sync_all().map_err(Into::into), + ) +} + +fn atomic_write_owner_only_with_hooks( + path: &Path, + bytes: &[u8], + replace: bool, + strict_parent: bool, + before_publish: BeforePublish, + sync_parent: SyncParent, +) -> Result<(), RuntimeError> +where + BeforePublish: FnOnce(), + SyncParent: FnOnce(&File) -> Result<(), RuntimeError>, +{ + let parent_path = normalized_parent(path)?; + let parent = open_verified_owner_directory(parent_path, strict_parent)?; + let final_name = c_name(path.file_name().ok_or(RuntimeError::Io)?)?; + let temp_name = c_name(std::ffi::OsStr::new(&format!( + ".{}.{}.tmp", + path.file_name().ok_or(RuntimeError::Io)?.to_string_lossy(), + uuid::Uuid::new_v4() + )))?; + let temp_raw = unsafe { + libc::openat( + parent.as_raw_fd(), + temp_name.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o600, + ) + }; + if temp_raw < 0 { + return Err(RuntimeError::Io); + } + let mut file = unsafe { File::from_raw_fd(temp_raw) }; + if !safe_regular(&file.metadata()?) { + return Err(RuntimeError::Io); + } file.write_all(bytes)?; file.flush()?; file.sync_all()?; drop(file); - if replace { - fs::rename(temp_path, path)?; + before_publish(); + let publish_result = if replace { + cvt(unsafe { + libc::renameat( + parent.as_raw_fd(), + temp_name.as_ptr(), + parent.as_raw_fd(), + final_name.as_ptr(), + ) + }) } else { - fs::hard_link(temp_path, path)?; - fs::remove_file(temp_path)?; + cvt(unsafe { + libc::linkat( + parent.as_raw_fd(), + temp_name.as_ptr(), + parent.as_raw_fd(), + final_name.as_ptr(), + 0, + ) + }) + .and_then(|()| unlink_relative(&parent, &temp_name)) + }; + if publish_result.is_err() { + let _ = unlink_relative(&parent, &temp_name); + return publish_result; } - sync_parent(parent) + sync_parent(&parent) } fn normalized_parent(path: &Path) -> Result<&Path, RuntimeError> { @@ -318,16 +296,156 @@ fn normalized_parent(path: &Path) -> Result<&Path, RuntimeError> { Ok(parent) } -fn sync_directory(path: &Path) -> Result<(), RuntimeError> { - File::open(PathBuf::from(path))?.sync_all()?; - Ok(()) +pub(crate) fn open_verified_owner_directory( + path: &Path, + strict_mode: bool, +) -> Result { + if strict_mode { + reject_symlink_components(path)?; + } + let before = fs::symlink_metadata(path)?; + let mode = before.mode() & 0o777; + if !before.file_type().is_dir() + || before.file_type().is_symlink() + || before.uid() != unsafe { libc::geteuid() } + || if strict_mode { + mode != 0o700 + } else { + mode & 0o022 != 0 + } + { + return Err(RuntimeError::Io); + } + let name = c_name(path.as_os_str())?; + let raw = unsafe { + libc::open( + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if raw < 0 { + return Err(RuntimeError::Io); + } + let parent = unsafe { File::from_raw_fd(raw) }; + let after = parent.metadata()?; + let after_mode = after.mode() & 0o777; + if !after.file_type().is_dir() + || after.uid() != unsafe { libc::geteuid() } + || if strict_mode { + after_mode != 0o700 + } else { + after_mode & 0o022 != 0 + } + || before.dev() != after.dev() + || before.ino() != after.ino() + { + return Err(RuntimeError::Io); + } + Ok(parent) +} + +pub(crate) fn open_owner_only_append_at( + parent: &File, + name: &std::ffi::OsStr, +) -> Result<(File, bool), RuntimeError> { + let name = c_name(name)?; + for _ in 0..2 { + match openat_owner_file(parent, &name, libc::O_RDWR | libc::O_APPEND, false) { + Ok(file) => return Ok((file, false)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match openat_owner_file(parent, &name, libc::O_RDWR | libc::O_APPEND, true) { + Ok(file) => return Ok((file, true)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => return Err(RuntimeError::Io), + } + } + Err(_) => return Err(RuntimeError::Io), + } + } + Err(RuntimeError::Io) +} + +pub(crate) fn open_owner_only_lock_at( + parent: &File, + name: &std::ffi::OsStr, +) -> Result { + let name = c_name(name).map_err(|_| OwnerOnlyLockError::Unsafe)?; + for _ in 0..2 { + let (file, created) = match openat_owner_file(parent, &name, libc::O_RDWR, false) { + Ok(file) => (file, false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match openat_owner_file(parent, &name, libc::O_RDWR, true) { + Ok(file) => (file, true), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(_) => return Err(OwnerOnlyLockError::Unsafe), + } + } + Err(_) => return Err(OwnerOnlyLockError::Unsafe), + }; + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result != 0 { + return Err(OwnerOnlyLockError::Locked); + } + if created { + file.sync_data().map_err(|_| OwnerOnlyLockError::Unsafe)?; + parent.sync_all().map_err(|_| OwnerOnlyLockError::Unsafe)?; + } + return Ok(file); + } + Err(OwnerOnlyLockError::Unsafe) +} + +fn openat_owner_file( + parent: &File, + name: &std::ffi::CStr, + access: libc::c_int, + create_new: bool, +) -> std::io::Result { + let create_flags = if create_new { + libc::O_CREAT | libc::O_EXCL + } else { + 0 + }; + let raw = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + access | create_flags | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + 0o600, + ) + }; + if raw < 0 { + return Err(std::io::Error::last_os_error()); + } + let file = unsafe { File::from_raw_fd(raw) }; + if !safe_regular(&file.metadata()?) { + return Err(std::io::Error::other("unsafe owner-only file")); + } + Ok(file) +} + +fn c_name(value: &std::ffi::OsStr) -> Result { + std::ffi::CString::new(value.as_encoded_bytes()).map_err(|_| RuntimeError::Io) +} + +fn cvt(result: libc::c_int) -> Result<(), RuntimeError> { + if result == 0 { + Ok(()) + } else { + Err(RuntimeError::Io) + } +} + +fn unlink_relative(parent: &File, name: &std::ffi::CStr) -> Result<(), RuntimeError> { + cvt(unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), 0) }) } #[cfg(test)] mod tests { use super::*; + use std::os::unix::fs::PermissionsExt; - fn fail_directory_sync(_: &Path) -> Result<(), RuntimeError> { + fn fail_directory_sync(_: &File) -> Result<(), RuntimeError> { Err(RuntimeError::Io) } @@ -335,15 +453,15 @@ mod tests { fn post_rename_directory_sync_error_reports_uncertain_but_restart_sees_final() { let temp = tempfile::tempdir().unwrap(); let parent = temp.path().canonicalize().unwrap(); - let temporary = parent.join(".state.tmp"); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).unwrap(); let final_path = parent.join("state"); assert_eq!( - write_and_publish_with_sync( - &parent, - &temporary, + atomic_write_owner_only_with_hooks( &final_path, b"committed", true, + true, + || {}, fail_directory_sync, ), Err(RuntimeError::Io) @@ -369,4 +487,37 @@ mod tests { )); assert!(!safe_regular_values(false, metadata.uid(), metadata.mode())); } + + #[test] + fn parent_replacement_cannot_redirect_fd_relative_publication() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let managed = root.join("managed"); + let detached = root.join("detached"); + let attacker = root.join("attacker"); + fs::create_dir(&managed).unwrap(); + fs::create_dir(&attacker).unwrap(); + fs::set_permissions(&managed, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&attacker, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(attacker.join("state"), b"attacker-sentinel").unwrap(); + + atomic_write_owner_only_with_hooks( + &managed.join("state"), + b"trusted-state", + true, + true, + || { + fs::rename(&managed, &detached).unwrap(); + std::os::unix::fs::symlink(&attacker, &managed).unwrap(); + }, + |directory| directory.sync_all().map_err(Into::into), + ) + .unwrap(); + + assert_eq!(fs::read(detached.join("state")).unwrap(), b"trusted-state"); + assert_eq!( + fs::read(attacker.join("state")).unwrap(), + b"attacker-sentinel" + ); + } } diff --git a/tests/bootstrap_config.rs b/tests/bootstrap_config.rs index 25c1c15..ac75640 100644 --- a/tests/bootstrap_config.rs +++ b/tests/bootstrap_config.rs @@ -268,6 +268,16 @@ fn startup_loader_verifies_credential_role_time_domain_and_tls_identity() { load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now).unwrap(); assert_eq!(loaded.tls_identity.node_id.as_str(), "node:startup"); assert!(!format!("{loaded:?}").contains("BEGIN PRIVATE KEY")); + + std::fs::remove_file(&paths.tls_private_key_file).unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::UnsafeStatePath) + )); + paths + .write_startup_material(&chain, &node, &identity) + .unwrap(); + let wrong_root = SigningKey::from_bytes(&[43; 32]); assert!(matches!( load_startup_bundle( diff --git a/tests/bootstrap_recovery.rs b/tests/bootstrap_recovery.rs index 68d1e7e..29cdb6b 100644 --- a/tests/bootstrap_recovery.rs +++ b/tests/bootstrap_recovery.rs @@ -226,6 +226,50 @@ fn a_real_child_process_cannot_take_the_state_lock() { assert!(BootstrapStateStore::open(&journal).is_ok()); } +#[test] +fn runtime_journal_limit_rejects_before_append_and_restart_remains_valid() { + let temp = TempDir::new().unwrap(); + let journal = temp.path().canonicalize().unwrap().join("state/journal"); + let mut store = BootstrapStateStore::open(&journal).unwrap(); + store + .apply( + "binary", + BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled), + ) + .unwrap(); + let mut index = 0_u32; + 'cycles: loop { + for (name, transition) in [ + ( + "service", + BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), + ), + ( + "ready", + BootstrapTransition::Advance(BootstrapPhase::ReadyForEnrollment), + ), + ("rollback", BootstrapTransition::RollbackService), + ] { + let before = store.phase(); + let result = store.apply(&format!("{name}-{index}"), transition.clone()); + if result == Err(BootstrapError::ResourceLimitExceeded) { + assert_eq!(store.phase(), before); + assert_eq!( + store.apply(&format!("{name}-retry-{index}"), transition), + Err(BootstrapError::ResourceLimitExceeded) + ); + break 'cycles; + } + result.unwrap(); + } + index += 1; + assert!(index < 1_000, "runtime journal limit was not enforced"); + } + let phase = store.phase(); + drop(store); + assert_eq!(BootstrapStateStore::open(&journal).unwrap().phase(), phase); +} + #[test] #[ignore] fn bootstrap_lock_child() { From 806305041b485639fabae520c8c23f28e1389eec Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 00:28:48 +0800 Subject: [PATCH 29/67] [bug] Clean failed bootstrap publications Root cause: Pre-publication early returns bypassed temporary-file cleanup after the secure fd-relative create. Solution: Guard each temporary name with parent-fd-relative RAII cleanup and disarm only after successful publication. Risks: A process crash can still leave an owner-only temporary inode. Dependency: Bootstrap step 9 and commit 0196b5f. Links: plan/01-v1-multi-host-node-bootstrap.md Post-mortem: Cleanup ownership must begin when a resource is created. --- README.md | 14 +++ ROADMAP.md | 12 ++- src/runtime/key_store.rs | 183 +++++++++++++++++++++++++++++++++--- tests/bootstrap_recovery.rs | 20 ++++ 4 files changed, 215 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 22b36ca..550b566 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,20 @@ The journal is provisional newline-framed JSONL, not binary framing. Its lock, create/open, replay, header sync, and later appends remain anchored to one verified owner-only directory descriptor and one journal descriptor, so a pathname replacement cannot redirect publication between validation and sync. +Temporary private-material files are guarded from creation through publication: +any metadata, write, flush, or file-sync error unlinks the temporary name via +the pinned parent descriptor. Successful rename/link publication disarms that +cleanup before the parent-directory sync, so an uncertain post-publish sync +error never deletes the final file. A process crash can still leave a `0600` +temporary inode; automatic wildcard cleanup is intentionally unsupported. + +New journal creation exposes its owner-only final name before writing and +syncing the versioned header. If header write, file sync, or parent-directory +sync is uncertain, opening the store fails and the file is retained. Restart +accepts only a complete valid header and otherwise fails closed; explicit +operator/Task 10 repair is required. AgenNet does not automatically delete the +file because a complete header may already be durable even when sync reported +an error. ## MVP boundary diff --git a/ROADMAP.md b/ROADMAP.md index 2febb33..9312fd4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,16 @@ # ROADMAP -## 2026-08-15 01:10 CST +## 2026-08-15 00:28 CST + +- **Change**: Closed Task 9 review-round-two leakage of failed private-material temporary publications. +- **Files**: `src/runtime/key_store.rs`, `README.md`, this roadmap, and ignored Task 9 report/evidence. +- **Root cause**: Regression in the round-one fd-relative refactor — the temporary file was created securely, but metadata, write, flush, and file-sync used `?` before the later explicit cleanup block. Those early returns could retain partially or fully written secret-bearing `0600` temporary names and accumulate them across repeated failures. +- **Solution**: Arm an fd-relative `PendingTemp` guard immediately after successful `openat`. Every pre-publication return unlinks through the pinned parent fd; successful rename or link-plus-unlink publication explicitly disarms the guard before directory sync. Post-publish sync uncertainty therefore retains the final file, while non-replacing publication keeps its prior no-overwrite semantics. +- **Verification**: A real fault seam covers failure after a partial write, after flush, and after file sync. With cleanup deliberately disabled, the test observes the secret temporary filename; with the guard active, the original final and attacker boundary remain unchanged and the directory contains no temporary entry. +- **Journal boundary**: A new journal's final owner-only name is created before its header is written and synced. Header write/file-sync/parent-sync uncertainty retains the file; restart accepts only a complete valid header and otherwise fails closed. Automatic deletion is forbidden because a complete header may already be durable despite a reported sync error. Task 10 must expose explicit repair/reconciliation rather than guessing. +- **Post-mortem**: Cleanup ownership must begin at resource creation, not after the first fallible operation. Every future secret-bearing temporary resource needs a drop/RAII failure path tested after partial write and after durable file sync. + +## 2026-08-15 00:15 CST - **Change**: Closed Task 9 review-round-one persistence races without changing the provisional schema or entering Task 10. - **Files**: `src/runtime/key_store.rs`, `src/bootstrap/paths.rs`, `src/bootstrap/state.rs`, adjacent compatibility fixtures, focused bootstrap tests, `README.md`, this roadmap, and ignored Task 9 report/evidence. diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index d146144..0c2ca02 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -231,6 +231,53 @@ fn atomic_write_owner_only_with_hooks( where BeforePublish: FnOnce(), SyncParent: FnOnce(&File) -> Result<(), RuntimeError>, +{ + atomic_write_owner_only_with_writer_and_hooks( + path, + bytes, + replace, + strict_parent, + write_temp_fully, + before_publish, + sync_parent, + ) +} + +#[cfg(test)] +fn atomic_write_owner_only_with_temp_writer( + path: &Path, + bytes: &[u8], + replace: bool, + strict_parent: bool, + temp_writer: TempWriter, +) -> Result<(), RuntimeError> +where + TempWriter: FnOnce(&mut File, &[u8]) -> Result<(), RuntimeError>, +{ + atomic_write_owner_only_with_writer_and_hooks( + path, + bytes, + replace, + strict_parent, + temp_writer, + || {}, + |parent| parent.sync_all().map_err(Into::into), + ) +} + +fn atomic_write_owner_only_with_writer_and_hooks( + path: &Path, + bytes: &[u8], + replace: bool, + strict_parent: bool, + temp_writer: TempWriter, + before_publish: BeforePublish, + sync_parent: SyncParent, +) -> Result<(), RuntimeError> +where + TempWriter: FnOnce(&mut File, &[u8]) -> Result<(), RuntimeError>, + BeforePublish: FnOnce(), + SyncParent: FnOnce(&File) -> Result<(), RuntimeError>, { let parent_path = normalized_parent(path)?; let parent = open_verified_owner_directory(parent_path, strict_parent)?; @@ -251,43 +298,79 @@ where if temp_raw < 0 { return Err(RuntimeError::Io); } + let mut pending = PendingTemp::new(&parent, temp_name); let mut file = unsafe { File::from_raw_fd(temp_raw) }; if !safe_regular(&file.metadata()?) { return Err(RuntimeError::Io); } - file.write_all(bytes)?; - file.flush()?; - file.sync_all()?; + temp_writer(&mut file, bytes)?; drop(file); before_publish(); - let publish_result = if replace { + if replace { cvt(unsafe { libc::renameat( parent.as_raw_fd(), - temp_name.as_ptr(), + pending.name().as_ptr(), parent.as_raw_fd(), final_name.as_ptr(), ) - }) + })?; + pending.disarm(); } else { cvt(unsafe { libc::linkat( parent.as_raw_fd(), - temp_name.as_ptr(), + pending.name().as_ptr(), parent.as_raw_fd(), final_name.as_ptr(), 0, ) - }) - .and_then(|()| unlink_relative(&parent, &temp_name)) - }; - if publish_result.is_err() { - let _ = unlink_relative(&parent, &temp_name); - return publish_result; + })?; + unlink_relative(&parent, pending.name())?; + pending.disarm(); } sync_parent(&parent) } +fn write_temp_fully(file: &mut File, bytes: &[u8]) -> Result<(), RuntimeError> { + file.write_all(bytes)?; + file.flush()?; + file.sync_all()?; + Ok(()) +} + +struct PendingTemp<'a> { + parent: &'a File, + name: std::ffi::CString, + armed: bool, +} + +impl<'a> PendingTemp<'a> { + fn new(parent: &'a File, name: std::ffi::CString) -> Self { + Self { + parent, + name, + armed: true, + } + } + + fn name(&self) -> &std::ffi::CStr { + &self.name + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for PendingTemp<'_> { + fn drop(&mut self) { + if self.armed { + let _ = unlink_relative(self.parent, &self.name); + } + } +} + fn normalized_parent(path: &Path) -> Result<&Path, RuntimeError> { let parent = path.parent().ok_or(RuntimeError::Io)?; if parent.as_os_str().is_empty() { @@ -445,10 +528,84 @@ mod tests { use super::*; use std::os::unix::fs::PermissionsExt; + type TempWriter = fn(&mut File, &[u8]) -> Result<(), RuntimeError>; + fn fail_directory_sync(_: &File) -> Result<(), RuntimeError> { Err(RuntimeError::Io) } + fn fail_after_partial_write(file: &mut File, _: &[u8]) -> Result<(), RuntimeError> { + file.write_all(b"partial-secret")?; + Err(RuntimeError::Io) + } + + fn fail_after_flush(file: &mut File, bytes: &[u8]) -> Result<(), RuntimeError> { + file.write_all(bytes)?; + file.flush()?; + Err(RuntimeError::Io) + } + + fn fail_after_sync(file: &mut File, bytes: &[u8]) -> Result<(), RuntimeError> { + file.write_all(bytes)?; + file.flush()?; + file.sync_all()?; + Err(RuntimeError::Io) + } + + #[test] + fn prepublish_write_flush_or_sync_failure_removes_secret_temp_by_parent_fd() { + for (index, writer) in [ + fail_after_partial_write as TempWriter, + fail_after_flush, + fail_after_sync, + ] + .into_iter() + .enumerate() + { + let temp = tempfile::tempdir().unwrap(); + let parent = temp + .path() + .canonicalize() + .unwrap() + .join(format!("managed-{index}")); + fs::create_dir(&parent).unwrap(); + fs::set_permissions(&parent, fs::Permissions::from_mode(0o700)).unwrap(); + let attacker = temp + .path() + .canonicalize() + .unwrap() + .join(format!("attacker-{index}")); + fs::create_dir(&attacker).unwrap(); + fs::write(attacker.join("private.key"), b"attacker-sentinel").unwrap(); + let final_path = parent.join("private.key"); + fs::write(&final_path, b"trusted-final").unwrap(); + fs::set_permissions(&final_path, fs::Permissions::from_mode(0o600)).unwrap(); + + assert_eq!( + atomic_write_owner_only_with_temp_writer( + &final_path, + b"new-secret-material", + true, + true, + writer, + ), + Err(RuntimeError::Io) + ); + assert_eq!(fs::read(&final_path).unwrap(), b"trusted-final"); + assert_eq!( + fs::read(attacker.join("private.key")).unwrap(), + b"attacker-sentinel" + ); + assert_eq!( + fs::read_dir(&parent) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(), + vec![std::ffi::OsString::from("private.key")] + ); + } + } + #[test] fn post_rename_directory_sync_error_reports_uncertain_but_restart_sees_final() { let temp = tempfile::tempdir().unwrap(); diff --git a/tests/bootstrap_recovery.rs b/tests/bootstrap_recovery.rs index 29cdb6b..5845245 100644 --- a/tests/bootstrap_recovery.rs +++ b/tests/bootstrap_recovery.rs @@ -162,6 +162,26 @@ fn journal_fails_closed_on_torn_future_corrupt_or_oversized_input() { )); } +#[test] +fn incomplete_new_journal_header_is_retained_for_explicit_repair() { + let temp = TempDir::new().unwrap(); + let state = temp.path().canonicalize().unwrap().join("state"); + std::fs::create_dir(&state).unwrap(); + std::fs::set_permissions(&state, std::fs::Permissions::from_mode(0o700)).unwrap(); + let journal = state.join("journal"); + std::fs::write(&journal, []).unwrap(); + std::fs::set_permissions(&journal, std::fs::Permissions::from_mode(0o600)).unwrap(); + + for _ in 0..2 { + assert!(matches!( + BootstrapStateStore::open(&journal), + Err(BootstrapError::InvalidBootstrapJournal) + )); + assert!(journal.is_file()); + assert_eq!(std::fs::metadata(&journal).unwrap().len(), 0); + } +} + #[test] fn journal_hash_chain_rejects_a_stable_bit_flip() { let temp = TempDir::new().unwrap(); From 35f995bf2059b9721bcb1c958d690dfbd4f4b040 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 01:16:47 +0800 Subject: [PATCH 30/67] [feat][Bootstrap][10/14] Add bootstrap commands Root cause: NA Solution: Expose Domain, invitation, and pinned enrollment through one typed CLI with TTY-only secret entry. Risks: Headless enrollment requires an interactive operator. Dependency: Bootstrap step 9. Links: docs/superpowers/specs/ 2026-08-14-node-bootstrap-and-pages-design.md --- Cargo.toml | 2 +- README.md | 18 + ROADMAP.md | 9 + ...6-08-14-node-bootstrap-and-pages-design.md | 11 + plan/01-v1-multi-host-node-bootstrap.md | 17 +- src/bootstrap/enrollment.rs | 47 +- src/bootstrap/invitation.rs | 185 +++++- src/bootstrap/journal.rs | 10 +- src/bootstrap/paths.rs | 16 + src/bootstrap/pki.rs | 97 ++- src/cli/domain.rs | 444 +++++++++++++ src/cli/invite.rs | 283 ++++++++ src/cli/join.rs | 623 ++++++++++++++++++ src/cli/mod.rs | 169 +++++ src/cli/output.rs | 58 ++ src/lib.rs | 1 + src/main.rs | 75 +-- src/protocol/enrollment.rs | 12 +- src/transport/enrollment.rs | 4 +- tests/cli_bootstrap.rs | 127 ++++ tests/enrollment_protocol.rs | 19 +- tests/http_enrollment.rs | 6 +- tests/invitation_store.rs | 21 +- 23 files changed, 2114 insertions(+), 140 deletions(-) create mode 100644 src/cli/domain.rs create mode 100644 src/cli/invite.rs create mode 100644 src/cli/join.rs create mode 100644 src/cli/mod.rs create mode 100644 src/cli/output.rs create mode 100644 tests/cli_bootstrap.rs diff --git a/Cargo.toml b/Cargo.toml index aa2ca3f..36eb847 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "agenet" version = "0.2.0" edition = "2024" rust-version = "1.97.1" -description = "Experimental AgenNet loopback coordination substrate" +description = "Experimental AgenNet private-overlay coordination substrate" license = "MIT" [lib] diff --git a/README.md b/README.md index 550b566..fc0b2ab 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,21 @@ reachability. Separate OS processes and state directories are not claimed as a secure sandbox. The workload does not execute shell commands. A future `project.build_test.v1` adapter must use Docker, a VM, or a platform sandbox before accepting untrusted code. + +## Developer Preview bootstrap CLI + +One typed CLI path is being added for private Tailscale and WireGuard Domains: + +```text +agenet domain init --network tailscale --bind-ip +agenet domain init --network wireguard --bind-ip \ + --allowed-cidr +agenet invite create --profile --ttl 10m +agenet node join [--bind-ip ] +``` + +Root passphrases and complete invitations use only the controlling terminal; +they are not accepted through argv, environment variables, JSON, or ordinary +stdin. `node join` returns `credential_issued` and the next command instead of +claiming registration or health. User-service start, registration, doctor, +and physical two-device proof remain later gates. diff --git a/ROADMAP.md b/ROADMAP.md index 9312fd4..6abb74d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 03:40 CST + +- **Change**: Added the Task 10 bootstrap CLI and corrected the provisional invitation/enrollment wire boundary before exposing it to operators. +- **Files**: `src/cli/`, `src/main.rs`, bootstrap invitation/enrollment and persistence paths, protocol/CLI tests, design, plan, and README. +- **Root cause**: Security design gap — invitation v2 did not carry Domain-owned overlay policy and enrollment v0.2 did not sign a joining node bind IP. Its client-only certificate could not pass Task 9's exact-IP peer startup validation. +- **Solution**: Emit invitation handoff/journal v3 with overlay kind and allowed CIDRs; sign `requested_bind_ip` in enrollment v0.3; validate it before reservation; issue and validate a dual-use certificate with one exact IP SAN. Old formats fail closed. Added TTY-only Domain/invitation/join commands, durable pending-operation recovery, startup-bundle validation, and stable redacted output. +- **Prevention**: Work backward from the complete daemon startup validator. Every transport-identity input must be explicitly authorized, signed, persisted, and tested before a bearer can be consumed. +- **Boundary**: Task 10 stops at `CredentialIssued`. Service start, registration, doctor, live route clocks, and physical two-device proof remain deferred. + ## 2026-08-15 00:28 CST - **Change**: Closed Task 9 review-round-two leakage of failed private-material temporary publications. diff --git a/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md index 9f86011..fe0b501 100644 --- a/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md +++ b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md @@ -218,6 +218,8 @@ contains: - Domain Root and Authority TLS CA fingerprints; - allowed profile and capability ceiling; - invite ID, expiry, and maximum attempts. +- Domain-owned overlay kind and bounded allowed CIDRs used to validate both + control-plane endpoints and the joining node's requested bind address. The secret contains at least 256 bits of randomness. The Authority stores only an HMAC-SHA-256 digest made with a separate owner-only server pepper. Defaults @@ -231,6 +233,7 @@ invitation. Joining node Enrollment Authority ------------ -------------------- generate signing key + TLS CSR +select an assigned bind IP inside invitation policy pin TLS CA from invitation ──────▶ validate TLS bootstrap sign exact enrollment request ─────▶ check secret hash, expiry, profile, attempts, operation ID, and key proof @@ -241,6 +244,14 @@ start user service ─────▶ signed health and Manifest regi run doctor ◀───── signed Directory acknowledgement ``` +The provisional v0.3 enrollment request signs the requested bind IP together +with the node key, CSR, profile, operation ID, and exact invitation-claims +digest. The Authority rejects a bind IP outside the invitation-owned policy +before reserving the invitation and issues a dual-use peer certificate whose +only IP SAN is that exact address. Invitation handoff and journal v2 are +rejected because they did not bind enough network policy to authorize a peer +identity; no migration guesses this security input. + The node private key never leaves the joining machine. Enrollment uses an operation ID and durable result record. If the response is lost after issuance, the same operation recovers the existing credential instead of issuing a second diff --git a/plan/01-v1-multi-host-node-bootstrap.md b/plan/01-v1-multi-host-node-bootstrap.md index 5a3188a..d40f031 100644 --- a/plan/01-v1-multi-host-node-bootstrap.md +++ b/plan/01-v1-multi-host-node-bootstrap.md @@ -671,28 +671,31 @@ Links: plan/01-v1-multi-host-node-bootstrap.md agenet domain init --network --bind-ip [--allowed-cidr ] agenet invite create --profile [--ttl 10m] -agenet node join +agenet node join [--bind-ip ] ``` -- [ ] Write CLI tests for help, exact required arguments, invalid network +- [x] Write CLI tests for help, exact required arguments, invalid network combinations, non-TTY rejection, pre-existing Domain state, JSON output, and secret redaction. Capture process lists to prove passphrases and invitation secrets never appear in argv. -- [ ] Make `domain init` request and confirm a passphrase on the controlling +- [x] Make `domain init` request and confirm a passphrase on the controlling TTY, create the Root and Authority, write the encrypted Root keystore, and output only Domain ID, endpoints, Root fingerprint, and next commands. -- [ ] Make `invite create` unlock the Root keystore, authorize the online +- [x] Make `invite create` unlock the Root keystore, authorize the online Authority scope, and print the invitation secret exactly once to the controlling TTY. Structured JSON output contains only invitation metadata and must never contain the secret. -- [ ] Make `node join` read the complete invitation through hidden TTY input, +- [x] Make `node join` read the complete invitation through hidden TTY input, validate its public Domain, Authority, Directory, profile, capability ceiling, expiry, attempt count, and fingerprint fields, generate keys locally, perform pinned enrollment, persist validated state atomically, and return a stable `JoinResult`. -- [ ] Add `--output human|json` for automation. Keep stable `code`, `message`, +- [x] Add `--output human|json` for automation. Keep stable `code`, `message`, `retryable`, and `operation_id` fields in JSON errors. -- [ ] Run `cargo test --test cli_bootstrap` and manually inspect `agenet +- [x] Bind Domain-owned overlay policy into invitation v3 and the requested + bind IP into signed enrollment v0.3; issue an exact-IP dual-use peer + certificate and reject v2 without guessing missing authorization policy. +- [x] Run `cargo test --test cli_bootstrap` and manually inspect `agenet --help`, `agenet domain --help`, `agenet invite --help`, and `agenet node join --help`. - [ ] Commit: diff --git a/src/bootstrap/enrollment.rs b/src/bootstrap/enrollment.rs index d51887e..fdf8505 100644 --- a/src/bootstrap/enrollment.rs +++ b/src/bootstrap/enrollment.rs @@ -2,6 +2,7 @@ use std::{ fmt::{Debug, Formatter}, fs::{self, DirBuilder, File, OpenOptions}, io::{Read, Write}, + net::IpAddr, os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}, path::{Path, PathBuf}, sync::{Arc, Mutex}, @@ -32,7 +33,7 @@ use super::{ InvitationPublicClaims, InvitationStore, ReservationStatus, }; -const ENROLLMENT_WIRE_VERSION: &str = "agenet.enrollment-wire.v0.2"; +const ENROLLMENT_WIRE_VERSION: &str = "agenet.enrollment-wire.v0.3"; const MAX_RESULT_BYTES: usize = 256 * 1024; pub(crate) const MAX_ENROLLMENT_REQUEST_BYTES: usize = 256 * 1024; @@ -41,6 +42,7 @@ pub struct EnrollmentAttempt { pub operation_id: Uuid, pub node_id: NodeId, pub requested_profile: BootstrapProfile, + pub requested_bind_ip: IpAddr, pub signing_public_key_base64: String, pub tls_csr_pem: String, exact_claims_base64: String, @@ -67,6 +69,7 @@ impl EnrollmentAttempt { operation_id: Uuid, node_id: NodeId, requested_profile: BootstrapProfile, + requested_bind_ip: IpAddr, signing_key: &SigningKey, tls_csr_pem: String, ) -> Result { @@ -81,6 +84,7 @@ impl EnrollmentAttempt { )?, node_id: node_id.clone(), requested_profile, + requested_bind_ip, signing_public_key_base64: STANDARD.encode(signing_key.verifying_key().to_bytes()), tls_csr_pem: tls_csr_pem.clone(), }; @@ -92,6 +96,7 @@ impl EnrollmentAttempt { operation_id, node_id, requested_profile, + requested_bind_ip, signing_public_key_base64, tls_csr_pem, exact_claims_base64, @@ -116,6 +121,7 @@ impl EnrollmentAttempt { || claims.invitation_claims_sha256 != expected_digest || claims.node_id != self.node_id || claims.requested_profile != self.requested_profile + || claims.requested_bind_ip != self.requested_bind_ip || claims.signing_public_key_base64 != self.signing_public_key_base64 || claims.tls_csr_pem != self.tls_csr_pem { @@ -400,6 +406,14 @@ impl EnrollmentAuthority { { return Err(EnrollmentError::PolicyRejected); } + let requested_boundary = super::network::NetworkBoundary { + kind: request.public_claims.network_kind, + bind_ip: claims.requested_bind_ip, + allowed_cidrs: request.public_claims.allowed_cidrs.clone(), + }; + requested_boundary + .validate_bind_shape() + .map_err(|_| EnrollmentError::PolicyRejected)?; self.verify_authority_binding(&request.public_claims)?; Ok(VerifiedEnrollmentRequest { request_digest: request.exact_request_digest()?, @@ -510,7 +524,7 @@ impl EnrollmentAuthority { return Err(EnrollmentError::TransportFailed); } let result = DurableEnrollmentResult { - format_version: "agenet.enrollment-result.v0.2".to_owned(), + format_version: "agenet.enrollment-result.v0.3".to_owned(), invitation_id: request.public_claims.invitation_id, operation_id: verified.claims.operation_id, exact_request_sha256: verified.request_digest, @@ -582,9 +596,10 @@ impl EnrollmentAuthority { .map_err(|_| EnrollmentError::PolicyRejected)?; let certificate = self .pki - .issue_client( + .issue_peer( &request.tls_csr_pem, &request.node_id, + request.requested_bind_ip, issued_at_ms, expires_at_ms, ) @@ -664,7 +679,7 @@ impl EnrollmentAuthority { } let result: DurableEnrollmentResult = serde_json::from_slice(&encoded).map_err(|_| EnrollmentError::PersistenceFailed)?; - if result.format_version != "agenet.enrollment-result.v0.2" + if result.format_version != "agenet.enrollment-result.v0.3" || result.invitation_id != invitation_id || result.operation_id != operation_id || result.issued_at_ms <= 0 @@ -753,7 +768,7 @@ fn validate_tls_bundle( let (_, client) = x509_parser::prelude::X509Certificate::from_der(&client_der) .map_err(|_| EnrollmentError::CertificateRejected)?; validate_returned_ca(&ca, now_ms)?; - validate_returned_leaf(&client)?; + validate_returned_leaf(&client, attempt.requested_bind_ip)?; client .verify_signature(Some(ca.public_key())) .map_err(|_| EnrollmentError::CertificateRejected)?; @@ -862,6 +877,7 @@ fn validate_returned_ca( fn validate_returned_leaf( leaf: &x509_parser::certificate::X509Certificate<'_>, + requested_bind_ip: IpAddr, ) -> Result<(), EnrollmentError> { let basic = leaf .basic_constraints() @@ -875,17 +891,25 @@ fn validate_returned_leaf( .extended_key_usage() .map_err(|_| EnrollmentError::CertificateRejected)? .ok_or(EnrollmentError::CertificateRejected)?; - let has_san = leaf + let san = leaf .subject_alternative_name() .map_err(|_| EnrollmentError::CertificateRejected)? - .is_some(); + .ok_or(EnrollmentError::CertificateRejected)?; + let expected = match requested_bind_ip { + IpAddr::V4(value) => value.octets().to_vec(), + IpAddr::V6(value) => value.octets().to_vec(), + }; + let exact_ip_san = san.value.general_names.as_slice() + == [x509_parser::extensions::GeneralName::IPAddress( + expected.as_slice(), + )]; if basic.value.ca || basic.value.path_len_constraint.is_some() || usage.value.flags != 1 || !extended.value.client_auth || extended.value.any - || extended.value.server_auth - || has_san + || !extended.value.server_auth + || !exact_ip_san { return Err(EnrollmentError::CertificateRejected); } @@ -1054,12 +1078,14 @@ mod tests { let handoff = invitations .create( super::super::InvitationSpec { - protocol_version: "agenet.enrollment.v0.2".to_owned(), + protocol_version: "agenet.enrollment.v0.3".to_owned(), domain_id: authority_credential.claims.domain_id.clone(), authority_endpoint: endpoint.clone(), directory_seeds: vec![ Url::parse("https://127.0.0.1:9443/").expect("directory"), ], + network_kind: super::super::network::OverlayKind::Loopback, + allowed_cidrs: vec!["127.0.0.1/32".parse().expect("CIDR")], root_sha256: fingerprint_bytes(root.verifying_key().as_bytes()), tls_ca_sha256: pki.fingerprint_sha256.clone(), allowed_profile: BootstrapProfile::Provider, @@ -1075,6 +1101,7 @@ mod tests { operation_id, NodeId::new(format!("node-{operation_id}")).expect("node"), BootstrapProfile::Provider, + "127.0.0.1".parse().expect("IP"), &SigningKey::from_bytes(&[43_u8; 32]), super::super::NodeTlsCsr::generate().expect("CSR").csr_pem, ) diff --git a/src/bootstrap/invitation.rs b/src/bootstrap/invitation.rs index f9cf9a1..9e708b0 100644 --- a/src/bootstrap/invitation.rs +++ b/src/bootstrap/invitation.rs @@ -11,6 +11,7 @@ use std::{ use age::secrecy::{ExposeSecret, SecretString}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use hmac::{Hmac, KeyInit, Mac}; +use ipnet::IpNet; use reqwest::Url; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -19,7 +20,7 @@ use zeroize::{Zeroize, Zeroizing}; use crate::protocol::{BootstrapProfile, CapabilityKind, DomainId, NodeId}; -use super::{BootstrapError, journal::DurableJournal}; +use super::{BootstrapError, journal::DurableJournal, network::OverlayKind}; const PEPPER_FILE: &str = "invitation.pepper"; const JOURNAL_FILE: &str = "invitation.journal"; @@ -27,11 +28,13 @@ const PEPPER_BYTES: usize = 32; const SECRET_BYTES: usize = 32; const ENCODED_SECRET_BYTES: usize = 43; const DEFAULT_TTL_MS: i64 = 10 * 60 * 1_000; +const MIN_TTL_MS: i64 = 60 * 1_000; +const MAX_TTL_MS: i64 = 24 * 60 * 60 * 1_000; const MAXIMUM_ATTEMPTS: u8 = 5; const MAX_INVITATIONS: usize = 10_000; const MAX_DIRECTORY_SEEDS: usize = 16; const MAX_CAPABILITIES: usize = 64; -const HANDOFF_FORMAT_V2: &str = "agenet.invitation-handoff.v2"; +const HANDOFF_FORMAT_V3: &str = "agenet.invitation-handoff.v3"; const MAX_HANDOFF_BYTES: usize = 16 * 1024; type HmacSha256 = Hmac; @@ -74,6 +77,8 @@ pub struct InvitationPublicClaims { pub authority_endpoint: Url, #[serde(with = "url_vec_serde")] pub directory_seeds: Vec, + pub network_kind: OverlayKind, + pub allowed_cidrs: Vec, pub root_sha256: String, pub tls_ca_sha256: String, pub allowed_profile: BootstrapProfile, @@ -92,6 +97,8 @@ pub struct InvitationSpec { pub authority_endpoint: Url, #[serde(with = "url_vec_serde")] pub directory_seeds: Vec, + pub network_kind: OverlayKind, + pub allowed_cidrs: Vec, pub root_sha256: String, pub tls_ca_sha256: String, pub allowed_profile: BootstrapProfile, @@ -255,30 +262,84 @@ pub fn display_invitation_handoff_to_tty( handoff: &InvitationHandoff, ) -> Result<(), BootstrapError> { let mut terminal = open_controlling_tty_for_write()?; - let mut encoded = encode_handoff(handoff)?; - encoded.push(b'\n'); + let encoded = encode_handoff(handoff)?; terminal .write_all(&encoded) + .and_then(|()| terminal.write_all(b"\n")) .and_then(|()| terminal.flush()) .map_err(|_| BootstrapError::HandoffTtyUnavailable) } fn encode_handoff(handoff: &InvitationHandoff) -> Result>, BootstrapError> { + encode_handoff_with_limit(handoff, MAX_HANDOFF_BYTES) +} + +fn encode_handoff_with_limit( + handoff: &InvitationHandoff, + limit: usize, +) -> Result>, BootstrapError> { validate_public_claims(&handoff.public_claims)?; let wire = InvitationHandoffRef { - format_version: HANDOFF_FORMAT_V2, + format_version: HANDOFF_FORMAT_V3, public_claims: &handoff.public_claims, secret: handoff.authentication.secret_for_request().expose_secret(), claims_integrity_hmac_sha256_base64: URL_SAFE_NO_PAD .encode(handoff.authentication.claims_integrity_hmac_for_request()), }; - let encoded = Zeroizing::new( - serde_json::to_vec(&wire).map_err(|_| BootstrapError::InvalidInvitationClaims)?, - ); - if encoded.len() > MAX_HANDOFF_BYTES { - return Err(BootstrapError::ResourceLimitExceeded); + let mut writer = FixedSecretWriter::new(limit)?; + if serde_json::to_writer(&mut writer, &wire).is_err() { + return Err(if writer.limit_exceeded { + BootstrapError::ResourceLimitExceeded + } else { + BootstrapError::InvalidInvitationClaims + }); + } + Ok(writer.into_bytes()) +} + +struct FixedSecretWriter { + bytes: Zeroizing>, + limit: usize, + limit_exceeded: bool, +} + +impl FixedSecretWriter { + fn new(limit: usize) -> Result { + if limit == 0 || limit > MAX_HANDOFF_BYTES { + return Err(BootstrapError::ResourceLimitExceeded); + } + Ok(Self { + bytes: Zeroizing::new(Vec::with_capacity(limit)), + limit, + limit_exceeded: false, + }) + } + + fn into_bytes(mut self) -> Zeroizing> { + std::mem::take(&mut self.bytes) + } +} + +impl Write for FixedSecretWriter { + fn write(&mut self, input: &[u8]) -> std::io::Result { + let next = self + .bytes + .len() + .checked_add(input.len()) + .filter(|next| *next <= self.limit); + let Some(_) = next else { + self.limit_exceeded = true; + return Err(std::io::Error::other( + "handoff serialization limit exceeded", + )); + }; + self.bytes.extend_from_slice(input); + Ok(input.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) } - Ok(encoded) } /// Reads a complete versioned handoff with terminal echo disabled. @@ -299,12 +360,12 @@ fn decode_handoff(encoded: &[u8]) -> Result { } let format: InvitationHandoffFormatProbe = serde_json::from_slice(encoded).map_err(|_| BootstrapError::InvalidInvitationClaims)?; - if format.format_version != HANDOFF_FORMAT_V2 { + if format.format_version != HANDOFF_FORMAT_V3 { return Err(BootstrapError::UnsupportedInvitationFormat); } let owned: InvitationHandoffOwned = serde_json::from_slice(encoded).map_err(|_| BootstrapError::InvalidInvitationClaims)?; - if owned.format_version != HANDOFF_FORMAT_V2 { + if owned.format_version != HANDOFF_FORMAT_V3 { return Err(BootstrapError::UnsupportedInvitationFormat); } validate_public_claims(&owned.public_claims)?; @@ -444,13 +505,25 @@ impl InvitationStore { &self, specification: InvitationSpec, now_ms: i64, + ) -> Result { + self.create_with_ttl(specification, now_ms, DEFAULT_TTL_MS) + } + + pub fn create_with_ttl( + &self, + specification: InvitationSpec, + now_ms: i64, + ttl_ms: i64, ) -> Result { if now_ms <= 0 { return Err(BootstrapError::InvalidTimestamp); } + if !(MIN_TTL_MS..=MAX_TTL_MS).contains(&ttl_ms) { + return Err(BootstrapError::InvalidInvitationClaims); + } validate_specification(&specification)?; let expires_at_ms = now_ms - .checked_add(DEFAULT_TTL_MS) + .checked_add(ttl_ms) .ok_or(BootstrapError::InvalidInvitationClaims)?; let mut secret_bytes = Zeroizing::new([0u8; SECRET_BYTES]); getrandom::fill(&mut secret_bytes[..]).map_err(|_| BootstrapError::SecretUnavailable)?; @@ -466,6 +539,8 @@ impl InvitationStore { domain_id: specification.domain_id, authority_endpoint: specification.authority_endpoint, directory_seeds: specification.directory_seeds, + network_kind: specification.network_kind, + allowed_cidrs: specification.allowed_cidrs, root_sha256: specification.root_sha256, tls_ca_sha256: specification.tls_ca_sha256, allowed_profile: specification.allowed_profile, @@ -999,7 +1074,7 @@ pub(crate) fn public_claims_sha256_for_enrollment( fn encode_public_claims(claims: &InvitationPublicClaims) -> Result, BootstrapError> { validate_public_claims(claims)?; let mut encoded = Vec::with_capacity(512); - encoded.extend_from_slice(b"AGENET\0invitation-public-claims-v1\0"); + encoded.extend_from_slice(b"AGENET\0invitation-public-claims-v2\0"); append_claim_field(&mut encoded, claims.protocol_version.as_bytes())?; append_claim_field(&mut encoded, claims.domain_id.as_str().as_bytes())?; append_claim_field(&mut encoded, claims.authority_endpoint.as_str().as_bytes())?; @@ -1014,6 +1089,17 @@ fn encode_public_claims(claims: &InvitationPublicClaims) -> Result, Boot append_claim_field(&mut directories, directory.as_str().as_bytes())?; } append_claim_field(&mut encoded, &directories)?; + append_claim_field(&mut encoded, &[overlay_discriminant(claims.network_kind)])?; + let mut networks = Vec::new(); + networks.extend_from_slice( + &u32::try_from(claims.allowed_cidrs.len()) + .map_err(|_| BootstrapError::ResourceLimitExceeded)? + .to_be_bytes(), + ); + for network in &claims.allowed_cidrs { + append_claim_field(&mut networks, network.to_string().as_bytes())?; + } + append_claim_field(&mut encoded, &networks)?; append_claim_field(&mut encoded, claims.root_sha256.as_bytes())?; append_claim_field(&mut encoded, claims.tls_ca_sha256.as_bytes())?; append_claim_field( @@ -1052,6 +1138,14 @@ fn bootstrap_profile_discriminant(profile: BootstrapProfile) -> u8 { } } +fn overlay_discriminant(kind: OverlayKind) -> u8 { + match kind { + OverlayKind::Loopback => 0, + OverlayKind::Tailscale => 1, + OverlayKind::WireGuard => 2, + } +} + fn compute_claims_integrity( secret: &[u8; SECRET_BYTES], public_claims_sha256: &[u8; 32], @@ -1138,6 +1232,14 @@ fn validate_specification(specification: &InvitationSpec) -> Result<(), Bootstra || !valid_private_endpoint_shape(&specification.authority_endpoint) || specification.directory_seeds.is_empty() || specification.directory_seeds.len() > MAX_DIRECTORY_SEEDS + || specification.allowed_cidrs.is_empty() + || specification.allowed_cidrs.len() > 16 + || specification + .allowed_cidrs + .iter() + .collect::>() + .len() + != specification.allowed_cidrs.len() || specification .directory_seeds .iter() @@ -1148,6 +1250,26 @@ fn validate_specification(specification: &InvitationSpec) -> Result<(), Bootstra { return Err(BootstrapError::InvalidInvitationClaims); } + let authority_ip = specification + .authority_endpoint + .host_str() + .and_then(parse_ip_host) + .ok_or(BootstrapError::InvalidInvitationClaims)?; + let boundary = super::network::NetworkBoundary { + kind: specification.network_kind, + bind_ip: authority_ip, + allowed_cidrs: specification.allowed_cidrs.clone(), + }; + boundary + .validate_bind_shape() + .map_err(|_| BootstrapError::InvalidInvitationClaims)?; + if specification.directory_seeds.iter().any(|seed| { + seed.host_str() + .and_then(parse_ip_host) + .is_none_or(|address| !boundary.allows_peer(address)) + }) { + return Err(BootstrapError::InvalidInvitationClaims); + } Ok(()) } @@ -1157,6 +1279,8 @@ fn validate_public_claims(claims: &InvitationPublicClaims) -> Result<(), Bootstr domain_id: claims.domain_id.clone(), authority_endpoint: claims.authority_endpoint.clone(), directory_seeds: claims.directory_seeds.clone(), + network_kind: claims.network_kind, + allowed_cidrs: claims.allowed_cidrs.clone(), root_sha256: claims.root_sha256.clone(), tls_ca_sha256: claims.tls_ca_sha256.clone(), allowed_profile: claims.allowed_profile, @@ -1409,12 +1533,14 @@ mod tests { fn handoff(invitation_id: Uuid, domain: &str) -> InvitationHandoff { let public_claims = InvitationPublicClaims { - protocol_version: "agenet.enrollment.v0.2".to_owned(), + protocol_version: "agenet.enrollment.v0.3".to_owned(), domain_id: DomainId::new(domain).expect("test domain is valid"), authority_endpoint: Url::parse("https://100.64.0.1:7443/").expect("test URL is valid"), directory_seeds: vec![ Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), ], + network_kind: OverlayKind::Tailscale, + allowed_cidrs: vec!["100.64.0.0/10".parse().expect("CIDR")], root_sha256: "11".repeat(32), tls_ca_sha256: "22".repeat(32), allowed_profile: BootstrapProfile::Base, @@ -1475,12 +1601,14 @@ mod tests { .expect("state directory is owner-only"); let store = InvitationStore::open(temp.path()).expect("store opens"); let specification = || InvitationSpec { - protocol_version: "agenet.enrollment.v0.2".to_owned(), + protocol_version: "agenet.enrollment.v0.3".to_owned(), domain_id: DomainId::new("domain:secret-proof").expect("test domain is valid"), authority_endpoint: Url::parse("https://100.64.0.1:7443/").expect("test URL is valid"), directory_seeds: vec![ Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), ], + network_kind: OverlayKind::Tailscale, + allowed_cidrs: vec!["100.64.0.0/10".parse().expect("CIDR")], root_sha256: "11".repeat(32), tls_ca_sha256: "22".repeat(32), allowed_profile: BootstrapProfile::Base, @@ -1601,7 +1729,7 @@ mod tests { let encoded = encode_handoff(&handoff).expect("handoff encodes"); let format: InvitationHandoffFormatProbe = serde_json::from_slice(&encoded).expect("format probe parses"); - assert_eq!(format.format_version, HANDOFF_FORMAT_V2); + assert_eq!(format.format_version, HANDOFF_FORMAT_V3); let decoded = decode_handoff(&encoded).expect("handoff decodes"); assert_eq!(decoded.public_claims, handoff.public_claims); assert!( @@ -1616,6 +1744,20 @@ mod tests { .len(), 32 ); + assert_eq!(encoded.capacity(), MAX_HANDOFF_BYTES); + } + + #[test] + fn handoff_codec_rejects_before_growth_and_keeps_one_allocation() { + let handoff = handoff(Uuid::from_u128(101), "domain:fixed-capacity"); + let encoded = + encode_handoff_with_limit(&handoff, MAX_HANDOFF_BYTES).expect("legal handoff encodes"); + assert_eq!(encoded.capacity(), MAX_HANDOFF_BYTES); + assert!(matches!( + encode_handoff_with_limit(&handoff, 64), + Err(BootstrapError::ResourceLimitExceeded) + )); + assert!(!format!("{:?}", handoff).contains("test-secret")); } #[test] @@ -1626,7 +1768,8 @@ mod tests { serde_json::from_slice(&encoded).expect("handoff JSON parses"); for version in [ "agenet.invitation-handoff.v1", - "agenet.invitation-handoff.v3", + "agenet.invitation-handoff.v2", + "agenet.invitation-handoff.v4", ] { let mut unsupported = original.clone(); unsupported["format_version"] = serde_json::json!(version); @@ -1669,7 +1812,7 @@ mod tests { let mutations: &[(&str, serde_json::Value)] = &[ ( "protocol_version", - serde_json::json!("agenet.enrollment.v0.3"), + serde_json::json!("agenet.enrollment.v0.4"), ), ("domain_id", serde_json::json!("domain:attacker")), ( @@ -1680,6 +1823,8 @@ mod tests { "directory_seeds", serde_json::json!(["https://100.64.0.99:7444/"]), ), + ("network_kind", serde_json::json!("wire_guard")), + ("allowed_cidrs", serde_json::json!(["100.64.0.0/11"])), ("root_sha256", serde_json::json!("33".repeat(32))), ("tls_ca_sha256", serde_json::json!("44".repeat(32))), ("allowed_profile", serde_json::json!("provider")), diff --git a/src/bootstrap/journal.rs b/src/bootstrap/journal.rs index 9fb9342..bb36ccd 100644 --- a/src/bootstrap/journal.rs +++ b/src/bootstrap/journal.rs @@ -12,7 +12,7 @@ use sha2::{Digest, Sha256}; use super::BootstrapError; const HEADER_PREFIX: &[u8] = b"AGENET-INVITATION-JOURNAL\0"; -const HEADER_V2: &[u8] = b"AGENET-INVITATION-JOURNAL\0\x02"; +const HEADER_V3: &[u8] = b"AGENET-INVITATION-JOURNAL\0\x03"; const CHECKSUM_BYTES: usize = 32; const MAX_RECORD_BYTES: usize = 64 * 1024; const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024; @@ -33,7 +33,7 @@ where let metadata = file.metadata().map_err(|_| BootstrapError::StorageFailed)?; require_owner_only_regular(&metadata)?; if created { - file.write_all(HEADER_V2) + file.write_all(HEADER_V3) .map_err(|_| BootstrapError::StorageFailed)?; persist(&mut file)?; sync_parent(path)?; @@ -129,13 +129,13 @@ fn open_new(path: &Path) -> Result { } fn decode_entries(bytes: &[u8]) -> Result, BootstrapError> { - if bytes.starts_with(HEADER_PREFIX) && !bytes.starts_with(HEADER_V2) { + if bytes.starts_with(HEADER_PREFIX) && !bytes.starts_with(HEADER_V3) { return Err(BootstrapError::UnsupportedInvitationFormat); } - if !bytes.starts_with(HEADER_V2) { + if !bytes.starts_with(HEADER_V3) { return Err(BootstrapError::InvalidJournal); } - let mut cursor = HEADER_V2.len(); + let mut cursor = HEADER_V3.len(); let mut entries = Vec::new(); while cursor < bytes.len() { if entries.len() >= MAX_RECORDS || bytes.len() - cursor < 4 { diff --git a/src/bootstrap/paths.rs b/src/bootstrap/paths.rs index 42c7cc4..f443095 100644 --- a/src/bootstrap/paths.rs +++ b/src/bootstrap/paths.rs @@ -52,6 +52,14 @@ pub struct NodePaths { pub journal_file: PathBuf, pub lock_file: PathBuf, pub service_definition: PathBuf, + pub root_keystore_file: PathBuf, + pub root_public_key_file: PathBuf, + pub authority_credential_file: PathBuf, + pub authority_signing_key_file: PathBuf, + pub authority_ca_private_key_file: PathBuf, + pub authority_ca_certificate_file: PathBuf, + pub invitation_state_dir: PathBuf, + pub pending_join_file: PathBuf, } impl NodePaths { @@ -121,6 +129,14 @@ impl NodePaths { revocation_file: state_dir.join("revocation-cache-v1.json"), journal_file: state_dir.join("bootstrap-state-v1.jsonl"), lock_file: state_dir.join("bootstrap-state-v1.lock"), + root_keystore_file: config_dir.join("domain-root-v2.age"), + root_public_key_file: config_dir.join("domain-root-public-v1.key"), + authority_credential_file: state_dir.join("authority-credential-v1.json"), + authority_signing_key_file: state_dir.join("authority-signing-key-v1.key"), + authority_ca_private_key_file: state_dir.join("authority-ca-private-v1.pem"), + authority_ca_certificate_file: state_dir.join("authority-ca-certificate-v1.pem"), + invitation_state_dir: state_dir.join("invitations"), + pending_join_file: state_dir.join("pending-join-v1.json"), config_dir, state_dir, service_definition, diff --git a/src/bootstrap/pki.rs b/src/bootstrap/pki.rs index 8b5fd80..559ad1f 100644 --- a/src/bootstrap/pki.rs +++ b/src/bootstrap/pki.rs @@ -9,12 +9,14 @@ use std::sync::{Arc, atomic::AtomicUsize}; use base64::{Engine, engine::general_purpose::STANDARD}; use rcgen::{ - BasicConstraints, CertificateParams, CertificateSigningRequestParams, CertifiedIssuer, - CustomExtension, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, - PublicKeyData, SanType, SignatureAlgorithm, SigningKey, + BasicConstraints, CertificateParams, CertificateSigningRequestParams, CustomExtension, DnType, + ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose, PublicKeyData, SanType, + SignatureAlgorithm, SigningKey, }; +use rustls::pki_types::CertificateDer; use sha2::{Digest, Sha256}; use time::OffsetDateTime; +use x509_parser::prelude::FromDer; use zeroize::{Zeroize, Zeroizing}; use crate::{protocol::NodeId, runtime::key_store::atomic_write_owner_only}; @@ -31,7 +33,7 @@ pub struct AuthorityPki { pub ca_cert_pem: Zeroizing, pub ca_key_pem: Zeroizing, pub fingerprint_sha256: String, - issuer: CertifiedIssuer<'static, ZeroizingKeyPair>, + issuer: Issuer<'static, ZeroizingKeyPair>, not_before_ms: i64, not_after_ms: i64, } @@ -65,6 +67,15 @@ impl ZeroizingKeyPair { pem } + fn from_pem(pem: &str) -> Result { + Ok(Self { + inner: KeyPair::from_pem(pem)?, + zeroized: false, + #[cfg(test)] + zeroize_counter: None, + }) + } + #[cfg(test)] fn generate_with_counter(counter: Arc) -> Result { let mut key = Self::generate()?; @@ -182,10 +193,12 @@ impl AuthorityPki { params .distinguished_name .push(DnType::CommonName, "AgenNet Internal Authority CA"); - let issuer = - CertifiedIssuer::self_signed(params, key).map_err(|_| BootstrapError::InvalidPki)?; - let ca_cert_pem = Zeroizing::new(issuer.pem()); - let fingerprint_sha256 = Self::fingerprint_der(issuer.der()); + let certificate = params + .self_signed(&key) + .map_err(|_| BootstrapError::InvalidPki)?; + let ca_cert_pem = Zeroizing::new(certificate.pem()); + let fingerprint_sha256 = Self::fingerprint_der(certificate.der()); + let issuer = Issuer::new(params, key); Ok(Self { ca_cert_pem, ca_key_pem, @@ -196,6 +209,55 @@ impl AuthorityPki { }) } + pub fn load(ca_cert_pem: &str, ca_key_pem: &str) -> Result { + let key = ZeroizingKeyPair::from_pem(ca_key_pem).map_err(|_| BootstrapError::InvalidPki)?; + let certificate = pem::parse(ca_cert_pem).map_err(|_| BootstrapError::InvalidPki)?; + if certificate.tag() != "CERTIFICATE" { + return Err(BootstrapError::InvalidPki); + } + let der = CertificateDer::from(certificate.contents().to_vec()); + let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(der.as_ref()) + .map_err(|_| BootstrapError::InvalidPki)?; + let basic = parsed + .basic_constraints() + .map_err(|_| BootstrapError::InvalidPki)? + .ok_or(BootstrapError::InvalidPki)?; + let usage = parsed + .key_usage() + .map_err(|_| BootstrapError::InvalidPki)? + .ok_or(BootstrapError::InvalidPki)?; + if !basic.value.ca + || basic.value.path_len_constraint != Some(0) + || !usage.value.key_cert_sign() + || !usage.value.crl_sign() + || parsed.public_key().subject_public_key.data.as_ref() != key.der_bytes() + || parsed.verify_signature(None).is_err() + { + return Err(BootstrapError::InvalidPki); + } + let issuer = Issuer::from_ca_cert_der(&der, key).map_err(|_| BootstrapError::InvalidPki)?; + let not_before_ms = parsed + .validity() + .not_before + .timestamp() + .checked_mul(1_000) + .ok_or(BootstrapError::InvalidPki)?; + let not_after_ms = parsed + .validity() + .not_after + .timestamp() + .checked_mul(1_000) + .ok_or(BootstrapError::InvalidPki)?; + Ok(Self { + ca_cert_pem: Zeroizing::new(ca_cert_pem.to_owned()), + ca_key_pem: Zeroizing::new(ca_key_pem.to_owned()), + fingerprint_sha256: Self::fingerprint_der(&der), + issuer, + not_before_ms, + not_after_ms, + }) + } + pub fn fingerprint_der(der: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let digest = Sha256::digest(der); @@ -389,7 +451,7 @@ mod tests { use zeroize::Zeroize; - use super::ZeroizingKeyPair; + use super::{AuthorityPki, ZeroizingKeyPair}; #[test] fn zeroizing_key_pair_drop_invokes_zeroize_once() { @@ -413,4 +475,21 @@ mod tests { drop(key); assert_eq!(counter.load(Ordering::SeqCst), 1); } + + #[test] + fn persisted_authority_pki_reloads_without_rotating_ca() { + let generated = + AuthorityPki::generate(2_000_000_000_000, 2_100_000_000_000).expect("generate CA"); + let fingerprint = generated.fingerprint_sha256.clone(); + let certificate = generated.ca_cert_pem.to_string(); + let key = generated.ca_key_pem.to_string(); + drop(generated); + let loaded = AuthorityPki::load(&certificate, &key).expect("reload CA"); + assert_eq!(loaded.fingerprint_sha256, fingerprint); + assert_eq!(loaded.ca_cert_pem.as_str(), certificate); + let wrong_key = ZeroizingKeyPair::generate() + .expect("second key") + .private_key_pem(); + assert!(AuthorityPki::load(&certificate, &wrong_key).is_err()); + } } diff --git a/src/cli/domain.rs b/src/cli/domain.rs new file mode 100644 index 0000000..43b63fc --- /dev/null +++ b/src/cli/domain.rs @@ -0,0 +1,444 @@ +use std::{ + collections::BTreeSet, + net::IpAddr, + time::{SystemTime, UNIX_EPOCH}, +}; + +use age::secrecy::{ExposeSecret, SecretString}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use clap::{Args, Subcommand, ValueEnum}; +use ed25519_dalek::SigningKey; +use ipnet::IpNet; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use url::Url; +use uuid::Uuid; + +use crate::{ + bootstrap::{ + AgeRootKeystore, AuthorityPki, BootstrapPhase, BootstrapStateStore, BootstrapTransition, + DomainRootMaterial, NodeConfigV1, NodePaths, NodeTlsCsr, RootKeystore, + network::{NetworkBoundary, OverlayKind}, + }, + protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, CredentialChain, DomainId, + NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, + }, + runtime::key_store::{atomic_write_owner_only_strict, write_signing_key_strict}, + transport::PeerTlsIdentity, +}; + +use super::{ + SecretTerminal, + output::{self, CliError, OutputFormat}, +}; + +const AUTHORITY_PORT: u16 = 7443; +const DIRECTORY_PORT: u16 = 7444; +const REVOCATION_PORT: u16 = 7445; + +#[derive(Debug, Args)] +pub struct DomainArgs { + #[command(subcommand)] + command: DomainCommand, +} + +#[derive(Debug, Subcommand)] +enum DomainCommand { + Init(InitArgs), +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum NetworkChoice { + Tailscale, + Wireguard, +} + +#[derive(Debug, Args)] +struct InitArgs { + #[arg(long)] + network: NetworkChoice, + #[arg(long)] + bind_ip: IpAddr, + #[arg(long, required_if_eq("network", "wireguard"))] + allowed_cidr: Vec, + #[arg(long, value_enum, default_value = "human")] + output: OutputFormat, +} + +impl DomainArgs { + pub fn output(&self) -> OutputFormat { + match &self.command { + DomainCommand::Init(args) => args.output, + } + } +} + +#[derive(Serialize)] +struct DomainInitResult { + domain_id: String, + founding_node_id: String, + root_fingerprint_sha256: String, + authority_endpoint: String, + directory_endpoint: String, + phase: &'static str, + next_commands: [&'static str; 1], +} + +pub fn execute(args: DomainArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { + match args.command { + DomainCommand::Init(args) => init(args, terminal), + } +} + +fn init(args: InitArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { + if matches!(args.network, NetworkChoice::Tailscale) && !args.allowed_cidr.is_empty() { + return Err(CliError::new( + "InvalidArguments", + "--allowed-cidr is only valid for WireGuard.", + false, + )); + } + let allowed_cidrs = match args.network { + NetworkChoice::Tailscale => vec![match args.bind_ip { + IpAddr::V4(_) => "100.64.0.0/10".parse().map_err(|_| internal())?, + IpAddr::V6(_) => "fd7a:115c:a1e0::/48".parse().map_err(|_| internal())?, + }], + NetworkChoice::Wireguard => args.allowed_cidr, + }; + let boundary = NetworkBoundary { + kind: match args.network { + NetworkChoice::Tailscale => OverlayKind::Tailscale, + NetworkChoice::Wireguard => OverlayKind::WireGuard, + }, + bind_ip: args.bind_ip, + allowed_cidrs, + }; + boundary.validate_bind().map_err(|_| { + CliError::new( + "UnsupportedNetworkBoundary", + "The bind address is not assigned inside the selected private overlay.", + false, + ) + })?; + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + reject_existing_domain(&paths)?; + let passphrase = prompt_new_passphrase(terminal)?; + provision(paths, boundary, passphrase, args.output) +} + +fn reject_existing_domain(paths: &NodePaths) -> Result<(), CliError> { + let managed = [&paths.config_dir, &paths.state_dir]; + if managed + .iter() + .any(|path| std::fs::symlink_metadata(path).is_ok()) + { + return Err(CliError::new( + "DomainStateExists", + "Domain state already exists; no file was overwritten.", + false, + )); + } + Ok(()) +} + +fn prompt_new_passphrase(terminal: &impl SecretTerminal) -> Result { + let first = terminal + .prompt_hidden("New AgenNet Domain Root passphrase: ") + .map_err(map_bootstrap)?; + let second = terminal + .prompt_hidden("Confirm AgenNet Domain Root passphrase: ") + .map_err(map_bootstrap)?; + if first.expose_secret().is_empty() || first.expose_secret() != second.expose_secret() { + return Err(CliError::new( + "PassphraseConfirmationFailed", + "Passphrases were empty or did not match.", + false, + )); + } + Ok(first) +} + +pub(super) fn provision( + paths: NodePaths, + boundary: NetworkBoundary, + passphrase: SecretString, + format: OutputFormat, +) -> Result<(), CliError> { + let now = now_ms()?; + let domain_id = DomainId::new(format!("domain:{}", Uuid::new_v4())).map_err(|_| internal())?; + let root = random_signing_key()?; + let authority_key = random_signing_key()?; + let founding_key = random_signing_key()?; + let pki = AuthorityPki::generate(now - 60_000, now + 365 * 24 * 60 * 60 * 1_000) + .map_err(map_bootstrap)?; + let authority_id = + NodeId::new(format!("authority:{}", Uuid::new_v4())).map_err(|_| internal())?; + let founding_node_id = + NodeId::new(format!("node:directory:{}", Uuid::new_v4())).map_err(|_| internal())?; + let authority = SignedAuthorityCredential::issue( + &root, + AuthorityClaims { + domain_id: domain_id.clone(), + authority_id: authority_id.clone(), + signing_public_key_base64: STANDARD.encode(authority_key.verifying_key().to_bytes()), + tls_ca_sha256: pki.fingerprint_sha256.clone(), + scopes: BTreeSet::from([ + AuthorityScope::IssueNodeCredential, + AuthorityScope::IssueFoundingDirectoryCredential, + AuthorityScope::PublishRevocationSnapshot, + ]), + allowed_profiles: BTreeSet::from([ + BootstrapProfile::Base, + BootstrapProfile::Provider, + BootstrapProfile::AgentCandidate, + ]), + maximum_node_lifetime_ms: 30 * 24 * 60 * 60 * 1_000, + issued_at_ms: now, + expires_at_ms: now + 180 * 24 * 60 * 60 * 1_000, + }, + ) + .map_err(|_| internal())?; + let node = authority + .issue_founding_directory_credential( + &root.verifying_key(), + &authority_key, + NodeCredentialClaims { + domain_id: domain_id.clone(), + authority_id, + node_id: founding_node_id.clone(), + signing_public_key_base64: STANDARD.encode(founding_key.verifying_key().to_bytes()), + bootstrap_profile: BootstrapProfile::Base, + allowed_roles: BTreeSet::from([NodeRole::Directory]), + issued_at_ms: now, + expires_at_ms: now + 30 * 24 * 60 * 60 * 1_000, + }, + now, + ) + .map_err(|_| internal())?; + let csr = NodeTlsCsr::generate().map_err(map_bootstrap)?; + let certificate = pki + .issue_peer( + &csr.csr_pem, + &founding_node_id, + boundary.bind_ip, + now, + now + 30 * 24 * 60 * 60 * 1_000, + ) + .map_err(map_bootstrap)?; + let identity = PeerTlsIdentity { + node_id: founding_node_id.clone(), + certificate_chain_pem: certificate.cert_pem.into(), + private_key_pem: csr.private_key_pem, + authority_ca_pem: pki.ca_cert_pem.to_string(), + }; + let authority_endpoint = endpoint(boundary.bind_ip, AUTHORITY_PORT)?; + let directory_endpoint = endpoint(boundary.bind_ip, DIRECTORY_PORT)?; + let config = NodeConfigV1 { + format: "agenet.node-config".to_owned(), + schema_version: 1, + domain_id: domain_id.clone(), + profile: BootstrapProfile::Base, + network: boundary, + directory_seeds: vec![directory_endpoint.clone()], + authority_endpoint: authority_endpoint.clone(), + revocation_endpoint: endpoint(config_ip(&authority_endpoint)?, REVOCATION_PORT)?, + }; + paths.ensure_secure_layout().map_err(map_bootstrap)?; + AgeRootKeystore::create( + &paths.root_keystore_file, + &DomainRootMaterial { + domain_id: domain_id.clone(), + signing_key: root.clone(), + created_at_ms: now, + }, + passphrase, + ) + .map_err(map_bootstrap)?; + persist_public_and_authority(&paths, &root, &authority_key, &authority, &pki)?; + paths.write_config(&config).map_err(map_bootstrap)?; + paths + .write_startup_material( + &CredentialChain { authority, node }, + &founding_key, + &identity, + ) + .map_err(map_bootstrap)?; + let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; + for (id, phase) in [ + ("domain-binary-v1", BootstrapPhase::BinaryInstalled), + ("domain-service-v1", BootstrapPhase::ServicePrepared), + ("domain-enrollment-v1", BootstrapPhase::ReadyForEnrollment), + ("domain-credential-v1", BootstrapPhase::CredentialIssued), + ] { + state + .apply(id, BootstrapTransition::Advance(phase)) + .map_err(map_bootstrap)?; + } + let fingerprint = hex(Sha256::digest(root.verifying_key().as_bytes()).as_slice()); + let result = DomainInitResult { + domain_id: domain_id.as_str().to_owned(), + founding_node_id: founding_node_id.as_str().to_owned(), + root_fingerprint_sha256: fingerprint, + authority_endpoint: authority_endpoint.to_string(), + directory_endpoint: directory_endpoint.to_string(), + phase: "credential_issued", + next_commands: ["agenet node start"], + }; + output::emit( + format, + &format!( + "AgenNet Domain {} created. Next: agenet node start", + result.domain_id + ), + &result, + ) +} + +fn persist_public_and_authority( + paths: &NodePaths, + root: &SigningKey, + authority_key: &SigningKey, + authority: &SignedAuthorityCredential, + pki: &AuthorityPki, +) -> Result<(), CliError> { + let root_public = format!("{}\n", STANDARD.encode(root.verifying_key().to_bytes())); + atomic_write_owner_only_strict(&paths.root_public_key_file, root_public.as_bytes(), false) + .map_err(|_| persistence())?; + write_signing_key_strict(&paths.authority_signing_key_file, authority_key) + .map_err(|_| persistence())?; + let mut credential = serde_json::to_vec(authority).map_err(|_| internal())?; + credential.push(b'\n'); + atomic_write_owner_only_strict(&paths.authority_credential_file, &credential, false) + .map_err(|_| persistence())?; + atomic_write_owner_only_strict( + &paths.authority_ca_private_key_file, + pki.ca_key_pem.as_bytes(), + false, + ) + .map_err(|_| persistence())?; + atomic_write_owner_only_strict( + &paths.authority_ca_certificate_file, + pki.ca_cert_pem.as_bytes(), + false, + ) + .map_err(|_| persistence()) +} + +fn endpoint(ip: IpAddr, port: u16) -> Result { + Url::parse(&format!("https://{}/", std::net::SocketAddr::new(ip, port))).map_err(|_| internal()) +} +fn config_ip(url: &Url) -> Result { + url.host_str() + .and_then(|v| v.trim_matches(['[', ']']).parse().ok()) + .ok_or_else(internal) +} +fn random_signing_key() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).map_err(|_| internal())?; + Ok(SigningKey::from_bytes(&bytes)) +} +fn now_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|v| i64::try_from(v.as_millis()).ok()) + .filter(|v| *v > 0) + .ok_or_else(internal) +} +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} +fn internal() -> CliError { + CliError::new( + "BootstrapFailed", + "Domain bootstrap failed without exposing sensitive details.", + false, + ) +} +fn persistence() -> CliError { + CliError::new( + "PersistenceUnavailable", + "Domain state could not be persisted safely.", + true, + ) +} +fn map_bootstrap(error: crate::bootstrap::BootstrapError) -> CliError { + match error { + crate::bootstrap::BootstrapError::HandoffTtyUnavailable => CliError::new( + "HandoffTtyUnavailable", + "A controlling terminal is required.", + false, + ), + crate::bootstrap::BootstrapError::PassphraseUnavailable => CliError::new( + "PassphraseTtyUnavailable", + "A controlling terminal is required.", + false, + ), + crate::bootstrap::BootstrapError::UnsafeStatePath => { + CliError::new("UnsafeStatePath", "The user state path is unsafe.", false) + } + _ => internal(), + } +} + +#[cfg(test)] +mod tests { + use age::secrecy::SecretString; + use tempfile::TempDir; + + use crate::{ + bootstrap::{ + AgeRootKeystore, BootstrapPhase, BootstrapStateStore, NodePathEnvironment, NodePaths, + RootKeystore, UserPlatform, load_startup_bundle, + }, + protocol::NodeRole, + }; + + use super::*; + + #[test] + fn provisioned_domain_reloads_root_authority_and_founding_peer() { + let home = TempDir::new().expect("temporary home"); + let canonical_home = home.path().canonicalize().expect("canonical home"); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(canonical_home, None, None), + ) + .expect("paths"); + provision( + paths.clone(), + NetworkBoundary::loopback_ipv4(), + SecretString::from("test-only-passphrase".to_owned()), + OutputFormat::Human, + ) + .expect("Domain provisions"); + let root = AgeRootKeystore::unlock( + &paths.root_keystore_file, + SecretString::from("test-only-passphrase".to_owned()), + ) + .expect("Root unlocks"); + load_startup_bundle( + &paths, + &root.signing_key.verifying_key(), + NodeRole::Directory, + now_ms().expect("clock"), + ) + .expect("founding peer reloads"); + let ca = paths + .read_material(&paths.authority_ca_certificate_file, 64 * 1024) + .expect("CA cert"); + let key = paths + .read_material(&paths.authority_ca_private_key_file, 16 * 1024) + .expect("CA key"); + let ca = std::str::from_utf8(&ca).expect("CA UTF-8"); + let key = std::str::from_utf8(&key).expect("key UTF-8"); + AuthorityPki::load(ca, key).expect("Authority PKI reloads"); + assert_eq!( + BootstrapStateStore::open(&paths.journal_file) + .expect("state") + .phase(), + BootstrapPhase::CredentialIssued + ); + } +} diff --git a/src/cli/invite.rs b/src/cli/invite.rs new file mode 100644 index 0000000..1d679cf --- /dev/null +++ b/src/cli/invite.rs @@ -0,0 +1,283 @@ +use std::{ + collections::BTreeSet, + time::{SystemTime, UNIX_EPOCH}, +}; + +use clap::{Args, Subcommand, ValueEnum}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::{ + bootstrap::{ + AgeRootKeystore, InvitationSpec, InvitationStore, NodePaths, RootKeystore, + load_startup_bundle, + }, + protocol::{BootstrapProfile, CapabilityKind, NodeRole}, +}; + +use super::{ + SecretTerminal, + output::{self, CliError, OutputFormat}, +}; + +#[derive(Debug, Args)] +pub struct InviteArgs { + #[command(subcommand)] + command: InviteCommand, +} + +#[derive(Debug, Subcommand)] +enum InviteCommand { + Create(CreateArgs), +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum ProfileChoice { + Base, + Provider, + AgentCandidate, +} + +#[derive(Debug, Args)] +struct CreateArgs { + #[arg(long, value_enum)] + profile: ProfileChoice, + #[arg(long, default_value = "10m", value_parser = parse_ttl)] + ttl: i64, + #[arg(long, value_enum, default_value = "human")] + output: OutputFormat, +} + +impl InviteArgs { + pub fn output(&self) -> OutputFormat { + match &self.command { + InviteCommand::Create(args) => args.output, + } + } +} + +#[derive(Serialize)] +struct InviteResult { + invitation_id: String, + allowed_profile: &'static str, + expires_at_ms: i64, + maximum_attempts: u8, +} + +pub fn execute(args: InviteArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { + match args.command { + InviteCommand::Create(args) => create(args, terminal), + } +} + +fn create(args: CreateArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + create_at(&paths, args, terminal) +} + +fn create_at( + paths: &NodePaths, + args: CreateArgs, + terminal: &impl SecretTerminal, +) -> Result<(), CliError> { + let root = AgeRootKeystore::unlock( + &paths.root_keystore_file, + terminal + .prompt_hidden("AgenNet Domain Root passphrase: ") + .map_err(map_bootstrap)?, + ) + .map_err(map_bootstrap)?; + let now = now_ms()?; + let bundle = load_startup_bundle( + paths, + &root.signing_key.verifying_key(), + NodeRole::Directory, + now, + ) + .map_err(map_bootstrap)?; + if bundle.config.domain_id != root.domain_id { + return Err(CliError::new( + "RootFingerprintMismatch", + "The unlocked Root does not own this Domain.", + false, + )); + } + let profile = match args.profile { + ProfileChoice::Base => BootstrapProfile::Base, + ProfileChoice::Provider => BootstrapProfile::Provider, + ProfileChoice::AgentCandidate => BootstrapProfile::AgentCandidate, + }; + let capabilities = match profile { + BootstrapProfile::Base => BTreeSet::new(), + BootstrapProfile::Provider | BootstrapProfile::AgentCandidate => { + BTreeSet::from([CapabilityKind::new("source.metrics.v1").map_err(|_| internal())?]) + } + }; + let root_fingerprint = + hex(Sha256::digest(root.signing_key.verifying_key().as_bytes()).as_slice()); + let authority = &bundle.credential.authority.claims; + let store = InvitationStore::open(&paths.invitation_state_dir).map_err(map_bootstrap)?; + let handoff = store + .create_with_ttl( + InvitationSpec { + protocol_version: "agenet.enrollment.v0.3".to_owned(), + domain_id: root.domain_id.clone(), + authority_endpoint: bundle.config.authority_endpoint.clone(), + directory_seeds: bundle.config.directory_seeds.clone(), + network_kind: bundle.config.network.kind, + allowed_cidrs: bundle.config.network.allowed_cidrs.clone(), + root_sha256: root_fingerprint, + tls_ca_sha256: authority.tls_ca_sha256.clone(), + allowed_profile: profile, + capability_ceiling: capabilities, + }, + now, + args.ttl, + ) + .map_err(map_bootstrap)?; + let result = InviteResult { + invitation_id: handoff.public_claims().invitation_id.to_string(), + allowed_profile: match profile { + BootstrapProfile::Base => "base", + BootstrapProfile::Provider => "provider", + BootstrapProfile::AgentCandidate => "agent-candidate", + }, + expires_at_ms: handoff.public_claims().expires_at_ms, + maximum_attempts: handoff.public_claims().maximum_attempts, + }; + terminal + .display_invitation(&handoff) + .map_err(map_bootstrap)?; + output::emit( + args.output, + &format!( + "Invitation {} created; its secret was written once to the controlling terminal.", + result.invitation_id + ), + &result, + ) +} + +fn parse_ttl(value: &str) -> Result { + let (number, multiplier) = if let Some(value) = value.strip_suffix('m') { + (value, 60_000_i64) + } else if let Some(value) = value.strip_suffix('h') { + (value, 3_600_000_i64) + } else { + return Err("TTL must use m or h (for example 10m)".to_owned()); + }; + let count = number + .parse::() + .map_err(|_| "TTL is invalid".to_owned())?; + count + .checked_mul(multiplier) + .filter(|ttl| (60_000..=86_400_000).contains(ttl)) + .ok_or_else(|| "TTL must be between 1m and 24h".to_owned()) +} + +fn now_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|v| i64::try_from(v.as_millis()).ok()) + .filter(|v| *v > 0) + .ok_or_else(internal) +} +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} +fn internal() -> CliError { + CliError::new( + "InviteCreateFailed", + "The invitation could not be created safely.", + false, + ) +} +fn map_bootstrap(error: crate::bootstrap::BootstrapError) -> CliError { + match error { + crate::bootstrap::BootstrapError::HandoffTtyUnavailable => CliError::new( + "HandoffTtyUnavailable", + "A controlling terminal is required.", + false, + ), + crate::bootstrap::BootstrapError::PassphraseUnavailable => CliError::new( + "PassphraseTtyUnavailable", + "A controlling terminal is required.", + false, + ), + crate::bootstrap::BootstrapError::InvalidKeystore => CliError::new( + "RootUnlockFailed", + "The Root keystore could not be unlocked.", + false, + ), + _ => internal(), + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use age::secrecy::SecretString; + use tempfile::TempDir; + + use crate::bootstrap::{ + BootstrapError, InvitationHandoff, NodePathEnvironment, NodePaths, UserPlatform, + network::NetworkBoundary, + }; + + use super::*; + + struct RecordingTerminal { + displays: AtomicUsize, + } + + impl SecretTerminal for RecordingTerminal { + fn prompt_hidden(&self, _: &str) -> Result { + Ok(SecretString::from("test-only-passphrase".to_owned())) + } + fn read_invitation(&self) -> Result { + Err(BootstrapError::HandoffTtyUnavailable) + } + fn display_invitation(&self, handoff: &InvitationHandoff) -> Result<(), BootstrapError> { + assert!(format!("{handoff:?}").contains("[REDACTED]")); + self.displays.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + #[test] + fn invite_creation_unlocks_root_and_displays_secret_once() { + let home = TempDir::new().expect("home"); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new( + home.path().canonicalize().expect("canonical home"), + None, + None, + ), + ) + .expect("paths"); + super::super::domain::provision( + paths.clone(), + NetworkBoundary::loopback_ipv4(), + SecretString::from("test-only-passphrase".to_owned()), + OutputFormat::Json, + ) + .expect("Domain provisions"); + let terminal = RecordingTerminal { + displays: AtomicUsize::new(0), + }; + create_at( + &paths, + CreateArgs { + profile: ProfileChoice::Provider, + ttl: 600_000, + output: OutputFormat::Json, + }, + &terminal, + ) + .expect("invitation creates"); + assert_eq!(terminal.displays.load(Ordering::SeqCst), 1); + } +} diff --git a/src/cli/join.rs b/src/cli/join.rs new file mode 100644 index 0000000..acb693a --- /dev/null +++ b/src/cli/join.rs @@ -0,0 +1,623 @@ +use std::{ + io::Read, + net::IpAddr, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use clap::Args; +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::{ + bootstrap::{ + BootstrapPhase, BootstrapStateStore, BootstrapTransition, EnrollmentAttempt, NodeConfigV1, + NodePaths, NodeTlsCsr, load_startup_bundle, + }, + protocol::{BootstrapProfile, NodeId, NodeRole}, + runtime::key_store::{ + atomic_write_owner_only_strict, read_signing_key_hardened, write_signing_key_strict, + }, + transport::{EnrollmentClient, PeerTlsIdentity}, +}; + +use super::{ + SecretTerminal, + output::{self, CliError, OutputFormat}, +}; + +#[derive(Debug, Args)] +pub struct JoinArgs { + #[arg(long)] + pub bind_ip: Option, + #[arg(long, value_enum, default_value = "human")] + pub output: OutputFormat, +} + +#[derive(Debug, Serialize)] +struct JoinResult { + node_id: String, + domain_id: String, + phase: &'static str, + operation_id: String, + next_command: &'static str, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PendingJoin { + format: String, + invitation_id: Uuid, + operation_id: Uuid, + node_id: NodeId, + profile: BootstrapProfile, + bind_ip: IpAddr, + tls_csr_pem: String, +} + +pub async fn execute(args: JoinArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + execute_at(&paths, args, terminal).await +} + +async fn execute_at( + paths: &NodePaths, + args: JoinArgs, + terminal: &impl SecretTerminal, +) -> Result<(), CliError> { + let handoff = terminal.read_invitation().map_err(map_bootstrap)?; + let bind_ip = resolve_bind_ip( + args.bind_ip, + handoff.public_claims(), + &DirectBindAddressResolver, + )?; + let policy = crate::bootstrap::network::NetworkBoundary { + kind: handoff.public_claims().network_kind, + bind_ip, + allowed_cidrs: handoff.public_claims().allowed_cidrs.clone(), + }; + policy.validate_bind().map_err(|_| { + CliError::new( + "UnsupportedNetworkBoundary", + "The requested bind address is not assigned inside the invitation policy.", + false, + ) + })?; + paths.ensure_secure_layout().map_err(map_bootstrap)?; + let (pending, signing_key, tls_private_key) = load_or_create_pending(paths, &handoff, bind_ip)?; + let attempt = EnrollmentAttempt::sign( + &handoff, + pending.operation_id, + pending.node_id.clone(), + pending.profile, + bind_ip, + &signing_key, + pending.tls_csr_pem.clone(), + ) + .map_err(|_| enrollment_error(pending.operation_id))?; + let client = EnrollmentClient::new( + &policy, + handoff.public_claims().authority_endpoint.clone(), + handoff.public_claims().tls_ca_sha256.clone(), + Duration::from_secs(5), + Duration::from_secs(30), + ) + .map_err(|_| authority_unavailable(pending.operation_id))?; + let bundle = client + .enroll(handoff, &attempt) + .await + .map_err(|_| authority_unavailable(pending.operation_id))?; + let root = decode_root(&bundle.root_public_key_base64)?; + let identity = PeerTlsIdentity { + node_id: pending.node_id.clone(), + certificate_chain_pem: bundle.tls_client_certificate_pem.clone().into(), + private_key_pem: tls_private_key, + authority_ca_pem: bundle.tls_ca_certificate_pem.clone(), + }; + let mut revocation_endpoint = bundle.authority_endpoint.clone(); + revocation_endpoint + .set_port(Some(7445)) + .map_err(|_| internal())?; + let config = NodeConfigV1 { + format: "agenet.node-config".to_owned(), + schema_version: 1, + domain_id: bundle.domain_id.clone(), + profile: pending.profile, + network: policy, + directory_seeds: bundle.directory_seeds.clone(), + authority_endpoint: bundle.authority_endpoint.clone(), + revocation_endpoint, + }; + paths.write_config(&config).map_err(map_bootstrap)?; + paths + .write_startup_material(&bundle.credential_chain, &signing_key, &identity) + .map_err(map_bootstrap)?; + let root_public = format!("{}\n", STANDARD.encode(root.as_bytes())); + atomic_write_owner_only_strict(&paths.root_public_key_file, root_public.as_bytes(), true) + .map_err(|_| { + CliError::new( + "PersistenceUnavailable", + "Enrollment state could not be persisted safely.", + true, + ) + })?; + let expected_role = match pending.profile { + BootstrapProfile::Base | BootstrapProfile::AgentCandidate => NodeRole::Requester, + BootstrapProfile::Provider => NodeRole::Executor, + }; + load_startup_bundle(paths, &root, expected_role, now_ms()).map_err(map_bootstrap)?; + let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; + let transitions = [ + ( + BootstrapPhase::Absent, + "binary", + BootstrapPhase::BinaryInstalled, + ), + ( + BootstrapPhase::BinaryInstalled, + "service", + BootstrapPhase::ServicePrepared, + ), + ( + BootstrapPhase::ServicePrepared, + "ready", + BootstrapPhase::ReadyForEnrollment, + ), + ( + BootstrapPhase::ReadyForEnrollment, + "credential", + BootstrapPhase::CredentialIssued, + ), + ]; + for (from, suffix, phase) in transitions { + if state.phase() != from { + continue; + } + state + .apply( + &format!("join-{}-{suffix}", pending.operation_id), + BootstrapTransition::Advance(phase), + ) + .map_err(map_bootstrap)?; + } + if !matches!( + state.phase(), + BootstrapPhase::CredentialIssued | BootstrapPhase::Registered | BootstrapPhase::Healthy + ) { + return Err(internal()); + } + let phase = match state.phase() { + BootstrapPhase::CredentialIssued => "credential_issued", + BootstrapPhase::Registered => "registered", + BootstrapPhase::Healthy => "healthy", + _ => return Err(internal()), + }; + let result = JoinResult { + node_id: pending.node_id.as_str().to_owned(), + domain_id: bundle.domain_id.as_str().to_owned(), + phase, + operation_id: pending.operation_id.to_string(), + next_command: "agenet node start", + }; + output::emit( + args.output, + &format!("Node {} enrolled. Next: agenet node start", result.node_id), + &result, + ) +} + +fn resolve_bind_ip( + explicit: Option, + claims: &crate::bootstrap::InvitationPublicClaims, + resolver: &impl BindAddressResolver, +) -> Result { + if let Some(address) = explicit { + return Ok(address); + } + if claims.network_kind != crate::bootstrap::network::OverlayKind::Tailscale { + return Err(bind_required()); + } + let mut candidates = Vec::new(); + for family in ["-4", "-6"] { + if let Ok(addresses) = resolver.tailscale_addresses(family) { + candidates.extend(addresses.into_iter().filter(|address| { + claims + .allowed_cidrs + .iter() + .any(|cidr| cidr.contains(address)) + })); + } + } + candidates.sort(); + candidates.dedup(); + if candidates.len() == 1 { + Ok(candidates[0]) + } else { + Err(bind_required()) + } +} + +trait BindAddressResolver { + fn tailscale_addresses(&self, family: &str) -> Result, CliError>; +} + +struct DirectBindAddressResolver; + +impl BindAddressResolver for DirectBindAddressResolver { + fn tailscale_addresses(&self, family: &str) -> Result, CliError> { + tailscale_addresses(family) + } +} + +fn tailscale_addresses(family: &str) -> Result, CliError> { + let mut child = Command::new("tailscale") + .args(["ip", family]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| bind_required())?; + let deadline = Instant::now() + .checked_add(Duration::from_secs(2)) + .ok_or_else(bind_required)?; + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => break, + Ok(Some(_)) | Err(_) => return Err(bind_required()), + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return Err(bind_required()); + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + } + } + let mut output = Vec::new(); + child + .stdout + .take() + .ok_or_else(bind_required)? + .take(4097) + .read_to_end(&mut output) + .map_err(|_| bind_required())?; + if output.len() > 4096 { + return Err(bind_required()); + } + let text = std::str::from_utf8(&output).map_err(|_| bind_required())?; + text.lines() + .map(|line| line.trim().parse().map_err(|_| bind_required())) + .collect() +} + +fn bind_required() -> CliError { + CliError::new( + "BindAddressRequired", + "Specify --bind-ip; automatic Tailscale selection requires exactly one policy-matching address.", + false, + ) +} + +fn load_or_create_pending( + paths: &NodePaths, + handoff: &crate::bootstrap::InvitationHandoff, + bind_ip: IpAddr, +) -> Result<(PendingJoin, SigningKey, Zeroizing), CliError> { + if std::fs::symlink_metadata(&paths.pending_join_file).is_ok() { + let encoded = paths + .read_material(&paths.pending_join_file, 64 * 1024) + .map_err(map_bootstrap)?; + let pending: PendingJoin = serde_json::from_slice(&encoded).map_err(|_| partial())?; + if pending.format != "agenet.pending-join.v1" + || pending.invitation_id != handoff.public_claims().invitation_id + || pending.profile != handoff.public_claims().allowed_profile + || pending.bind_ip != bind_ip + { + return Err(partial()); + } + let signing = + read_signing_key_hardened(&paths.signing_private_key_file).map_err(|_| partial())?; + let tls = read_tls_key(paths)?; + return Ok((pending, signing, tls)); + } + if [&paths.signing_private_key_file, &paths.tls_private_key_file] + .iter() + .any(|path| std::fs::symlink_metadata(path).is_ok()) + { + return Err(partial()); + } + let signing = random_signing_key()?; + let csr = NodeTlsCsr::generate().map_err(map_bootstrap)?; + let pending = PendingJoin { + format: "agenet.pending-join.v1".to_owned(), + invitation_id: handoff.public_claims().invitation_id, + operation_id: Uuid::new_v4(), + node_id: NodeId::new(format!("node:{}", Uuid::new_v4())).map_err(|_| internal())?, + profile: handoff.public_claims().allowed_profile, + bind_ip, + tls_csr_pem: csr.csr_pem, + }; + write_signing_key_strict(&paths.signing_private_key_file, &signing).map_err(|_| partial())?; + atomic_write_owner_only_strict( + &paths.tls_private_key_file, + csr.private_key_pem.as_bytes(), + false, + ) + .map_err(|_| partial())?; + let mut encoded = serde_json::to_vec(&pending).map_err(|_| internal())?; + encoded.push(b'\n'); + atomic_write_owner_only_strict(&paths.pending_join_file, &encoded, false) + .map_err(|_| partial())?; + Ok((pending, signing, csr.private_key_pem)) +} + +fn read_tls_key(paths: &NodePaths) -> Result, CliError> { + let mut bytes = paths + .read_material(&paths.tls_private_key_file, 16 * 1024) + .map_err(map_bootstrap)?; + String::from_utf8(std::mem::take(&mut *bytes)) + .map(Zeroizing::new) + .map_err(|error| { + let _rejected = Zeroizing::new(error.into_bytes()); + partial() + }) +} +fn decode_root(encoded: &str) -> Result { + let bytes = STANDARD.decode(encoded).map_err(|_| internal())?; + VerifyingKey::from_bytes(&bytes.try_into().map_err(|_| internal())?).map_err(|_| internal()) +} +fn random_signing_key() -> Result { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).map_err(|_| internal())?; + Ok(SigningKey::from_bytes(&bytes)) +} +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|v| i64::try_from(v.as_millis()).ok()) + .unwrap_or(0) +} +fn partial() -> CliError { + CliError::new( + "PartialEnrollmentState", + "Partial enrollment state was retained; repair it explicitly before retrying.", + false, + ) +} +fn internal() -> CliError { + CliError::new( + "EnrollmentFailed", + "Enrollment failed without exposing sensitive details.", + false, + ) +} +fn enrollment_error(operation: Uuid) -> CliError { + internal().with_operation(operation.to_string()) +} +fn authority_unavailable(operation: Uuid) -> CliError { + CliError::new( + "AuthorityUnavailable", + "The Enrollment Authority is unavailable; retry with the same invitation.", + true, + ) + .with_operation(operation.to_string()) +} +fn map_bootstrap(error: crate::bootstrap::BootstrapError) -> CliError { + match error { + crate::bootstrap::BootstrapError::HandoffTtyUnavailable => CliError::new( + "HandoffTtyUnavailable", + "A controlling terminal is required.", + false, + ), + crate::bootstrap::BootstrapError::UnsupportedInvitationFormat => CliError::new( + "UnsupportedInvitationFormat", + "This invitation version is not supported.", + false, + ), + crate::bootstrap::BootstrapError::UnsafeStatePath => { + CliError::new("UnsafeStatePath", "The user state path is unsafe.", false) + } + _ => internal(), + } +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeSet, + net::{SocketAddr, TcpListener}, + sync::{Arc, Mutex}, + }; + + use age::secrecy::SecretString; + use base64::{Engine, engine::general_purpose::STANDARD}; + use sha2::{Digest, Sha256}; + use tempfile::TempDir; + + use crate::{ + bootstrap::{ + AuthorityPki, BootstrapError, EnrollmentAuthority, InvitationHandoff, InvitationSpec, + InvitationStore, NodePathEnvironment, NodePaths, UserPlatform, + }, + protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, DomainId, NodeId, + SignedAuthorityCredential, + }, + transport::{enrollment_router, enrollment_tls_config}, + }; + + use super::*; + + struct JoinTerminal(Mutex>); + impl SecretTerminal for JoinTerminal { + fn prompt_hidden(&self, _: &str) -> Result { + Err(BootstrapError::PassphraseUnavailable) + } + fn read_invitation(&self) -> Result { + self.0 + .lock() + .map_err(|_| BootstrapError::InvalidStatePath)? + .take() + .ok_or(BootstrapError::InvalidInvitation) + } + fn display_invitation(&self, _: &InvitationHandoff) -> Result<(), BootstrapError> { + Err(BootstrapError::HandoffTtyUnavailable) + } + } + + struct FakeResolver(Vec); + impl BindAddressResolver for FakeResolver { + fn tailscale_addresses(&self, _: &str) -> Result, CliError> { + Ok(self.0.clone()) + } + } + + #[test] + fn tailscale_auto_selects_exactly_one_policy_address_and_rejects_ambiguity() { + let claims = crate::bootstrap::InvitationPublicClaims { + protocol_version: "agenet.enrollment.v0.3".to_owned(), + domain_id: DomainId::new("domain:auto-bind").expect("domain"), + authority_endpoint: url::Url::parse("https://100.64.0.1:7443/").expect("URL"), + directory_seeds: vec![url::Url::parse("https://100.64.0.1:7444/").expect("URL")], + network_kind: crate::bootstrap::network::OverlayKind::Tailscale, + allowed_cidrs: vec!["100.64.0.0/10".parse().expect("CIDR")], + root_sha256: "11".repeat(32), + tls_ca_sha256: "22".repeat(32), + allowed_profile: BootstrapProfile::Base, + capability_ceiling: BTreeSet::new(), + invitation_id: Uuid::new_v4(), + expires_at_ms: now_ms() + 60_000, + maximum_attempts: 5, + }; + let one = FakeResolver(vec![ + "100.64.0.8".parse().expect("IP"), + "10.0.0.1".parse().expect("IP"), + ]); + assert_eq!( + resolve_bind_ip(None, &claims, &one).expect("unique"), + "100.64.0.8".parse::().expect("IP") + ); + let ambiguous = FakeResolver(vec![ + "100.64.0.8".parse().expect("IP"), + "100.64.0.9".parse().expect("IP"), + ]); + assert_eq!( + resolve_bind_ip(None, &claims, &ambiguous).unwrap_err().code, + "BindAddressRequired" + ); + } + + #[tokio::test] + async fn join_enrolls_persists_validates_and_stops_at_credential_issued() { + let now = now_ms() - now_ms().rem_euclid(1_000); + let root = SigningKey::from_bytes(&[71_u8; 32]); + let authority_key = SigningKey::from_bytes(&[72_u8; 32]); + let pki = AuthorityPki::generate(now - 60_000, now + 3_600_000).expect("CA"); + let port = TcpListener::bind(("127.0.0.1", 0)) + .expect("reserve") + .local_addr() + .expect("address") + .port(); + let endpoint = url::Url::parse(&format!("https://127.0.0.1:{port}/")).expect("endpoint"); + let domain = DomainId::new("domain:cli-join").expect("domain"); + let authority = SignedAuthorityCredential::issue( + &root, + AuthorityClaims { + domain_id: domain.clone(), + authority_id: NodeId::new("authority:cli-join").expect("id"), + signing_public_key_base64: STANDARD + .encode(authority_key.verifying_key().to_bytes()), + tls_ca_sha256: pki.fingerprint_sha256.clone(), + scopes: BTreeSet::from([AuthorityScope::IssueNodeCredential]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Provider]), + maximum_node_lifetime_ms: 1_800_000, + issued_at_ms: now - 60_000, + expires_at_ms: now + 3_600_000, + }, + ) + .expect("Authority credential"); + let authority_state = TempDir::new().expect("Authority state"); + let invitations = Arc::new( + InvitationStore::open(&authority_state.path().join("invitations")).expect("store"), + ); + let handoff = invitations + .create( + InvitationSpec { + protocol_version: "agenet.enrollment.v0.3".to_owned(), + domain_id: domain, + authority_endpoint: endpoint.clone(), + directory_seeds: vec![endpoint.clone()], + network_kind: crate::bootstrap::network::OverlayKind::Loopback, + allowed_cidrs: vec!["127.0.0.1/32".parse().expect("CIDR")], + root_sha256: Sha256::digest(root.verifying_key().as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + tls_ca_sha256: pki.fingerprint_sha256.clone(), + allowed_profile: BootstrapProfile::Provider, + capability_ceiling: BTreeSet::new(), + }, + now, + ) + .expect("handoff"); + let server_identity = pki + .issue_server( + "127.0.0.1".parse().expect("IP"), + now - 60_000, + now + 600_000, + ) + .expect("server identity"); + let tls = enrollment_tls_config(&server_identity, &pki.ca_cert_pem) + .await + .expect("TLS"); + let enrollment = Arc::new( + EnrollmentAuthority::open( + &authority_state.path().join("results"), + invitations, + root.verifying_key(), + authority, + authority_key, + pki, + endpoint, + ) + .expect("Authority"), + ); + let handle = axum_server::Handle::new(); + let server_handle = handle.clone(); + let task = tokio::spawn(async move { + axum_server::bind_rustls(SocketAddr::from(([127, 0, 0, 1], port)), tls) + .handle(server_handle) + .serve(enrollment_router(enrollment).into_make_service()) + .await + .expect("server"); + }); + handle.listening().await.expect("listener"); + let home = TempDir::new().expect("home"); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(home.path().canonicalize().expect("canonical"), None, None), + ) + .expect("paths"); + execute_at( + &paths, + JoinArgs { + bind_ip: Some("127.0.0.1".parse().expect("IP")), + output: OutputFormat::Json, + }, + &JoinTerminal(Mutex::new(Some(handoff))), + ) + .await + .expect("join"); + assert_eq!( + BootstrapStateStore::open(&paths.journal_file) + .expect("state") + .phase(), + BootstrapPhase::CredentialIssued + ); + handle.shutdown(); + task.await.expect("join server"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..618d2ce --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,169 @@ +mod domain; +mod invite; +mod join; +mod output; + +use crate::{bootstrap::network::NetworkBoundary, demo, node}; +use age::secrecy::SecretString; +use clap::{Args, Parser, Subcommand}; +pub use output::{CliError, OutputFormat}; + +trait SecretTerminal { + fn prompt_hidden(&self, prompt: &str) + -> Result; + fn read_invitation( + &self, + ) -> Result; + fn display_invitation( + &self, + handoff: &crate::bootstrap::InvitationHandoff, + ) -> Result<(), crate::bootstrap::BootstrapError>; +} + +struct ControllingTerminal; + +impl SecretTerminal for ControllingTerminal { + fn prompt_hidden( + &self, + prompt: &str, + ) -> Result { + crate::bootstrap::prompt_root_passphrase(prompt) + } + fn read_invitation( + &self, + ) -> Result { + crate::bootstrap::read_invitation_handoff_from_tty() + } + fn display_invitation( + &self, + handoff: &crate::bootstrap::InvitationHandoff, + ) -> Result<(), crate::bootstrap::BootstrapError> { + crate::bootstrap::display_invitation_handoff_to_tty(handoff) + } +} +use std::path::PathBuf; + +#[derive(Debug, Parser)] +#[command(name = "agenet", version, about)] +pub struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Domain(domain::DomainArgs), + Invite(invite::InviteArgs), + Node(NodeArgs), + Demo(DemoArgs), +} + +#[derive(Debug, Args)] +struct NodeArgs { + #[command(subcommand)] + bootstrap_command: Option, + #[arg(long)] + profile: Option, + #[arg(long)] + state_dir: Option, + #[arg(long)] + key_file: Option, + #[arg(long)] + credential_file: Option, + #[arg(long)] + root_public_key_file: Option, + #[arg(long)] + ready_file: Option, + #[arg(long)] + directory_seed: Option, + #[arg(long)] + control_token_file: Option, +} + +#[derive(Debug, Subcommand)] +enum NodeBootstrapCommand { + Join(join::JoinArgs), +} + +#[derive(Debug, Args)] +struct DemoArgs { + #[arg(long)] + env_file: PathBuf, + #[arg(long)] + artifact: PathBuf, + #[arg(long)] + state_dir: Option, +} + +pub async fn run() -> i32 { + run_cli(Cli::parse()).await +} + +async fn run_cli(cli: Cli) -> i32 { + let (format, result) = match cli.command { + Command::Domain(args) => { + let format = args.output(); + (format, domain::execute(args, &ControllingTerminal)) + } + Command::Invite(args) => { + let format = args.output(); + (format, invite::execute(args, &ControllingTerminal)) + } + Command::Node(args) => { + if let Some(NodeBootstrapCommand::Join(join_args)) = args.bootstrap_command { + let format = join_args.output; + (format, join::execute(join_args, &ControllingTerminal).await) + } else { + (OutputFormat::Human, run_internal_node(args).await) + } + } + Command::Demo(args) => ( + OutputFormat::Human, + demo::run(demo::DemoOptions { + env_file: args.env_file, + artifact: args.artifact, + state_dir: args.state_dir, + }) + .await + .map(|_| ()) + .map_err(|_| CliError::new("DemoFailed", "The local demo failed.", true)), + ), + }; + match result { + Ok(()) => 0, + Err(error) => { + output::emit_error(format, &error); + 1 + } + } +} + +async fn run_internal_node(args: NodeArgs) -> Result<(), CliError> { + let missing = || { + CliError::new( + "InvalidArguments", + "Internal node arguments are incomplete.", + false, + ) + }; + node::run(node::NodeOptions { + profile: args.profile.ok_or_else(missing)?, + state_dir: args.state_dir.ok_or_else(missing)?, + key_file: args.key_file.ok_or_else(missing)?, + credential_file: args.credential_file.ok_or_else(missing)?, + root_public_key_file: args.root_public_key_file.ok_or_else(missing)?, + ready_file: args.ready_file.ok_or_else(missing)?, + directory_seed: args.directory_seed, + control_token_file: args.control_token_file, + network: NetworkBoundary::loopback_ipv4(), + }) + .await + .map(|_| ()) + .map_err(|_| { + CliError::new( + "NodeFailed", + "The node runtime stopped with an error.", + true, + ) + }) +} diff --git a/src/cli/output.rs b/src/cli/output.rs new file mode 100644 index 0000000..aae1031 --- /dev/null +++ b/src/cli/output.rs @@ -0,0 +1,58 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum OutputFormat { + Human, + Json, +} + +#[derive(Debug, Serialize)] +pub struct CliError { + pub code: &'static str, + pub message: &'static str, + pub retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_id: Option, +} + +impl CliError { + pub const fn new(code: &'static str, message: &'static str, retryable: bool) -> Self { + Self { + code, + message, + retryable, + operation_id: None, + } + } + + pub fn with_operation(mut self, operation_id: impl Into) -> Self { + self.operation_id = Some(operation_id.into()); + self + } +} + +pub fn emit(format: OutputFormat, human: &str, value: &T) -> Result<(), CliError> { + match format { + OutputFormat::Human => println!("{human}"), + OutputFormat::Json => println!( + "{}", + serde_json::to_string(value).map_err(|_| { + CliError::new( + "OutputFailed", + "The command result could not be encoded.", + false, + ) + })? + ), + } + Ok(()) +} + +pub fn emit_error(format: OutputFormat, error: &CliError) { + match format { + OutputFormat::Human => eprintln!("{}: {}", error.code, error.message), + OutputFormat::Json => println!("{}", serde_json::to_string(error).unwrap_or_else(|_| { + "{\"code\":\"OutputFailed\",\"message\":\"The error could not be encoded.\",\"retryable\":false}".to_owned() + })), + } +} diff --git a/src/lib.rs b/src/lib.rs index 1b50c9e..b0223e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod adapters; pub mod bootstrap; +pub mod cli; pub mod demo; pub mod node; pub mod protocol; diff --git a/src/main.rs b/src/main.rs index 9030f26..fdd7f4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,51 +1,3 @@ -use std::path::PathBuf; - -use agenet::{bootstrap::network::NetworkBoundary, demo, node}; -use clap::{Args, Parser, Subcommand}; - -#[derive(Debug, Parser)] -#[command(name = "agenet", version, about)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - Node(NodeArgs), - Demo(DemoArgs), -} - -#[derive(Debug, Args)] -struct NodeArgs { - #[arg(long)] - profile: node::NodeProfile, - #[arg(long)] - state_dir: PathBuf, - #[arg(long)] - key_file: PathBuf, - #[arg(long)] - credential_file: PathBuf, - #[arg(long)] - root_public_key_file: PathBuf, - #[arg(long)] - ready_file: PathBuf, - #[arg(long)] - directory_seed: Option, - #[arg(long)] - control_token_file: Option, -} - -#[derive(Debug, Args)] -struct DemoArgs { - #[arg(long)] - env_file: PathBuf, - #[arg(long)] - artifact: PathBuf, - #[arg(long)] - state_dir: Option, -} - #[tokio::main] async fn main() { tracing_subscriber::fmt() @@ -53,30 +5,5 @@ async fn main() { .with_target(false) .without_time() .init(); - let result = match Cli::parse().command { - Command::Node(args) => node::run(node::NodeOptions { - profile: args.profile, - state_dir: args.state_dir, - key_file: args.key_file, - credential_file: args.credential_file, - root_public_key_file: args.root_public_key_file, - ready_file: args.ready_file, - directory_seed: args.directory_seed, - control_token_file: args.control_token_file, - network: NetworkBoundary::loopback_ipv4(), - }) - .await - .map(|_| ()), - Command::Demo(args) => demo::run(demo::DemoOptions { - env_file: args.env_file, - artifact: args.artifact, - state_dir: args.state_dir, - }) - .await - .map(|_| ()), - }; - if let Err(error) = result { - eprintln!("agenet failed: {error}"); - std::process::exit(1); - } + std::process::exit(agenet::cli::run().await); } diff --git a/src/protocol/enrollment.rs b/src/protocol/enrollment.rs index c484c27..1af7010 100644 --- a/src/protocol/enrollment.rs +++ b/src/protocol/enrollment.rs @@ -2,11 +2,12 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use reqwest::Url; use serde::{Deserialize, Serialize}; +use std::net::IpAddr; use uuid::Uuid; use super::{BootstrapProfile, CredentialChain, DomainId, NodeId, ProtocolError}; -const ENROLLMENT_REQUEST_DOMAIN: &[u8] = b"AGENET\0enrollment-request-v0.2\0"; +const ENROLLMENT_REQUEST_DOMAIN: &[u8] = b"AGENET\0enrollment-request-v0.3\0"; pub const MAX_ENROLLMENT_CSR_BYTES: usize = 16 * 1024; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -29,6 +30,7 @@ pub(crate) struct EnrollmentRequestClaims { pub invitation_claims_sha256: [u8; 32], pub node_id: NodeId, pub requested_profile: BootstrapProfile, + pub requested_bind_ip: IpAddr, pub signing_public_key_base64: String, pub tls_csr_pem: String, } @@ -76,7 +78,7 @@ pub(crate) fn verify_enrollment_claims( } fn validate_enrollment_claims(claims: &EnrollmentRequestClaims) -> Result<(), ProtocolError> { - if claims.protocol_version != "agenet.enrollment.v0.2" { + if claims.protocol_version != "agenet.enrollment.v0.3" { return Err(ProtocolError::UnsupportedEnrollmentVersion); } if claims.operation_id.is_nil() || claims.invitation_claims_sha256 == [0_u8; 32] { @@ -115,11 +117,12 @@ mod tests { fn exact_signed_claim_bytes_reject_each_field_mutation() { let key = SigningKey::from_bytes(&[31_u8; 32]); let claims = EnrollmentRequestClaims { - protocol_version: "agenet.enrollment.v0.2".to_owned(), + protocol_version: "agenet.enrollment.v0.3".to_owned(), operation_id: Uuid::from_u128(9), invitation_claims_sha256: [7_u8; 32], node_id: NodeId::new("node-exact-claims").expect("node"), requested_profile: BootstrapProfile::Provider, + requested_bind_ip: "100.64.0.8".parse().expect("IP"), signing_public_key_base64: STANDARD.encode(key.verifying_key().to_bytes()), tls_csr_pem: "-----BEGIN CERTIFICATE REQUEST-----\nabc\n-----END CERTIFICATE REQUEST-----\n" @@ -131,7 +134,7 @@ mod tests { let mutations = [ ( "protocol_version", - Value::String("agenet.enrollment.v0.3".to_owned()), + Value::String("agenet.enrollment.v0.2".to_owned()), ), ( "operation_id", @@ -143,6 +146,7 @@ mod tests { ), ("node_id", Value::String("node-mutated".to_owned())), ("requested_profile", Value::String("base".to_owned())), + ("requested_bind_ip", Value::String("100.64.0.9".to_owned())), ( "signing_public_key_base64", Value::String(STANDARD.encode([4_u8; 32])), diff --git a/src/transport/enrollment.rs b/src/transport/enrollment.rs index b662f09..789addd 100644 --- a/src/transport/enrollment.rs +++ b/src/transport/enrollment.rs @@ -120,7 +120,7 @@ impl EnrollmentClient { return Err(EnrollmentError::InvalidRequest); } let wire = self.send_enrollment(body).await?; - if wire.format_version != "agenet.enrollment-wire.v0.2" { + if wire.format_version != "agenet.enrollment-wire.v0.3" { return Err(EnrollmentError::InvalidRequest); } EnrollmentHandoffValidation::validate(&handoff, attempt, &wire.bundle, current_time_ms())?; @@ -234,7 +234,7 @@ async fn handle_enrollment( .process(&request, current_time_ms()) .map(|bundle| { Json(crate::bootstrap::EnrollmentWireResponse { - format_version: "agenet.enrollment-wire.v0.2".to_owned(), + format_version: "agenet.enrollment-wire.v0.3".to_owned(), bundle, }) }) diff --git a/tests/cli_bootstrap.rs b/tests/cli_bootstrap.rs new file mode 100644 index 0000000..8ecc507 --- /dev/null +++ b/tests/cli_bootstrap.rs @@ -0,0 +1,127 @@ +use std::process::{Command, Stdio}; + +fn agenet() -> Command { + Command::new(env!("CARGO_BIN_EXE_agenet")) +} + +#[test] +fn bootstrap_help_exposes_only_tty_secret_commands() { + for (arguments, expected) in [ + (&["--help"][..], "domain"), + (&["domain", "--help"][..], "init"), + (&["invite", "--help"][..], "create"), + (&["node", "join", "--help"][..], "--output"), + ] { + let output = agenet().args(arguments).output().expect("CLI executes"); + assert!(output.status.success(), "{arguments:?}"); + let stdout = String::from_utf8(output.stdout).expect("help is UTF-8"); + assert!(stdout.contains(expected), "{arguments:?}: {stdout}"); + assert!(!stdout.contains("passphrase")); + assert!(!stdout.contains("invitation-secret")); + } +} + +#[test] +fn domain_arguments_fail_before_secret_prompt() { + let missing_cidr = agenet() + .args([ + "domain", + "init", + "--network", + "wireguard", + "--bind-ip", + "10.0.0.2", + ]) + .stdin(Stdio::null()) + .output() + .expect("CLI executes"); + assert_eq!(missing_cidr.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&missing_cidr.stderr).contains("--allowed-cidr")); + + let invalid = agenet() + .args([ + "domain", + "init", + "--network", + "public", + "--bind-ip", + "127.0.0.1", + ]) + .output() + .expect("CLI executes"); + assert_eq!(invalid.status.code(), Some(2)); +} + +#[test] +fn join_without_controlling_tty_returns_redacted_json_error() { + let sentinel = "INVITATION-SENTINEL-MUST-NOT-LEAK"; + let output = agenet() + .args(["node", "join", "--output", "json"]) + .env("AGENET_SENTINEL", sentinel) + .stdin(Stdio::null()) + .output() + .expect("CLI executes"); + assert!(!output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("JSON output is UTF-8"); + let value: serde_json::Value = serde_json::from_str(stdout.trim()).expect("stable JSON error"); + assert_eq!(value["code"], "HandoffTtyUnavailable"); + assert_eq!(value["retryable"], false); + assert!(value.get("message").is_some()); + assert!(!stdout.contains(sentinel)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(sentinel)); +} + +#[test] +fn internal_node_mode_remains_available() { + let output = agenet() + .args(["node", "--help"]) + .output() + .expect("CLI executes"); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("--profile")); +} + +#[test] +fn preexisting_domain_state_fails_before_tty_without_overwrite() { + let home = tempfile::TempDir::new().expect("temporary home"); + let state = home.path().join("Library/Application Support/AgenNet"); + std::fs::create_dir_all(&state).expect("state directory"); + let sentinel = b"do-not-overwrite"; + let root = state.join("domain-root-v2.age"); + std::fs::write(&root, sentinel).expect("sentinel root"); + let output = agenet() + .args([ + "domain", + "init", + "--network", + "wireguard", + "--bind-ip", + "10.0.0.2", + "--allowed-cidr", + "10.0.0.0/24", + "--output", + "json", + ]) + .env("HOME", home.path()) + .stdin(Stdio::null()) + .output() + .expect("CLI executes"); + assert!(!output.status.success()); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).expect("JSON error"); + assert_eq!(value["code"], "DomainStateExists"); + assert_eq!(std::fs::read(root).expect("sentinel remains"), sentinel); +} + +#[test] +fn ttl_and_secret_arguments_are_fail_closed() { + let invalid_ttl = agenet() + .args(["invite", "create", "--profile", "provider", "--ttl", "25h"]) + .output() + .expect("CLI executes"); + assert_eq!(invalid_ttl.status.code(), Some(2)); + let secret_argument = agenet() + .args(["node", "join", "--invitation", "not-a-real-secret"]) + .output() + .expect("CLI executes"); + assert_eq!(secret_argument.status.code(), Some(2)); +} diff --git a/tests/enrollment_protocol.rs b/tests/enrollment_protocol.rs index 048cb27..9c343fe 100644 --- a/tests/enrollment_protocol.rs +++ b/tests/enrollment_protocol.rs @@ -18,10 +18,12 @@ fn invitation() -> (TempDir, agenet::bootstrap::InvitationHandoff) { let handoff = store .create( InvitationSpec { - protocol_version: "agenet.enrollment.v0.2".to_owned(), + protocol_version: "agenet.enrollment.v0.3".to_owned(), domain_id: DomainId::new("domain-test").expect("domain"), authority_endpoint: Url::parse("https://127.0.0.1:8443").expect("url"), directory_seeds: vec![Url::parse("https://127.0.0.1:9443").expect("url")], + network_kind: agenet::bootstrap::network::OverlayKind::Loopback, + allowed_cidrs: vec!["127.0.0.1/32".parse().expect("CIDR")], root_sha256: "11".repeat(32), tls_ca_sha256: "22".repeat(32), allowed_profile: BootstrapProfile::Provider, @@ -43,6 +45,7 @@ fn attempt(handoff: &agenet::bootstrap::InvitationHandoff) -> (SigningKey, Enrol Uuid::from_u128(17), NodeId::new("node-joiner").expect("node"), BootstrapProfile::Provider, + "127.0.0.1".parse().expect("IP"), &signing_key, csr.csr_pem, ) @@ -67,6 +70,9 @@ fn exact_request_signature_rejects_every_protected_field_mutation() { changed.requested_profile = BootstrapProfile::Base; mutations.push(changed); let mut changed = original.clone(); + changed.requested_bind_ip = "127.0.0.2".parse().expect("IP"); + mutations.push(changed); + let mut changed = original.clone(); changed.signing_public_key_base64 = "invalid-key".to_owned(); mutations.push(changed); let mut changed = original.clone(); @@ -85,7 +91,7 @@ fn invitation_claim_mutation_invalidates_the_exact_request_signature() { let original = handoff.public_claims(); let mut mutations = Vec::new(); let mut claims = original.clone(); - claims.protocol_version = "agenet.enrollment.v0.3".to_owned(); + claims.protocol_version = "agenet.enrollment.v0.4".to_owned(); mutations.push(claims); let mut claims = original.clone(); claims.domain_id = DomainId::new("domain-mutated").expect("domain"); @@ -97,6 +103,12 @@ fn invitation_claim_mutation_invalidates_the_exact_request_signature() { claims.directory_seeds = vec![Url::parse("https://127.0.0.1:9555/").expect("url")]; mutations.push(claims); let mut claims = original.clone(); + claims.network_kind = agenet::bootstrap::network::OverlayKind::Tailscale; + mutations.push(claims); + let mut claims = original.clone(); + claims.allowed_cidrs = vec!["127.0.0.0/8".parse().expect("CIDR")]; + mutations.push(claims); + let mut claims = original.clone(); claims.root_sha256 = "33".repeat(32); mutations.push(claims); let mut claims = original.clone(); @@ -135,6 +147,7 @@ fn invalid_operation_profile_and_csr_bounds_fail_before_transport() { Uuid::nil(), NodeId::new("node-joiner").expect("node"), BootstrapProfile::Provider, + "127.0.0.1".parse().expect("IP"), &signing_key, "csr".to_owned(), ); @@ -145,6 +158,7 @@ fn invalid_operation_profile_and_csr_bounds_fail_before_transport() { Uuid::from_u128(1), NodeId::new("node-joiner").expect("node"), BootstrapProfile::AgentCandidate, + "127.0.0.1".parse().expect("IP"), &signing_key, "csr".to_owned(), ); @@ -155,6 +169,7 @@ fn invalid_operation_profile_and_csr_bounds_fail_before_transport() { Uuid::from_u128(2), NodeId::new("node-joiner").expect("node"), BootstrapProfile::Provider, + "127.0.0.1".parse().expect("IP"), &signing_key, "x".repeat(16 * 1024 + 1), ); diff --git a/tests/http_enrollment.rs b/tests/http_enrollment.rs index 4b57c9c..a834e4f 100644 --- a/tests/http_enrollment.rs +++ b/tests/http_enrollment.rs @@ -218,6 +218,7 @@ async fn enrollment_rejects_invalid_csr_without_consuming_bearer_twice() { Uuid::from_u128(46), NodeId::new("node-invalid-csr").unwrap(), BootstrapProfile::Provider, + "127.0.0.1".parse().unwrap(), &SigningKey::from_bytes(&[3; 32]), invalid_csr, ) @@ -494,10 +495,12 @@ fn create_handoff( invitations .create( InvitationSpec { - protocol_version: "agenet.enrollment.v0.2".to_owned(), + protocol_version: "agenet.enrollment.v0.3".to_owned(), domain_id: credential.claims.domain_id.clone(), authority_endpoint: endpoint.clone(), directory_seeds: vec![Url::parse("https://127.0.0.1:9443/").unwrap()], + network_kind: agenet::bootstrap::network::OverlayKind::Loopback, + allowed_cidrs: vec!["127.0.0.1/32".parse().unwrap()], root_sha256: fingerprint(root.verifying_key().as_bytes()), tls_ca_sha256: tls_ca_sha256.to_owned(), allowed_profile: BootstrapProfile::Provider, @@ -521,6 +524,7 @@ fn signed_attempt( Uuid::from_u128(operation), NodeId::new(node).unwrap(), BootstrapProfile::Provider, + "127.0.0.1".parse().unwrap(), &SigningKey::from_bytes(key), NodeTlsCsr::generate().unwrap().csr_pem, ) diff --git a/tests/invitation_store.rs b/tests/invitation_store.rs index 8520aec..cf0c7ce 100644 --- a/tests/invitation_store.rs +++ b/tests/invitation_store.rs @@ -31,10 +31,12 @@ fn capability(value: &str) -> CapabilityKind { fn spec() -> InvitationSpec { InvitationSpec { - protocol_version: "agenet.enrollment.v0.2".to_owned(), + protocol_version: "agenet.enrollment.v0.3".to_owned(), domain_id: DomainId::new("domain:test").expect("test domain is valid"), authority_endpoint: Url::parse("https://100.64.0.1:7443").expect("test URL is valid"), directory_seeds: vec![Url::parse("https://100.64.0.1:7444").expect("test URL is valid")], + network_kind: agenet::bootstrap::network::OverlayKind::Tailscale, + allowed_cidrs: vec!["100.64.0.0/10".parse().expect("CIDR")], root_sha256: "11".repeat(32), tls_ca_sha256: "22".repeat(32), allowed_profile: BootstrapProfile::Provider, @@ -175,7 +177,7 @@ fn record_debug_redacts_persisted_authenticator() { fn every_tampered_security_claim_is_rejected_before_reservation() { let mutations: &[ClaimsMutation] = &[ ("protocol_version", |claims| { - claims.protocol_version = "agenet.enrollment.v0.3".to_owned(); + claims.protocol_version = "agenet.enrollment.v0.4".to_owned(); }), ("domain_id", |claims| { claims.domain_id = DomainId::new("domain:attacker").expect("test domain is valid"); @@ -188,6 +190,12 @@ fn every_tampered_security_claim_is_rejected_before_reservation() { claims.directory_seeds = vec![Url::parse("https://100.64.0.99:7444/").expect("test URL is valid")]; }), + ("network_kind", |claims| { + claims.network_kind = agenet::bootstrap::network::OverlayKind::WireGuard; + }), + ("allowed_cidrs", |claims| { + claims.allowed_cidrs = vec!["100.64.0.0/11".parse().expect("CIDR")]; + }), ("root_sha256", |claims| claims.root_sha256 = "33".repeat(32)), ("tls_ca_sha256", |claims| { claims.tls_ca_sha256 = "44".repeat(32); @@ -586,6 +594,9 @@ fn public_claim_urls_reject_credentials_and_ambient_url_components() { } let mut ipv6 = spec(); ipv6.authority_endpoint = Url::parse("https://[fd00::1]:7443/").expect("test IPv6 URL parses"); + ipv6.directory_seeds = vec![Url::parse("https://[fd00::1]:7444/").expect("IPv6 URL")]; + ipv6.network_kind = agenet::bootstrap::network::OverlayKind::WireGuard; + ipv6.allowed_cidrs = vec!["fd00::/64".parse().expect("CIDR")]; assert!(store.create(ipv6, NOW_MS).is_ok()); } @@ -714,15 +725,15 @@ fn replay_fails_closed_on_torn_corrupt_oversized_or_unsafe_state() { } #[test] -fn journal_emits_v2_and_rejects_legacy_or_future_versions() { +fn journal_emits_v3_and_rejects_legacy_or_future_versions() { const HEADER_PREFIX: &[u8] = b"AGENET-INVITATION-JOURNAL\0"; - for unsupported in [1u8, 3u8] { + for unsupported in [2u8, 4u8] { let temp = TempDir::new().expect("temporary directory is created"); drop(create_store(&temp)); let path = temp.path().join(JOURNAL_FILE); let mut bytes = fs::read(&path).expect("journal is readable"); assert_eq!(&bytes[..HEADER_PREFIX.len()], HEADER_PREFIX); - assert_eq!(bytes[HEADER_PREFIX.len()], 2, "new journal must emit v2"); + assert_eq!(bytes[HEADER_PREFIX.len()], 3, "new journal must emit v3"); bytes[HEADER_PREFIX.len()] = unsupported; fs::write(&path, bytes).expect("test journal version is changed"); assert!(matches!( From 2c81b23ee1df8017f1cb365097e3b25c7e60acba Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 02:00:23 +0800 Subject: [PATCH 31/67] [bug] Fix bootstrap authorization state Root cause: Phases claimed service artifacts before enrollment, and invitation capability scope was not persisted in node credentials. Solution: Version honest phase and credential state, enforce signed capability ceilings, harden PKI reload, and make CLI output fallible. Risks: Bootstrap journal schema 1 and Node Credential v0.2 fail closed. Dependency: Bootstrap step 10. Links: docs/superpowers/specs/ 2026-08-14-node-bootstrap-and-pages-design.md Post-mortem: Trace every phase to an artifact and every authorization input through persistence, restart, and final enforcement. --- README.md | 22 ++-- ROADMAP.md | 12 +- ...6-08-14-node-bootstrap-and-pages-design.md | 19 ++- plan/01-v1-multi-host-node-bootstrap.md | 19 ++- src/bootstrap/enrollment.rs | 31 +++++ src/bootstrap/pki.rs | 45 ++++++- src/bootstrap/state.rs | 19 ++- src/cli/domain.rs | 37 +++--- src/cli/invite.rs | 1 - src/cli/join.rs | 117 +++++++++++++----- src/cli/mod.rs | 15 ++- src/cli/output.rs | 96 +++++++++++--- src/demo.rs | 16 ++- src/protocol/authority.rs | 5 + src/protocol/error.rs | 1 + src/protocol/identity.rs | 40 +++++- src/runtime/directory.rs | 8 +- src/runtime/error.rs | 1 + tests/authority_protocol.rs | 75 ++++++++++- tests/bootstrap_recovery.rs | 72 +++++++++-- tests/cli_bootstrap.rs | 11 ++ tests/common/mod.rs | 8 +- tests/http_directory.rs | 40 ++++++ tests/http_enrollment.rs | 4 + tests/protocol_kernel.rs | 2 + 25 files changed, 598 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index fc0b2ab..63025ad 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,14 @@ set; Task 9 provides only the validated persisted-bundle boundary. The local journal records this forward path: ```text -Absent → BinaryInstalled → ServicePrepared → ReadyForEnrollment - → CredentialIssued → Registered → Healthy +Absent → BinaryInstalled → ReadyForEnrollment → CredentialIssued + → ServicePrepared → Registered → Healthy ``` -The only backward compensation is `ServicePrepared` or `ReadyForEnrollment` -to `BinaryInstalled`; it is forbidden after `CredentialIssued`, so interrupted -enrollment never guesses that credentials should be deleted. `Leave` moves any -installed, non-Left phase to `Left`. Every record contains a unique operation +The only backward compensation is `ServicePrepared → CredentialIssued`; it +removes no credential material. Task 10 never records `ServicePrepared`, because +Task 11 must first publish a real service artifact. `Leave` moves any installed, +non-Left phase to `Left`. Every record contains a unique operation ID, sequence, previous hash, transition, and checksum beneath a separate versioned/checksummed header. Exact operation replay is idempotent; changed reuse, illegal transitions, incomplete lines, unknown versions, corruption, @@ -75,7 +75,9 @@ and bounds violations fail closed. One owner-only nonblocking process lock is held for the store lifetime. An uncertain durable append poisons further mutation until restart; atomic replacement failures after publish are likewise reported as uncertain so restart can reconcile the visible final file. -The journal is provisional newline-framed JSONL, not binary framing. Its lock, +Journal schema 2 encodes this order and rejects schema 1 rather than +reinterpreting its contradictory phase semantics. The journal is provisional +newline-framed JSONL, not binary framing. Its lock, create/open, replay, header sync, and later appends remain anchored to one verified owner-only directory descriptor and one journal descriptor, so a pathname replacement cannot redirect publication between validation and sync. @@ -207,6 +209,8 @@ agenet node join [--bind-ip ] Root passphrases and complete invitations use only the controlling terminal; they are not accepted through argv, environment variables, JSON, or ordinary -stdin. `node join` returns `credential_issued` and the next command instead of -claiming registration or health. User-service start, registration, doctor, +stdin. `node join` returns `credential_issued` instead of claiming registration +or health. The current join result has no next command; +`domain init` advertises only the existing `invite create` command. User-service +start, registration, doctor, and physical two-device proof remain later gates. diff --git a/ROADMAP.md b/ROADMAP.md index 6abb74d..877633d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,15 @@ # ROADMAP +## 2026-08-15 06:20 CST + +- **Change**: Corrected Task 10 review findings in bootstrap phase truthfulness, durable capability authorization, strict Authority CA reload, and fallible CLI output. +- **Files**: Bootstrap journal/state and tests, Node Credential protocol and constructors, enrollment and Directory registration, CLI success results/output, PKI reload, README, design, and plan. +- **Root cause**: Requirement integration gap — Task 10 reused Task 9's provisional service-before-enrollment order and advertised a Task 11 command, while invitation capability authorization stopped at the bearer instead of surviving in the Authority-signed credential. PKI reload and stdout handling also accepted broader inputs/failure behavior than provisioning emits. +- **Solution**: Bump the bootstrap journal to schema 2 with `ReadyForEnrollment → CredentialIssued → ServicePrepared`; stop Task 10 at `CredentialIssued`; advertise only Clap-accepted commands. Bump Node Credential format/signing domain to v0.3, sign and persist the exact capability ceiling, compare it during handoff validation, and enforce it at Directory registration. Require one canonical CA PEM with the exact generated CA constraints/KU/self-signature/key binding, and use locked fallible writers for CLI output. +- **Prevention**: Every durable phase must correspond to an observed artifact, and every authenticated authorization input must be traced through issuance, persistence, restart, and its final enforcement point. Success output and reload parsers require executable negative tests, not only construction tests. +- **Boundary**: Service installation/start remains Task 11; Task 12 must consume verified credential claims without widening capability scope, and Task 14 setup automation must preserve the same ceiling. +- **Post-mortem**: Classified as technical blind spot. Future provisional schema reviews must include an end-to-end artifact/authorization ledger before exposing a CLI. + ## 2026-08-15 03:40 CST - **Change**: Added the Task 10 bootstrap CLI and corrected the provisional invitation/enrollment wire boundary before exposing it to operators. @@ -36,7 +46,7 @@ - **Files**: `src/bootstrap/config.rs`, `src/bootstrap/paths.rs`, `src/bootstrap/state.rs`, hardened key/TLS helpers, focused bootstrap tests, `README.md`, this roadmap, and ignored Task 9 report/evidence. - **Configuration boundary**: `agenet.node-config` schema 1 denies unknown fields, is bounded to 64 KiB, rejects empty/duplicate/excess Directory seeds, and permits only exact IP-literal root endpoints inside the selected boundary. Private-overlay endpoints require HTTPS; plaintext compatibility is explicit loopback policy only. DNS, userinfo, query, fragment, and non-root paths fail before startup. - **Filesystem boundary**: Current-host roots come only from `directories`; deterministic tests inject explicit platform/root values without global environment mutation. Managed directories are non-symlink, current-euid `0700`; credentials, Ed25519/TLS keys, certificates, CA, config, journal, and lock are separate owner-only regular files. Reads use `O_NOFOLLOW|O_NONBLOCK`, bounded length, and pre/post-open identity checks. The validated startup bundle re-verifies Root/domain/role/profile/time, Ed25519 public-key binding, Authority CA chain, TLS key, NodeId, exact IP SAN, and certificate time before returning material to a later runtime wiring task. -- **Recovery decision**: The JSONL journal has an independently versioned/checksummed header plus bounded complete-newline records with sequence, previous hash, operation ID, transition, and checksum. Only the documented forward path is legal; `RollbackService` is limited to `ServicePrepared`/`ReadyForEnrollment → BinaryInstalled`, retains credentials by being forbidden after `CredentialIssued`, and `Leave` moves any installed non-Left phase to `Left`. Exact operation replay is idempotent; changed reuse conflicts. +- **Recovery decision (superseded by the 2026-08-15 06:20 correction)**: Task 9 originally placed service preparation before enrollment. Journal schema 2 now uses the artifact-honest order and compensation documented above; schema 1 is rejected rather than reinterpreted. - **Durability and writer policy**: One owner-only nonblocking `flock` per state root is held for the store lifetime. Append validates before writing, flushes and syncs before advancing memory, poisons mutation after uncertain persistence, and requires restart reconciliation. Atomic replacement uses same-directory `0600` temporary files, file sync, publish, and parent-directory sync; a post-publish sync error is reported as uncertain and tests prove the published final is visible after restart. - **Provisional boundary**: These file names, schema, and transitions are explicit v1 choices, not permanent invariants. Task 10 may consume them but must not silently reinterpret unknown versions. Task 12 still owns full runtime/service wiring and live-clock enforcement. The previously recorded Task 5 `encode_handoff` growable serializer remains a must-fix before Task 10. - **Post-mortem**: Security-hardening integration gap — the first complete gate showed the existing macOS multiprocess demo timing out before `ready.json`. The hardened signing-key reader correctly rejected every symlink component, but the demo passed TempDir's `/var/...` spelling through the system `/var → /private/var` symlink to children. Provisioning now canonicalizes the just-created trusted run root before deriving or passing any child path; the Task 9 managed-root symlink policy remains strict. A second gate exposed that applying the same component policy to the pre-existing public signing-key API broke its established `/var/...` consumer contract. The public API now retains bounded final-file owner/mode/`O_NOFOLLOW` compatibility, while Task 9 startup uses a separate crate-private component-hardened reader. Future filesystem hardening must test both hostile user symlinks, platform path aliases, and existing public consumers before broadening a helper's policy. diff --git a/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md index fe0b501..6c74c64 100644 --- a/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md +++ b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md @@ -135,12 +135,18 @@ authorization and auditability after transport termination. ### 6.2 Credential hierarchy The v0.1 `SignedNodeCredential` was signed directly by the ephemeral demo Root. -v0.2 introduces a versioned `SignedAuthorityCredential` and validates a chain: +v0.2 introduced a versioned `SignedAuthorityCredential` and validates a chain: ```text trusted Domain Root → Authority Credential → Node Credential ``` +Node Credential format and signing domain v0.3 add an Authority-signed, +bounded `capability_ceiling`. Enrollment copies the exact invitation ceiling; +restart re-verifies it from the persisted credential, and Directory Manifest +registration rejects capability kinds outside it. Older Node Credential +semantics fail closed because they cannot authorize this boundary. + The Authority Credential restricts: - issuer and Domain IDs; @@ -262,17 +268,18 @@ identity or consuming a second invitation. ```text Absent → BinaryInstalled -→ ServicePrepared → ReadyForEnrollment → CredentialIssued +→ ServicePrepared → Registered → Healthy ``` -Each transition has a durable local marker and a compensating action. A failed -service registration removes the incomplete service definition but retains the -verified binary and diagnostic state. Enrollment failure never triggers an -automatic credential purge. +Bootstrap journal schema 2 encodes this order and rejects schema 1. Task 10 +stops at `CredentialIssued`; only Task 11 may append `ServicePrepared` after a +real service definition is durable. `RollbackService` returns to +`CredentialIssued`, retaining credentials. Enrollment failure never triggers +an automatic credential purge. ### 7.6 Service defaults diff --git a/plan/01-v1-multi-host-node-bootstrap.md b/plan/01-v1-multi-host-node-bootstrap.md index d40f031..f72a57b 100644 --- a/plan/01-v1-multi-host-node-bootstrap.md +++ b/plan/01-v1-multi-host-node-bootstrap.md @@ -166,10 +166,12 @@ pub fn verify_credential_chain( - [ ] Add a property test that mutates one serialized Authority-claim byte and proves `verify_strict` rejects it. - [ ] Change `SignedNodeCredential` claims to include `domain_id`, - `authority_id`, `bootstrap_profile`, and `allowed_roles`. `Base` permits the - Requester role, `Provider` adds Executor and Verifier, and `AgentCandidate` - remains Base-equivalent until a separately authorized Adapter is enabled. - Directory issuance is reserved for `domain init`, not an invitation. + `authority_id`, `bootstrap_profile`, `allowed_roles`, and the bounded + Authority-signed `capability_ceiling`. Node Credential v0.3 rejects older + semantics. `Base` permits the Requester role, `Provider` adds Executor and + Verifier, and `AgentCandidate` remains Base-equivalent until a separately + authorized Adapter is enabled. Directory issuance is reserved for `domain + init`, uses an explicit empty ceiling, and is not an invitation. - [ ] Make issuance require an Authority signing key, `IssueNodeCredential` scope, an allowed Bootstrap Profile, and a lifetime within the Authority ceiling. @@ -618,15 +620,22 @@ pub struct NodeConfigV1 { pub enum BootstrapPhase { Absent, BinaryInstalled, - ServicePrepared, ReadyForEnrollment, CredentialIssued, + ServicePrepared, Registered, Healthy, Left, } ``` +Review correction: bootstrap journal schema 2 uses the order shown above and +rejects schema 1. Task 10 stops at `CredentialIssued`; Task 11 owns the first +real `ServicePrepared` artifact and transition. Node Credential v0.3 carries +the exact Authority-signed invitation `capability_ceiling`; Directory +registration must enforce it, and Tasks 12/14 must preserve the verified +claims when wiring adapters and node setup automation. + - [ ] Write tests for macOS and Linux user paths, schema rejection, unknown fields, symlinks, owner mismatch, wrong permissions, partial writes, interrupted enrollment, interrupted service installation, and replay to the last committed diff --git a/src/bootstrap/enrollment.rs b/src/bootstrap/enrollment.rs index fdf8505..f9258b5 100644 --- a/src/bootstrap/enrollment.rs +++ b/src/bootstrap/enrollment.rs @@ -576,12 +576,14 @@ impl EnrollmentAuthority { ) -> Result { let (issued_at_ms, expires_at_ms) = self.credential_validity(now_ms)?; let node_claims = NodeCredentialClaims { + format_version: "agenet.node-credential.v0.3".to_owned(), domain_id: invitation.domain_id.clone(), authority_id: self.authority_credential.claims.authority_id.clone(), node_id: request.node_id.clone(), signing_public_key_base64: request.signing_public_key_base64.clone(), bootstrap_profile: request.requested_profile, allowed_roles: roles_for_bootstrap_profile(request.requested_profile), + capability_ceiling: invitation.capability_ceiling.clone(), issued_at_ms, expires_at_ms, }; @@ -744,6 +746,7 @@ impl EnrollmentHandoffValidation { .as_slice() || verified.bootstrap_profile != attempt.requested_profile || verified.allowed_roles != roles_for_bootstrap_profile(attempt.requested_profile) + || verified.capability_ceiling != invitation.capability_ceiling { return Err(EnrollmentError::CertificateRejected); } @@ -1222,6 +1225,34 @@ mod tests { .is_err() ); + let mut widened = bundle.clone(); + let mut widened_claims = widened + .credential_chain + .node + .decode_claims() + .expect("claims"); + widened_claims.capability_ceiling = + BTreeSet::from([CapabilityKind::new("project.build.v1").expect("capability")]); + widened.credential_chain.node = fixture + .authority + .authority_credential + .issue_node_credential( + &fixture.root_public_key, + &fixture.authority.authority_signing_key, + widened_claims, + NOW_MS, + ) + .expect("widened credential"); + assert_eq!( + EnrollmentHandoffValidation::validate( + &fixture.handoff, + &fixture.attempt, + &widened, + NOW_MS, + ), + Err(EnrollmentError::CertificateRejected) + ); + let other_csr = super::super::NodeTlsCsr::generate().expect("other CSR"); let mismatched_certificate = fixture .authority diff --git a/src/bootstrap/pki.rs b/src/bootstrap/pki.rs index 559ad1f..c599e6f 100644 --- a/src/bootstrap/pki.rs +++ b/src/bootstrap/pki.rs @@ -211,10 +211,20 @@ impl AuthorityPki { pub fn load(ca_cert_pem: &str, ca_key_pem: &str) -> Result { let key = ZeroizingKeyPair::from_pem(ca_key_pem).map_err(|_| BootstrapError::InvalidPki)?; - let certificate = pem::parse(ca_cert_pem).map_err(|_| BootstrapError::InvalidPki)?; + let certificates = pem::parse_many(ca_cert_pem).map_err(|_| BootstrapError::InvalidPki)?; + let [certificate] = certificates.as_slice() else { + return Err(BootstrapError::InvalidPki); + }; if certificate.tag() != "CERTIFICATE" { return Err(BootstrapError::InvalidPki); } + let canonical = pem::encode_config( + certificate, + pem::EncodeConfig::new().set_line_ending(pem::LineEnding::LF), + ); + if canonical != ca_cert_pem { + return Err(BootstrapError::InvalidPki); + } let der = CertificateDer::from(certificate.contents().to_vec()); let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(der.as_ref()) .map_err(|_| BootstrapError::InvalidPki)?; @@ -228,8 +238,8 @@ impl AuthorityPki { .ok_or(BootstrapError::InvalidPki)?; if !basic.value.ca || basic.value.path_len_constraint != Some(0) - || !usage.value.key_cert_sign() - || !usage.value.crl_sign() + || usage.value.flags != 0x60 + || parsed.subject().as_raw() != parsed.issuer().as_raw() || parsed.public_key().subject_public_key.data.as_ref() != key.der_bytes() || parsed.verify_signature(None).is_err() { @@ -449,6 +459,7 @@ mod tests { atomic::{AtomicUsize, Ordering}, }; + use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyUsagePurpose}; use zeroize::Zeroize; use super::{AuthorityPki, ZeroizingKeyPair}; @@ -492,4 +503,32 @@ mod tests { .private_key_pem(); assert!(AuthorityPki::load(&certificate, &wrong_key).is_err()); } + + #[test] + fn authority_pki_reload_rejects_duplicate_and_trailing_pem() { + let generated = + AuthorityPki::generate(2_000_000_000_000, 2_100_000_000_000).expect("generate CA"); + let certificate = generated.ca_cert_pem.to_string(); + let key = generated.ca_key_pem.to_string(); + assert!(AuthorityPki::load(&format!("{certificate}{certificate}"), &key).is_err()); + assert!(AuthorityPki::load(&format!("{certificate}trailing"), &key).is_err()); + } + + #[test] + fn authority_pki_reload_rejects_extra_key_usage() { + let key = ZeroizingKeyPair::generate().expect("key"); + let key_pem = key.private_key_pem(); + let mut params = CertificateParams::new(Vec::::new()).expect("params"); + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::CrlSign, + KeyUsagePurpose::DigitalSignature, + ]; + params + .distinguished_name + .push(DnType::CommonName, "AgenNet Internal Authority CA"); + let certificate = params.self_signed(&key).expect("certificate").pem(); + assert!(AuthorityPki::load(&certificate, &key_pem).is_err()); + } } diff --git a/src/bootstrap/state.rs b/src/bootstrap/state.rs index c1a32eb..f2dbd87 100644 --- a/src/bootstrap/state.rs +++ b/src/bootstrap/state.rs @@ -16,7 +16,7 @@ use crate::runtime::key_store::{ use super::BootstrapError; const JOURNAL_FORMAT: &str = "agenet.bootstrap-journal"; -const JOURNAL_SCHEMA: u32 = 1; +const JOURNAL_SCHEMA: u32 = 2; const MAX_JOURNAL_BYTES: usize = 256 * 1024; const MAX_RECORDS: usize = 1024; const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; @@ -26,9 +26,9 @@ const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000 pub enum BootstrapPhase { Absent, BinaryInstalled, - ServicePrepared, ReadyForEnrollment, CredentialIssued, + ServicePrepared, Registered, Healthy, Left, @@ -358,10 +358,6 @@ fn next_phase( } ( BootstrapPhase::BinaryInstalled, - BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), - ) => Ok(BootstrapPhase::ServicePrepared), - ( - BootstrapPhase::ServicePrepared, BootstrapTransition::Advance(BootstrapPhase::ReadyForEnrollment), ) => Ok(BootstrapPhase::ReadyForEnrollment), ( @@ -370,15 +366,18 @@ fn next_phase( ) => Ok(BootstrapPhase::CredentialIssued), ( BootstrapPhase::CredentialIssued, + BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), + ) => Ok(BootstrapPhase::ServicePrepared), + ( + BootstrapPhase::ServicePrepared, BootstrapTransition::Advance(BootstrapPhase::Registered), ) => Ok(BootstrapPhase::Registered), (BootstrapPhase::Registered, BootstrapTransition::Advance(BootstrapPhase::Healthy)) => { Ok(BootstrapPhase::Healthy) } - ( - BootstrapPhase::ServicePrepared | BootstrapPhase::ReadyForEnrollment, - BootstrapTransition::RollbackService, - ) => Ok(BootstrapPhase::BinaryInstalled), + (BootstrapPhase::ServicePrepared, BootstrapTransition::RollbackService) => { + Ok(BootstrapPhase::CredentialIssued) + } ( BootstrapPhase::BinaryInstalled | BootstrapPhase::ServicePrepared diff --git a/src/cli/domain.rs b/src/cli/domain.rs index 43b63fc..2b7104e 100644 --- a/src/cli/domain.rs +++ b/src/cli/domain.rs @@ -75,7 +75,7 @@ impl DomainArgs { } #[derive(Serialize)] -struct DomainInitResult { +pub(super) struct DomainInitResult { domain_id: String, founding_node_id: String, root_fingerprint_sha256: String, @@ -124,7 +124,15 @@ fn init(args: InitArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; reject_existing_domain(&paths)?; let passphrase = prompt_new_passphrase(terminal)?; - provision(paths, boundary, passphrase, args.output) + let result = provision(paths, boundary, passphrase)?; + output::emit( + args.output, + &format!( + "AgenNet Domain {} created. Next: agenet invite create --profile base", + result.domain_id + ), + &result, + ) } fn reject_existing_domain(paths: &NodePaths) -> Result<(), CliError> { @@ -163,8 +171,7 @@ pub(super) fn provision( paths: NodePaths, boundary: NetworkBoundary, passphrase: SecretString, - format: OutputFormat, -) -> Result<(), CliError> { +) -> Result { let now = now_ms()?; let domain_id = DomainId::new(format!("domain:{}", Uuid::new_v4())).map_err(|_| internal())?; let root = random_signing_key()?; @@ -204,12 +211,14 @@ pub(super) fn provision( &root.verifying_key(), &authority_key, NodeCredentialClaims { + format_version: "agenet.node-credential.v0.3".to_owned(), domain_id: domain_id.clone(), authority_id, node_id: founding_node_id.clone(), signing_public_key_base64: STANDARD.encode(founding_key.verifying_key().to_bytes()), bootstrap_profile: BootstrapProfile::Base, allowed_roles: BTreeSet::from([NodeRole::Directory]), + capability_ceiling: BTreeSet::new(), issued_at_ms: now, expires_at_ms: now + 30 * 24 * 60 * 60 * 1_000, }, @@ -267,7 +276,6 @@ pub(super) fn provision( let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; for (id, phase) in [ ("domain-binary-v1", BootstrapPhase::BinaryInstalled), - ("domain-service-v1", BootstrapPhase::ServicePrepared), ("domain-enrollment-v1", BootstrapPhase::ReadyForEnrollment), ("domain-credential-v1", BootstrapPhase::CredentialIssued), ] { @@ -283,16 +291,9 @@ pub(super) fn provision( authority_endpoint: authority_endpoint.to_string(), directory_endpoint: directory_endpoint.to_string(), phase: "credential_issued", - next_commands: ["agenet node start"], + next_commands: ["agenet invite create --profile base"], }; - output::emit( - format, - &format!( - "AgenNet Domain {} created. Next: agenet node start", - result.domain_id - ), - &result, - ) + Ok(result) } fn persist_public_and_authority( @@ -406,13 +407,17 @@ mod tests { &NodePathEnvironment::new(canonical_home, None, None), ) .expect("paths"); - provision( + let result = provision( paths.clone(), NetworkBoundary::loopback_ipv4(), SecretString::from("test-only-passphrase".to_owned()), - OutputFormat::Human, ) .expect("Domain provisions"); + assert_eq!(result.phase, "credential_issued"); + assert_eq!( + result.next_commands, + ["agenet invite create --profile base"] + ); let root = AgeRootKeystore::unlock( &paths.root_keystore_file, SecretString::from("test-only-passphrase".to_owned()), diff --git a/src/cli/invite.rs b/src/cli/invite.rs index 1d679cf..a4cc4c9 100644 --- a/src/cli/invite.rs +++ b/src/cli/invite.rs @@ -262,7 +262,6 @@ mod tests { paths.clone(), NetworkBoundary::loopback_ipv4(), SecretString::from("test-only-passphrase".to_owned()), - OutputFormat::Json, ) .expect("Domain provisions"); let terminal = RecordingTerminal { diff --git a/src/cli/join.rs b/src/cli/join.rs index acb693a..5f74b57 100644 --- a/src/cli/join.rs +++ b/src/cli/join.rs @@ -43,7 +43,7 @@ struct JoinResult { domain_id: String, phase: &'static str, operation_id: String, - next_command: &'static str, + next_command: Option<&'static str>, } #[derive(Debug, Serialize, Deserialize)] @@ -60,14 +60,23 @@ struct PendingJoin { pub async fn execute(args: JoinArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; - execute_at(&paths, args, terminal).await + let format = args.output; + let result = execute_at(&paths, args, terminal).await?; + output::emit( + format, + &format!( + "Node {} enrolled. Service installation is not available in this preview.", + result.node_id + ), + &result, + ) } async fn execute_at( paths: &NodePaths, args: JoinArgs, terminal: &impl SecretTerminal, -) -> Result<(), CliError> { +) -> Result { let handoff = terminal.read_invitation().map_err(map_bootstrap)?; let bind_ip = resolve_bind_ip( args.bind_ip, @@ -87,6 +96,9 @@ async fn execute_at( ) })?; paths.ensure_secure_layout().map_err(map_bootstrap)?; + let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; + let has_pending_join = std::fs::symlink_metadata(&paths.pending_join_file).is_ok(); + ensure_joinable_phase(state.phase(), has_pending_join)?; let (pending, signing_key, tls_private_key) = load_or_create_pending(paths, &handoff, bind_ip)?; let attempt = EnrollmentAttempt::sign( &handoff, @@ -149,7 +161,6 @@ async fn execute_at( BootstrapProfile::Provider => NodeRole::Executor, }; load_startup_bundle(paths, &root, expected_role, now_ms()).map_err(map_bootstrap)?; - let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; let transitions = [ ( BootstrapPhase::Absent, @@ -158,11 +169,6 @@ async fn execute_at( ), ( BootstrapPhase::BinaryInstalled, - "service", - BootstrapPhase::ServicePrepared, - ), - ( - BootstrapPhase::ServicePrepared, "ready", BootstrapPhase::ReadyForEnrollment, ), @@ -183,30 +189,34 @@ async fn execute_at( ) .map_err(map_bootstrap)?; } - if !matches!( - state.phase(), - BootstrapPhase::CredentialIssued | BootstrapPhase::Registered | BootstrapPhase::Healthy - ) { + if state.phase() != BootstrapPhase::CredentialIssued { return Err(internal()); } - let phase = match state.phase() { - BootstrapPhase::CredentialIssued => "credential_issued", - BootstrapPhase::Registered => "registered", - BootstrapPhase::Healthy => "healthy", - _ => return Err(internal()), - }; let result = JoinResult { node_id: pending.node_id.as_str().to_owned(), domain_id: bundle.domain_id.as_str().to_owned(), - phase, + phase: "credential_issued", operation_id: pending.operation_id.to_string(), - next_command: "agenet node start", + next_command: None, }; - output::emit( - args.output, - &format!("Node {} enrolled. Next: agenet node start", result.node_id), - &result, - ) + Ok(result) +} + +fn ensure_joinable_phase(phase: BootstrapPhase, has_pending_join: bool) -> Result<(), CliError> { + if matches!( + phase, + BootstrapPhase::Absent + | BootstrapPhase::BinaryInstalled + | BootstrapPhase::ReadyForEnrollment + ) || (phase == BootstrapPhase::CredentialIssued && has_pending_join) + { + return Ok(()); + } + Err(CliError::new( + "NodeAlreadyManaged", + "The node is already service-managed or has left this Domain.", + false, + )) } fn resolve_bind_ip( @@ -510,6 +520,35 @@ mod tests { ); } + #[test] + fn join_never_claims_service_registration_or_health() { + for allowed in [ + BootstrapPhase::Absent, + BootstrapPhase::BinaryInstalled, + BootstrapPhase::ReadyForEnrollment, + BootstrapPhase::CredentialIssued, + ] { + ensure_joinable_phase(allowed, true).expect("pre-service phase is joinable"); + } + assert_eq!( + ensure_joinable_phase(BootstrapPhase::CredentialIssued, false) + .unwrap_err() + .code, + "NodeAlreadyManaged" + ); + for managed in [ + BootstrapPhase::ServicePrepared, + BootstrapPhase::Registered, + BootstrapPhase::Healthy, + BootstrapPhase::Left, + ] { + assert_eq!( + ensure_joinable_phase(managed, false).unwrap_err().code, + "NodeAlreadyManaged" + ); + } + } + #[tokio::test] async fn join_enrolls_persists_validates_and_stops_at_credential_issued() { let now = now_ms() - now_ms().rem_euclid(1_000); @@ -558,7 +597,10 @@ mod tests { .collect(), tls_ca_sha256: pki.fingerprint_sha256.clone(), allowed_profile: BootstrapProfile::Provider, - capability_ceiling: BTreeSet::new(), + capability_ceiling: BTreeSet::from([crate::protocol::CapabilityKind::new( + "source.metrics.v1", + ) + .expect("capability")]), }, now, ) @@ -601,7 +643,7 @@ mod tests { &NodePathEnvironment::new(home.path().canonicalize().expect("canonical"), None, None), ) .expect("paths"); - execute_at( + let result = execute_at( &paths, JoinArgs { bind_ip: Some("127.0.0.1".parse().expect("IP")), @@ -611,12 +653,31 @@ mod tests { ) .await .expect("join"); + assert_eq!(result.phase, "credential_issued"); + assert_eq!(result.next_command, None); assert_eq!( BootstrapStateStore::open(&paths.journal_file) .expect("state") .phase(), BootstrapPhase::CredentialIssued ); + let persisted = + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Executor, now_ms()) + .expect("startup bundle reloads"); + let verified = crate::protocol::verify_credential_chain( + &root.verifying_key(), + &persisted.credential, + &persisted.config.domain_id, + NodeRole::Executor, + now_ms(), + ) + .expect("persisted credential verifies"); + assert_eq!( + verified.capability_ceiling, + BTreeSet::from([ + crate::protocol::CapabilityKind::new("source.metrics.v1").expect("capability"), + ]) + ); handle.shutdown(); task.await.expect("join server"); } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 618d2ce..cc1cf09 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -132,7 +132,7 @@ async fn run_cli(cli: Cli) -> i32 { match result { Ok(()) => 0, Err(error) => { - output::emit_error(format, &error); + let _ = output::emit_error(format, &error); 1 } } @@ -167,3 +167,16 @@ async fn run_internal_node(args: NodeArgs) -> Result<(), CliError> { ) }) } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::Cli; + + #[test] + fn advertised_domain_follow_up_is_a_clap_accepted_command() { + let parsed = Cli::try_parse_from(["agenet", "invite", "create", "--profile", "base"]); + assert!(parsed.is_ok()); + } +} diff --git a/src/cli/output.rs b/src/cli/output.rs index aae1031..e23dc38 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -1,3 +1,5 @@ +use std::io::{self, Write}; + use serde::Serialize; #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] @@ -32,27 +34,85 @@ impl CliError { } pub fn emit(format: OutputFormat, human: &str, value: &T) -> Result<(), CliError> { + emit_to(&mut io::stdout().lock(), format, human, value) +} + +fn emit_to( + writer: &mut impl Write, + format: OutputFormat, + human: &str, + value: &T, +) -> Result<(), CliError> { + let line = match format { + OutputFormat::Human => human.to_owned(), + OutputFormat::Json => serde_json::to_string(value).map_err(|_| output_failed())?, + }; + writeln!(writer, "{line}").map_err(|_| output_failed()) +} + +pub fn emit_error(format: OutputFormat, error: &CliError) -> Result<(), CliError> { match format { - OutputFormat::Human => println!("{human}"), - OutputFormat::Json => println!( - "{}", - serde_json::to_string(value).map_err(|_| { - CliError::new( - "OutputFailed", - "The command result could not be encoded.", - false, - ) - })? - ), + OutputFormat::Human => writeln!(io::stderr().lock(), "{}: {}", error.code, error.message), + OutputFormat::Json => { + let encoded = serde_json::to_string(error).map_err(|_| output_failed())?; + writeln!(io::stdout().lock(), "{encoded}") + } } - Ok(()) + .map_err(|_| output_failed()) } -pub fn emit_error(format: OutputFormat, error: &CliError) { - match format { - OutputFormat::Human => eprintln!("{}: {}", error.code, error.message), - OutputFormat::Json => println!("{}", serde_json::to_string(error).unwrap_or_else(|_| { - "{\"code\":\"OutputFailed\",\"message\":\"The error could not be encoded.\",\"retryable\":false}".to_owned() - })), +fn output_failed() -> CliError { + CliError::new( + "OutputFailed", + "The command output could not be written.", + false, + ) +} + +#[cfg(test)] +mod tests { + use std::io::{Error, ErrorKind, Write}; + + use super::*; + + struct BrokenWriter; + + impl Write for BrokenWriter { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(Error::new(ErrorKind::BrokenPipe, "closed")) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn result_output_failure_returns_stable_error() { + let error = emit_to( + &mut BrokenWriter, + OutputFormat::Json, + "ok", + &serde_json::json!({"phase": "credential_issued"}), + ) + .unwrap_err(); + assert_eq!(error.code, "OutputFailed"); + assert!(!error.retryable); + } + + #[test] + fn successful_json_output_is_one_complete_line() { + let mut output = Vec::new(); + emit_to( + &mut output, + OutputFormat::Json, + "ignored", + &serde_json::json!({"phase": "credential_issued", "next_command": null}), + ) + .expect("output succeeds"); + assert_eq!( + std::str::from_utf8(&output).unwrap(), + "{\"next_command\":null,\"phase\":\"credential_issued\"}\n" + ); } } diff --git a/src/demo.rs b/src/demo.rs index cc32209..efeb8f8 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize}; use crate::{ node::{NodeProfile, ReadyState}, protocol::{ - AuthorityClaims, AuthorityScope, BootstrapProfile, CredentialChain, DomainId, - NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, + AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityKind, CredentialChain, + DomainId, NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, }, runtime::{PursuitRequest, PursuitResult, write_signing_key}, }; @@ -267,12 +267,24 @@ fn provision_nodes( } }; let claims = NodeCredentialClaims { + format_version: "agenet.node-credential.v0.3".to_owned(), domain_id: domain_id.clone(), authority_id: authority_credential.claims.authority_id.clone(), node_id, signing_public_key_base64: STANDARD.encode(signing_key.verifying_key().to_bytes()), bootstrap_profile, allowed_roles, + capability_ceiling: match profile { + NodeProfile::Directory | NodeProfile::Requester => BTreeSet::new(), + NodeProfile::Executor => { + BTreeSet::from([CapabilityKind::new("source.metrics.v1").map_err(sanitized)?]) + } + NodeProfile::Verifier => { + BTreeSet::from([ + CapabilityKind::new("source.metrics.verify.v1").map_err(sanitized)? + ]) + } + }, issued_at_ms: now_ms.saturating_sub(1_000), expires_at_ms: now_ms.saturating_add(600_000), }; diff --git a/src/protocol/authority.rs b/src/protocol/authority.rs index ee402a9..0d07032 100644 --- a/src/protocol/authority.rs +++ b/src/protocol/authority.rs @@ -170,6 +170,7 @@ fn validate_node_issuance( authority: &AuthorityClaims, node: &NodeCredentialClaims, ) -> Result<(), ProtocolError> { + super::identity::validate_node_claims(node)?; if node.domain_id != authority.domain_id { return Err(ProtocolError::DomainMismatch); } @@ -193,6 +194,7 @@ fn validate_founding_directory_issuance( authority: &AuthorityClaims, node: &NodeCredentialClaims, ) -> Result<(), ProtocolError> { + super::identity::validate_node_claims(node)?; if node.domain_id != authority.domain_id { return Err(ProtocolError::DomainMismatch); } @@ -211,6 +213,9 @@ fn validate_founding_directory_issuance( if node.allowed_roles != BTreeSet::from([NodeRole::Directory]) { return Err(ProtocolError::CredentialRoleMismatch); } + if !node.capability_ceiling.is_empty() { + return Err(ProtocolError::AuthorityScopeViolation); + } validate_node_lifetime(authority, node) } diff --git a/src/protocol/error.rs b/src/protocol/error.rs index f61ed39..1cc0990 100644 --- a/src/protocol/error.rs +++ b/src/protocol/error.rs @@ -30,6 +30,7 @@ pub enum ProtocolError { RevocationSetTooLarge, UnsupportedRevocationVersion, UnsupportedEnrollmentVersion, + UnsupportedNodeCredentialVersion, EnrollmentRequestTooLarge, InvalidEnvelopeSignature, InvalidContractSignature, diff --git a/src/protocol/identity.rs b/src/protocol/identity.rs index 5404144..6cd6cba 100644 --- a/src/protocol/identity.rs +++ b/src/protocol/identity.rs @@ -4,10 +4,12 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; -use super::{DomainId, NodeId, NodeRole, ProtocolError}; +use super::{CapabilityKind, DomainId, NodeId, NodeRole, ProtocolError}; use crate::protocol::authority::credential_message; -const NODE_CREDENTIAL_DOMAIN: &[u8] = b"AGENET\0node-credential-v0.2\0"; +const NODE_CREDENTIAL_DOMAIN: &[u8] = b"AGENET\0node-credential-v0.3\0"; +const NODE_CREDENTIAL_FORMAT: &str = "agenet.node-credential.v0.3"; +const MAX_CAPABILITY_CEILING: usize = 64; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -20,12 +22,14 @@ pub enum BootstrapProfile { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct NodeCredentialClaims { + pub format_version: String, pub domain_id: DomainId, pub authority_id: NodeId, pub node_id: NodeId, pub signing_public_key_base64: String, pub bootstrap_profile: BootstrapProfile, pub allowed_roles: BTreeSet, + pub capability_ceiling: BTreeSet, pub issued_at_ms: i64, pub expires_at_ms: i64, } @@ -38,6 +42,7 @@ pub struct VerifiedNodeClaims { pub signing_public_key: VerifyingKey, pub bootstrap_profile: BootstrapProfile, pub allowed_roles: BTreeSet, + pub capability_ceiling: BTreeSet, pub issued_at_ms: i64, pub expires_at_ms: i64, } @@ -46,6 +51,7 @@ impl TryFrom for VerifiedNodeClaims { type Error = ProtocolError; fn try_from(claims: NodeCredentialClaims) -> Result { + validate_node_claims(&claims)?; let signing_public_key = decode_node_key(&claims.signing_public_key_base64)?; Ok(Self { domain_id: claims.domain_id, @@ -54,6 +60,7 @@ impl TryFrom for VerifiedNodeClaims { signing_public_key, bootstrap_profile: claims.bootstrap_profile, allowed_roles: claims.allowed_roles, + capability_ceiling: claims.capability_ceiling, issued_at_ms: claims.issued_at_ms, expires_at_ms: claims.expires_at_ms, }) @@ -72,6 +79,7 @@ impl SignedNodeCredential { authority: &SigningKey, claims: NodeCredentialClaims, ) -> Result { + validate_node_claims(&claims)?; decode_node_key(&claims.signing_public_key_base64)?; let claims_bytes = serde_json::to_vec(&claims).map_err(|_| ProtocolError::SerializationFailed)?; @@ -90,6 +98,7 @@ impl SignedNodeCredential { let claims_bytes = STANDARD .decode(&self.claims_base64) .map_err(|_| ProtocolError::InvalidBase64)?; + validate_claims_format(&claims_bytes)?; let signature_bytes = STANDARD .decode(&self.signature_base64) .map_err(|_| ProtocolError::InvalidBase64)?; @@ -103,7 +112,7 @@ impl SignedNodeCredential { .map_err(|_| ProtocolError::InvalidCredentialSignature)?; let claims: NodeCredentialClaims = serde_json::from_slice(&claims_bytes) .map_err(|_| ProtocolError::SerializationFailed)?; - decode_node_key(&claims.signing_public_key_base64)?; + validate_node_claims(&claims)?; if now_ms < claims.issued_at_ms { return Err(ProtocolError::CredentialNotYetValid); } @@ -117,10 +126,35 @@ impl SignedNodeCredential { let claims_bytes = STANDARD .decode(&self.claims_base64) .map_err(|_| ProtocolError::InvalidBase64)?; + validate_claims_format(&claims_bytes)?; serde_json::from_slice(&claims_bytes).map_err(|_| ProtocolError::SerializationFailed) } } +#[derive(Deserialize)] +struct NodeCredentialFormatProbe { + format_version: Option, +} + +fn validate_claims_format(bytes: &[u8]) -> Result<(), ProtocolError> { + let probe: NodeCredentialFormatProbe = + serde_json::from_slice(bytes).map_err(|_| ProtocolError::SerializationFailed)?; + if probe.format_version.as_deref() != Some(NODE_CREDENTIAL_FORMAT) { + return Err(ProtocolError::UnsupportedNodeCredentialVersion); + } + Ok(()) +} + +pub(crate) fn validate_node_claims(claims: &NodeCredentialClaims) -> Result<(), ProtocolError> { + if claims.format_version != NODE_CREDENTIAL_FORMAT { + return Err(ProtocolError::UnsupportedNodeCredentialVersion); + } + if claims.capability_ceiling.len() > MAX_CAPABILITY_CEILING { + return Err(ProtocolError::AuthorityScopeViolation); + } + decode_node_key(&claims.signing_public_key_base64).map(|_| ()) +} + pub(crate) fn decode_node_key(encoded: &str) -> Result { let bytes = STANDARD .decode(encoded) diff --git a/src/runtime/directory.rs b/src/runtime/directory.rs index d4475a3..331e719 100644 --- a/src/runtime/directory.rs +++ b/src/runtime/directory.rs @@ -3,7 +3,8 @@ use std::{collections::HashMap, net::IpAddr, sync::Arc}; use tokio::sync::RwLock; use crate::protocol::{ - CandidateSet, CapabilityId, CapabilityManifest, NodeId, RouteQuery, VerifiedNodeClaims, + CandidateSet, CapabilityId, CapabilityKind, CapabilityManifest, NodeId, RouteQuery, + VerifiedNodeClaims, }; use super::{RevocationGuard, RuntimeError, revocation::VerifiedRevocationSubject}; @@ -48,6 +49,11 @@ impl DirectoryRegistry { now_unix_ms: u64, ) -> Result<(), RuntimeError> { validate_manifest(&claims.node_id, &manifest, now_unix_ms)?; + let kind = CapabilityKind::new(manifest.capability_kind_version()) + .map_err(|_| RuntimeError::CapabilityNotAuthorized)?; + if !claims.capability_ceiling.contains(&kind) { + return Err(RuntimeError::CapabilityNotAuthorized); + } self.manifests.write().await.insert( manifest.capability_id.clone(), RegisteredManifest { diff --git a/src/runtime/error.rs b/src/runtime/error.rs index 3774465..48f514a 100644 --- a/src/runtime/error.rs +++ b/src/runtime/error.rs @@ -15,6 +15,7 @@ pub enum RuntimeError { ContractAlreadyExists, CredentialRoleMismatch, ManifestProviderMismatch, + CapabilityNotAuthorized, UnsupportedNonLoopbackTransport, UnsupportedArtifactEncoding, EvidenceMismatch, diff --git a/tests/authority_protocol.rs b/tests/authority_protocol.rs index b6b075f..258afd1 100644 --- a/tests/authority_protocol.rs +++ b/tests/authority_protocol.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use agenet::protocol::{ - AuthorityClaims, AuthorityScope, BootstrapProfile, CredentialChain, DomainId, + AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityKind, CredentialChain, DomainId, NodeCredentialClaims, NodeId, NodeRole, ProtocolError, SignedAuthorityCredential, verify_credential_chain, }; @@ -43,6 +43,7 @@ fn authority_claims(authority: &SigningKey) -> AuthorityClaims { fn node_claims(authority_id: NodeId, node: &SigningKey) -> NodeCredentialClaims { NodeCredentialClaims { + format_version: "agenet.node-credential.v0.3".to_owned(), domain_id: domain_id(), authority_id, node_id: node_id(), @@ -53,11 +54,73 @@ fn node_claims(authority_id: NodeId, node: &SigningKey) -> NodeCredentialClaims NodeRole::Executor, NodeRole::Verifier, ]), + capability_ceiling: BTreeSet::from([ + CapabilityKind::new("source.metrics.v1").expect("capability") + ]), issued_at_ms: NOW - 1_000, expires_at_ms: NOW + 30_000, } } +#[test] +fn node_credential_version_and_capability_ceiling_fail_closed() { + let (root, authority, node, chain) = valid_chain(); + let verified = verify_credential_chain( + &root.verifying_key(), + &chain, + &domain_id(), + NodeRole::Executor, + NOW, + ) + .expect("v0.3 chain verifies"); + assert_eq!( + verified.capability_ceiling, + BTreeSet::from([CapabilityKind::new("source.metrics.v1").unwrap()]) + ); + + let authority_credential = &chain.authority; + let mut legacy = node_claims(authority_credential.claims.authority_id.clone(), &node); + legacy.format_version = "agenet.node-credential.v0.2".to_owned(); + assert_eq!( + authority_credential.issue_node_credential(&root.verifying_key(), &authority, legacy, NOW,), + Err(ProtocolError::UnsupportedNodeCredentialVersion) + ); + + let mut widened = chain.clone(); + let decoded = STANDARD.decode(&widened.node.claims_base64).unwrap(); + let mut value: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + value["capability_ceiling"] = serde_json::json!(["project.build.v1"]); + widened.node.claims_base64 = STANDARD.encode(serde_json::to_vec(&value).unwrap()); + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &widened, + &domain_id(), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::InvalidCredentialSignature) + ); + + let mut missing_version = chain.clone(); + let decoded = STANDARD + .decode(&missing_version.node.claims_base64) + .unwrap(); + let mut value: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + value.as_object_mut().unwrap().remove("format_version"); + missing_version.node.claims_base64 = STANDARD.encode(serde_json::to_vec(&value).unwrap()); + assert_eq!( + verify_credential_chain( + &root.verifying_key(), + &missing_version, + &domain_id(), + NodeRole::Executor, + NOW, + ), + Err(ProtocolError::UnsupportedNodeCredentialVersion) + ); +} + fn valid_chain() -> (SigningKey, SigningKey, SigningKey, CredentialChain) { let root = signing_key(1); let authority = signing_key(2); @@ -335,6 +398,16 @@ fn founding_scope_is_the_only_directory_issuance_path() { let mut claims = node_claims(authority_credential.claims.authority_id.clone(), &node); claims.bootstrap_profile = BootstrapProfile::Base; claims.allowed_roles = BTreeSet::from([NodeRole::Directory]); + assert_eq!( + authority_credential.issue_founding_directory_credential( + &root.verifying_key(), + &authority, + claims.clone(), + NOW, + ), + Err(ProtocolError::AuthorityScopeViolation) + ); + claims.capability_ceiling.clear(); let node_credential = authority_credential .issue_founding_directory_credential(&root.verifying_key(), &authority, claims, NOW) diff --git a/tests/bootstrap_recovery.rs b/tests/bootstrap_recovery.rs index 5845245..d4e7ff3 100644 --- a/tests/bootstrap_recovery.rs +++ b/tests/bootstrap_recovery.rs @@ -46,14 +46,14 @@ fn journal_replays_exact_committed_phase_and_idempotency() { ); store .apply( - "service-1", - BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), + "ready-1", + BootstrapTransition::Advance(BootstrapPhase::ReadyForEnrollment), ) .unwrap(); drop(store); assert_eq!( BootstrapStateStore::open(&journal).unwrap().phase(), - BootstrapPhase::ServicePrepared + BootstrapPhase::ReadyForEnrollment ); } @@ -64,21 +64,33 @@ fn state_machine_retains_credentials_and_allows_only_explicit_compensation() { let mut store = BootstrapStateStore::open(&journal).unwrap(); for (operation, phase) in [ ("binary", BootstrapPhase::BinaryInstalled), - ("service", BootstrapPhase::ServicePrepared), ("ready", BootstrapPhase::ReadyForEnrollment), ("credential", BootstrapPhase::CredentialIssued), + ("service", BootstrapPhase::ServicePrepared), ] { store .apply(operation, BootstrapTransition::Advance(phase)) .unwrap(); } assert_eq!( - store.apply("unsafe-back", BootstrapTransition::RollbackService), - Err(BootstrapError::InvalidBootstrapTransition) + store + .apply("service-rollback", BootstrapTransition::RollbackService) + .unwrap(), + TransitionOutcome::Applied(BootstrapPhase::CredentialIssued) + ); + assert_eq!( + store.apply("second-rollback", BootstrapTransition::RollbackService), + Err(BootstrapError::InvalidBootstrapTransition), ); drop(store); let mut resumed = BootstrapStateStore::open(&journal).unwrap(); assert_eq!(resumed.phase(), BootstrapPhase::CredentialIssued); + resumed + .apply( + "service-again", + BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), + ) + .unwrap(); resumed .apply( "registered", @@ -110,6 +122,18 @@ fn state_machine_retains_credentials_and_allows_only_explicit_compensation() { BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled), ) .unwrap(); + service + .apply( + "ready", + BootstrapTransition::Advance(BootstrapPhase::ReadyForEnrollment), + ) + .unwrap(); + service + .apply( + "credential", + BootstrapTransition::Advance(BootstrapPhase::CredentialIssued), + ) + .unwrap(); service .apply( "service", @@ -120,7 +144,7 @@ fn state_machine_retains_credentials_and_allows_only_explicit_compensation() { service .apply("rollback", BootstrapTransition::RollbackService) .unwrap(), - TransitionOutcome::Applied(BootstrapPhase::BinaryInstalled) + TransitionOutcome::Applied(BootstrapPhase::CredentialIssued) ); assert_eq!( service.apply("leave", BootstrapTransition::Leave).unwrap(), @@ -139,9 +163,13 @@ fn journal_fails_closed_on_torn_future_corrupt_or_oversized_input() { "torn", b"{\"format\":\"agenet.bootstrap-journal\"".as_slice(), ), + ( + "legacy-v1", + b"{\"format\":\"agenet.bootstrap-journal\",\"schema_version\":1,\"checksum\":\"bdc8387e704610ed49be1132895d53dd03356f66f8fe6d1b730a09c08ef12271\"}\n".as_slice(), + ), ( "future", - b"{\"format\":\"agenet.bootstrap-journal\",\"schema_version\":2}\n".as_slice(), + b"{\"format\":\"agenet.bootstrap-journal\",\"schema_version\":3}\n".as_slice(), ), ("corrupt", b"not-json\n".as_slice()), ] { @@ -193,6 +221,18 @@ fn journal_hash_chain_rejects_a_stable_bit_flip() { BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled), ) .unwrap(); + store + .apply( + "ready", + BootstrapTransition::Advance(BootstrapPhase::ReadyForEnrollment), + ) + .unwrap(); + store + .apply( + "credential", + BootstrapTransition::Advance(BootstrapPhase::CredentialIssued), + ) + .unwrap(); drop(store); let mut bytes = std::fs::read(&journal).unwrap(); let marker = b"\"checksum\":\""; @@ -257,6 +297,18 @@ fn runtime_journal_limit_rejects_before_append_and_restart_remains_valid() { BootstrapTransition::Advance(BootstrapPhase::BinaryInstalled), ) .unwrap(); + store + .apply( + "ready", + BootstrapTransition::Advance(BootstrapPhase::ReadyForEnrollment), + ) + .unwrap(); + store + .apply( + "credential", + BootstrapTransition::Advance(BootstrapPhase::CredentialIssued), + ) + .unwrap(); let mut index = 0_u32; 'cycles: loop { for (name, transition) in [ @@ -264,10 +316,6 @@ fn runtime_journal_limit_rejects_before_append_and_restart_remains_valid() { "service", BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), ), - ( - "ready", - BootstrapTransition::Advance(BootstrapPhase::ReadyForEnrollment), - ), ("rollback", BootstrapTransition::RollbackService), ] { let before = store.phase(); diff --git a/tests/cli_bootstrap.rs b/tests/cli_bootstrap.rs index 8ecc507..ebc3ce0 100644 --- a/tests/cli_bootstrap.rs +++ b/tests/cli_bootstrap.rs @@ -81,6 +81,17 @@ fn internal_node_mode_remains_available() { assert!(String::from_utf8_lossy(&output.stdout).contains("--profile")); } +#[test] +fn process_success_output_is_one_complete_public_line() { + let output = agenet().arg("--version").output().expect("CLI executes"); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let stdout = String::from_utf8(output.stdout).expect("version output is UTF-8"); + assert!(stdout.starts_with("agenet ")); + assert!(stdout.ends_with('\n')); + assert_eq!(stdout.lines().count(), 1); +} + #[test] fn preexisting_domain_state_fails_before_tty_without_overwrite() { let home = tempfile::TempDir::new().expect("temporary home"); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1aa0f4a..677ade6 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,7 +1,7 @@ use std::collections::BTreeSet; use agenet::protocol::{ - AuthorityClaims, AuthorityScope, BootstrapProfile, CredentialChain, DomainId, + AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityKind, CredentialChain, DomainId, NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, }; use base64::{Engine, engine::general_purpose::STANDARD}; @@ -53,12 +53,18 @@ pub fn credential_chain( ), }; let claims = NodeCredentialClaims { + format_version: "agenet.node-credential.v0.3".to_owned(), domain_id: domain_id(), authority_id: authority_credential.claims.authority_id.clone(), node_id: NodeId::new(node_id).expect("valid Node ID"), signing_public_key_base64: STANDARD.encode(node.verifying_key().to_bytes()), bootstrap_profile, allowed_roles, + capability_ceiling: if role == NodeRole::Executor || role == NodeRole::Verifier { + BTreeSet::from([CapabilityKind::new("source.metrics.v1").expect("capability")]) + } else { + BTreeSet::new() + }, issued_at_ms: now_ms - 1_000, expires_at_ms: now_ms + 60_000, }; diff --git a/tests/http_directory.rs b/tests/http_directory.rs index bc765c0..425abbb 100644 --- a/tests/http_directory.rs +++ b/tests/http_directory.rs @@ -119,6 +119,46 @@ async fn signed_manifest_registration_and_deterministic_query_round_trip() { assert_eq!(candidates.candidates, vec![manifest]); } +#[tokio::test] +async fn credential_capability_ceiling_rejects_out_of_scope_manifest() { + let root = signing_key(44); + let directory = identity( + &root, + signing_key(45), + "node:directory-ceiling", + NodeRole::Directory, + ); + let executor = identity( + &root, + signing_key(46), + "node:executor-ceiling", + NodeRole::Executor, + ); + let app = directory_router(DirectoryRegistry::new(), directory, NOW); + let manifest = CapabilityManifest { + capability_id: CapabilityId::new("capability:outside-ceiling").unwrap(), + provider: executor.node_id().clone(), + kind: "project.build".to_owned(), + version: "v1".to_owned(), + description: "must be rejected".to_owned(), + input_profile: "project.v1".to_owned(), + output_profile: "build-result.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: "http://127.0.0.1:41415".to_owned(), + evidence_types: vec![], + expires_at_unix_ms: NOW + 60_000, + }; + let envelope = executor.seal("capability.manifest.v1", &manifest).unwrap(); + let response = app + .oneshot(loopback_request(envelope_request( + "/v0/capabilities/register", + &envelope, + ))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + #[tokio::test] async fn unsigned_and_oversized_directory_requests_are_rejected() { let root = signing_key(50); diff --git a/tests/http_enrollment.rs b/tests/http_enrollment.rs index a834e4f..e31936f 100644 --- a/tests/http_enrollment.rs +++ b/tests/http_enrollment.rs @@ -204,6 +204,10 @@ async fn enrollment_consumes_handoff_and_issues_a_valid_bundle() { ) .expect("credential chain"); assert!(verified.expires_at_ms > invitation_expiry); + assert_eq!( + verified.capability_ceiling, + BTreeSet::from([CapabilityKind::new("source.metrics.v1").unwrap()]) + ); server.stop().await; } diff --git a/tests/protocol_kernel.rs b/tests/protocol_kernel.rs index 6aec1dd..5c302b8 100644 --- a/tests/protocol_kernel.rs +++ b/tests/protocol_kernel.rs @@ -79,12 +79,14 @@ fn credential_chain_for_domain( &root.verifying_key(), &authority, NodeCredentialClaims { + format_version: "agenet.node-credential.v0.3".to_owned(), domain_id, authority_id: authority_credential.claims.authority_id.clone(), node_id: NodeId::new(node_id).expect("valid node id"), signing_public_key_base64: STANDARD.encode(node.verifying_key().to_bytes()), bootstrap_profile, allowed_roles, + capability_ceiling: BTreeSet::new(), issued_at_ms: NOW as i64 - 1_000, expires_at_ms: NOW as i64 + 60_000, }, From ba2f394cdae29bb0eeabc35a1494f856d79ec89b Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 02:25:58 +0800 Subject: [PATCH 32/67] [bug] Remove unchecked capability registration Root cause: A public registry method accepted raw node identifiers and bypassed the verified capability ceiling. Solution: Require an opened envelope for mutation and replace the version-only process check with a replay-backed CLI fixture. Risks: The test fixture feature is disabled by default and read-only. Dependency: Task 10 authorization state from 2c81b23. Links: ROADMAP.md Post-mortem: Remove unused compatibility APIs when they bypass a new authorization invariant; process tests must assert domain behavior. --- Cargo.toml | 4 +++ ROADMAP.md | 12 +++++++- src/cli/mod.rs | 43 +++++++++++++++++++++++++++ src/protocol/envelope.rs | 20 +++++++++++-- src/protocol/mod.rs | 1 + src/runtime/directory.rs | 29 ++++-------------- src/transport/directory.rs | 4 +-- tests/cli_bootstrap.rs | 61 ++++++++++++++++++++++++++++++++++---- 8 files changed, 139 insertions(+), 35 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 36eb847..11eb954 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ license = "MIT" name = "agenet" path = "src/lib.rs" +[features] +default = [] +cli-test-fixture = [] + [[bin]] name = "agenet" path = "src/main.rs" diff --git a/ROADMAP.md b/ROADMAP.md index 877633d..d27dd74 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,15 @@ # ROADMAP +## 2026-08-15 02:15 CST + +- **Change**: Removed the unchecked Directory capability-registration API and replaced the `--version` process assertion with a bootstrap-specific JSON output contract exercised through the real binary. +- **Files**: `src/protocol/envelope.rs`, `src/runtime/directory.rs`, `src/transport/directory.rs`, `src/cli/mod.rs`, `tests/http_directory.rs`, `tests/cli_bootstrap.rs`, `Cargo.toml`, this roadmap, and ignored Task 10 review evidence/report. +- **Root cause**: Security API and test-observable gaps — the HTTP handler enforced the signed capability ceiling, but a public legacy registry method still accepted a raw `NodeId` and inserted a Manifest without verified claims. The subprocess “success” test only executed `--version`, so it never observed a bootstrap phase or advertised next command. +- **Solution**: Delete the unused legacy mutation rather than preserve unsafe compatibility. The only registry mutation now consumes an `OpenedEnvelope` whose private fields can only be produced by exact envelope/credential verification; HTTP opens once, applies revocation policy to those claims, then consumes the same proof. Add a `cli-test-fixture` feature that lets the real binary read a nonsecret, owner-only, replay-validated bootstrap journal and emit the production JSON operator contract; the process test requires exact `CredentialIssued`, checks the follow-up with Clap, and scans stdout/stderr for a sentinel. +- **Prevention**: Every authorization-sensitive collection gets one mutation gate whose input type proves verification; raw identifiers and caller-constructible claims are not compatibility APIs. Process tests must assert domain behavior, not generic executable liveness. +- **Boundary**: The fixture feature injects no passphrase, invitation, credential, or success phase. It only reports an already durable schema-2 `CredentialIssued` journal and is disabled by default. The runner still denies `/dev/tty` even after PTY/session setup, so interactive PTY success remains unclaimed and production TTY behavior is unchanged. +- **Post-mortem**: Classified as a security boundary omission. Compatibility review must begin with a consumer search; an unused API that bypasses a new authorization invariant must be deleted. + ## 2026-08-15 06:20 CST - **Change**: Corrected Task 10 review findings in bootstrap phase truthfulness, durable capability authorization, strict Authority CA reload, and fallible CLI output. @@ -100,7 +110,7 @@ - **Root cause**: Security boundary omission — the first cache accepted any Root-authorized publish credential in the domain, a post-rename directory-sync error left disk and memory potentially divergent without freezing later writes, and Directory query returned a previously registered provider without reevaluating revocation. The enforcement helper also accepted a raw `CredentialChain`, making its verified-chain precondition caller-enforced rather than type-enforced. - **Solution**: Persist and validate a v0.3 stable `(domain, authority_id, signing key)` publisher binding with an explicit publish scope; accept same-key Root credential renewal but reject issuer/key rollover. Treat every persistence error as mutation poison until restart. Carry verified claims out of the single envelope-open operation into a private revocation subject, store it with registration, and return only `CurrentAndAllowed` candidates; stale policy returns HTTP 200 with an empty set. Reject stale incoming snapshots and higher-epoch timestamp regression before persistence. - **Post-mortem**: The initial tests emphasized signature/epoch validity but did not model publisher substitution, uncertain rename durability, or the time gap between registration and routing. Future security reviews must enumerate stable trust pins, explicitly model every atomic-write error point, and retest authorization at each use boundary rather than only at admission. -- **Compatibility**: Legacy `DirectoryRegistry::register/query` remains available for the loopback router. Revocation-aware transport uses the verified registration/query path. Cache persistence is deliberately versioned to v0.3 and fails closed on v0.2; publisher rollover requires a future explicit migration. +- **Compatibility (superseded by Task 10 review round 2)**: The raw `DirectoryRegistry::register` mutation had no consumer and was removed because it bypassed the signed capability ceiling. Query compatibility remains; cache persistence is deliberately versioned to v0.3 and fails closed on v0.2, while publisher rollover requires a future explicit migration. ## 2026-08-15 01:18 CST diff --git a/src/cli/mod.rs b/src/cli/mod.rs index cc1cf09..d2cdc4d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -96,9 +96,52 @@ struct DemoArgs { } pub async fn run() -> i32 { + #[cfg(feature = "cli-test-fixture")] + if let Some(journal) = std::env::var_os("AGENET_CLI_TEST_JOURNAL") { + return run_bootstrap_output_fixture(journal); + } run_cli(Cli::parse()).await } +#[cfg(feature = "cli-test-fixture")] +fn run_bootstrap_output_fixture(journal: std::ffi::OsString) -> i32 { + use crate::bootstrap::{BootstrapPhase, BootstrapStateStore}; + use serde::Serialize; + + #[derive(Serialize)] + struct ResultFixture { + phase: &'static str, + next_commands: [&'static str; 1], + } + + let result = BootstrapStateStore::open(std::path::Path::new(&journal)) + .map_err(|_| CliError::new("FixtureStateInvalid", "Fixture state is invalid.", false)) + .and_then(|state| { + if state.phase() != BootstrapPhase::CredentialIssued { + return Err(CliError::new( + "FixtureStateInvalid", + "Fixture state is invalid.", + false, + )); + } + output::emit( + OutputFormat::Json, + "fixture", + &ResultFixture { + phase: "credential_issued", + next_commands: ["agenet invite create --profile base"], + }, + ) + }); + match result { + Ok(()) => 0, + Err(error) => { + let _ = output::emit_error(OutputFormat::Json, &error); + 1 + } + } +} + async fn run_cli(cli: Cli) -> i32 { let (format, result) = match cli.command { Command::Domain(args) => { diff --git a/src/protocol/envelope.rs b/src/protocol/envelope.rs index 8797b40..aaca604 100644 --- a/src/protocol/envelope.rs +++ b/src/protocol/envelope.rs @@ -80,7 +80,7 @@ impl WireEnvelope { expected_role, now_ms, ) - .map(|opened| opened.payload) + .map(OpenedEnvelope::into_payload) } pub(crate) fn open_with_verified_claims( @@ -136,8 +136,22 @@ impl WireEnvelope { } pub(crate) struct OpenedEnvelope { - pub(crate) payload: T, - pub(crate) claims: VerifiedNodeClaims, + payload: T, + claims: VerifiedNodeClaims, +} + +impl OpenedEnvelope { + pub(crate) fn claims(&self) -> &VerifiedNodeClaims { + &self.claims + } + + pub(crate) fn into_parts(self) -> (T, VerifiedNodeClaims) { + (self.payload, self.claims) + } + + fn into_payload(self) -> T { + self.payload + } } #[derive(Deserialize)] diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 821cdfa..9c5bb16 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -18,6 +18,7 @@ pub use enrollment::{EnrollmentBundle, MAX_ENROLLMENT_CSR_BYTES}; pub(crate) use enrollment::{ EnrollmentRequestClaims, sign_enrollment_claims, verify_enrollment_claims, }; +pub(crate) use envelope::OpenedEnvelope; pub use envelope::WireEnvelope; pub use error::ProtocolError; pub use identity::{ diff --git a/src/runtime/directory.rs b/src/runtime/directory.rs index 331e719..a6f6437 100644 --- a/src/runtime/directory.rs +++ b/src/runtime/directory.rs @@ -3,8 +3,8 @@ use std::{collections::HashMap, net::IpAddr, sync::Arc}; use tokio::sync::RwLock; use crate::protocol::{ - CandidateSet, CapabilityId, CapabilityKind, CapabilityManifest, NodeId, RouteQuery, - VerifiedNodeClaims, + CandidateSet, CapabilityId, CapabilityKind, CapabilityManifest, NodeId, OpenedEnvelope, + RouteQuery, }; use super::{RevocationGuard, RuntimeError, revocation::VerifiedRevocationSubject}; @@ -25,29 +25,12 @@ impl DirectoryRegistry { Self::default() } - pub async fn register( + pub(crate) async fn register_opened( &self, - issuer: &NodeId, - manifest: CapabilityManifest, - now_unix_ms: u64, - ) -> Result<(), RuntimeError> { - validate_manifest(issuer, &manifest, now_unix_ms)?; - self.manifests.write().await.insert( - manifest.capability_id.clone(), - RegisteredManifest { - manifest, - verified_subject: None, - }, - ); - Ok(()) - } - - pub(crate) async fn register_verified( - &self, - claims: &VerifiedNodeClaims, - manifest: CapabilityManifest, + opened: OpenedEnvelope, now_unix_ms: u64, ) -> Result<(), RuntimeError> { + let (manifest, claims) = opened.into_parts(); validate_manifest(&claims.node_id, &manifest, now_unix_ms)?; let kind = CapabilityKind::new(manifest.capability_kind_version()) .map_err(|_| RuntimeError::CapabilityNotAuthorized)?; @@ -58,7 +41,7 @@ impl DirectoryRegistry { manifest.capability_id.clone(), RegisteredManifest { manifest, - verified_subject: Some(VerifiedRevocationSubject::from_claims(claims)), + verified_subject: Some(VerifiedRevocationSubject::from_claims(&claims)), }, ); Ok(()) diff --git a/src/transport/directory.rs b/src/transport/directory.rs index 1146753..3584173 100644 --- a/src/transport/directory.rs +++ b/src/transport/directory.rs @@ -109,7 +109,7 @@ async fn register( Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; if let Some(guard) = &state.revocations - && let Err(policy_error) = guard.effectful_verified_claims(now_ms, &opened.claims) + && let Err(policy_error) = guard.effectful_verified_claims(now_ms, opened.claims()) { return match policy_error { RuntimeError::RevocationStateStale => { @@ -123,7 +123,7 @@ async fn register( } if state .registry - .register_verified(&opened.claims, opened.payload, state.now_unix_ms) + .register_opened(opened, state.now_unix_ms) .await .is_err() { diff --git a/tests/cli_bootstrap.rs b/tests/cli_bootstrap.rs index ebc3ce0..64afad7 100644 --- a/tests/cli_bootstrap.rs +++ b/tests/cli_bootstrap.rs @@ -1,5 +1,10 @@ use std::process::{Command, Stdio}; +#[cfg(feature = "cli-test-fixture")] +use agenet::bootstrap::{BootstrapPhase, BootstrapStateStore, BootstrapTransition}; +#[cfg(feature = "cli-test-fixture")] +use std::os::unix::fs::PermissionsExt; + fn agenet() -> Command { Command::new(env!("CARGO_BIN_EXE_agenet")) } @@ -82,14 +87,58 @@ fn internal_node_mode_remains_available() { } #[test] -fn process_success_output_is_one_complete_public_line() { - let output = agenet().arg("--version").output().expect("CLI executes"); +#[cfg(feature = "cli-test-fixture")] +fn process_bootstrap_success_reports_phase_and_valid_follow_up() { + let fixture = tempfile::TempDir::new().expect("temporary bootstrap fixture"); + std::fs::set_permissions(fixture.path(), std::fs::Permissions::from_mode(0o700)) + .expect("restrict fixture directory"); + let journal = fixture + .path() + .canonicalize() + .expect("canonical fixture directory") + .join("bootstrap-state-v1.jsonl"); + let mut state = BootstrapStateStore::open(&journal).expect("fixture journal opens"); + for (operation, phase) in [ + ("fixture-binary", BootstrapPhase::BinaryInstalled), + ("fixture-ready", BootstrapPhase::ReadyForEnrollment), + ("fixture-credential", BootstrapPhase::CredentialIssued), + ] { + state + .apply(operation, BootstrapTransition::Advance(phase)) + .expect("fixture phase advances"); + } + drop(state); + + let sentinel = "PROCESS-BOOTSTRAP-SENTINEL-MUST-NOT-LEAK"; + let output = agenet() + .env("AGENET_CLI_TEST_JOURNAL", &journal) + .env("AGENET_SENTINEL", sentinel) + .output() + .expect("real AgenNet binary executes fixture boundary"); assert!(output.status.success()); assert!(output.stderr.is_empty()); - let stdout = String::from_utf8(output.stdout).expect("version output is UTF-8"); - assert!(stdout.starts_with("agenet ")); - assert!(stdout.ends_with('\n')); - assert_eq!(stdout.lines().count(), 1); + let result: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("bootstrap result is JSON"); + assert_eq!(result["phase"], "credential_issued"); + assert_eq!( + result["next_commands"][0], + "agenet invite create --profile base" + ); + + let follow_up = result["next_commands"][0] + .as_str() + .expect("follow-up is a command"); + let accepted = agenet() + .args(follow_up.split_whitespace().skip(1)) + .arg("--help") + .output() + .expect("advertised command parses"); + assert!(accepted.status.success()); + assert!(!String::from_utf8_lossy(&output.stdout).contains(sentinel)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(sentinel)); + + let state = BootstrapStateStore::open(&journal).expect("journal reloads after process"); + assert_eq!(state.phase(), BootstrapPhase::CredentialIssued); } #[test] From 0ee88e27818f80502bb1772c63ad9a7351928501 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 03:13:33 +0800 Subject: [PATCH 33/67] [feat][Bootstrap][11/14] Manage user node service Root cause: NA Solution: Add recoverable LaunchAgent and systemd user services without root privileges or embedded secrets. Risks: Nodes start only after the owning user logs in. Dependency: Bootstrap step 10. Links: plan/01-v1-multi-host-node-bootstrap.md --- README.md | 33 ++ ROADMAP.md | 13 + docs/design/agenet-v0.1.md | 1 + ...6-08-14-node-bootstrap-and-pages-design.md | 11 + plan/01-v2-multi-host-node-bootstrap.md | 59 +++ src/cli/domain.rs | 12 +- src/cli/join.rs | 9 +- src/cli/mod.rs | 68 ++- src/cli/node.rs | 441 ++++++++++++++++++ src/runtime/key_store.rs | 31 ++ src/service/linux.rs | 140 ++++++ src/service/macos.rs | 168 +++++++ src/service/mod.rs | 293 +++++++++++- src/service/supervisor.rs | 191 ++++++++ tests/fixtures/service_probe.rs | 5 + tests/service_linux.rs | 167 +++++++ tests/service_macos.rs | 245 ++++++++++ 17 files changed, 1866 insertions(+), 21 deletions(-) create mode 100644 plan/01-v2-multi-host-node-bootstrap.md create mode 100644 src/cli/node.rs create mode 100644 src/service/linux.rs create mode 100644 src/service/macos.rs create mode 100644 src/service/supervisor.rs create mode 100644 tests/fixtures/service_probe.rs create mode 100644 tests/service_linux.rs create mode 100644 tests/service_macos.rs diff --git a/README.md b/README.md index 63025ad..425e7a1 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,39 @@ operator/Task 10 repair is required. AgenNet does not automatically delete the file because a complete header may already be durable even when sync reported an error. +## Login-scoped bootstrap supervisor + +After `domain init` or `node join` has durably reached `CredentialIssued`, the +operator may explicitly run: + +```text +agenet node start +agenet node status --output json +agenet node stop +``` + +`node start` atomically publishes a real user-service definition, records +`ServicePrepared`, and only then asks the login-scoped service manager to start +it. macOS uses +`~/Library/LaunchAgents/org.nexa-language.agenet.plist`; Linux uses +`~/.config/systemd/user/agenet.service`. An activation failure removes that +definition, rolls the journal back to `CredentialIssued`, and retains all +credential material. Repeated start and stop operations reconcile the existing +definition and process without advancing to `Registered` or `Healthy`. + +The Task 11 service runs the internal bootstrap supervisor. It strictly loads +the persisted config, Root/Authority/Node credential chain, signing key, peer +TLS identity, and schema-2 journal; holds the single-instance journal lock; and +exits gracefully on termination. It intentionally opens no network listener. +Status therefore reports `service_process` independently and always reports +`runtime_ready: false` in this phase. Task 12 will attach the v0.2 runtime to +this same entrypoint. This boundary is provisional and may migrate as physical +multi-device evidence exposes better lifecycle semantics. + +These services persist only after the owning user logs in. AgenNet does not use +`sudo`, install a system service, enable Linux linger, embed an environment +file, or put secrets in the service definition. + ## MVP boundary The MVP runs four independent processes on different `127.0.0.1` ports: diff --git a/ROADMAP.md b/ROADMAP.md index d27dd74..c353c03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,18 @@ # ROADMAP +## 2026-08-15 02:45 CST + +- **Change**: Added recoverable macOS LaunchAgent and Linux systemd user-service management, explicit `node start|stop|status`, and a bootstrap-only service supervisor. +- **Files**: `src/service/`, `src/cli/node.rs`, bootstrap CLI follow-ups, service/CLI tests and probe fixture, README, design/spec, focused v2 plan, this roadmap, and ignored Task 11 evidence/report. +- **Decision reason**: Task 11 required a real activatable service before Task 12 owns network-runtime integration, but the only existing internal node entrypoint was the loopback demo harness. Treating it as the host runtime would have selected the wrong roles and transport; installing a process that immediately exits would have fabricated service readiness. +- **Solution**: Add an internal `node service-run --config ` supervisor that validates the full persisted startup bundle and schema-2 journal, holds the bootstrap lock, handles termination, and opens no listener. Publish the service artifact before `ServicePrepared`, activate only after that transition is durable, and compensate activation failure by uninstalling the artifact and rolling back to `CredentialIssued` while retaining credentials. Report process state separately from the always-false pre-Task-12 `runtime_ready` value. +- **Platform boundary**: LaunchAgent uses GUI-user `launchctl bootstrap/bootout`, `RunAtLoad`, bounded failed-exit keepalive, and state-root logs. systemd uses `--user`, `daemon-reload`, `enable --now`, bounded on-failure restart, and user-unit-compatible hardening. Both start only after login; no sudo, system unit, linger, shell, environment file, or embedded secret is used. +- **Verification**: Pure renderers and injected runners cover spaces, escaping, exact argv, idempotent start/stop, unavailable sessions, activation rollback/retry, symlink rejection, and honest status. A uniquely labelled macOS LaunchAgent ran a compiled non-shell probe and was removed through the tested uninstall path with no residual matching definition. +- **Root cause / classification**: Plan integration gap, classified as **误解任务边界**. The plan separated service management and runtime wiring without naming an executable pre-runtime service contract. +- **Prevention**: Every future service task must identify the exact executable entrypoint, its durable readiness predicate, and the next task's handoff before implementation. Phase names, process liveness, network readiness, registration, and health must remain separate observables. +- **Test post-mortem / technical blind spot**: One parallel full-suite run observed `StateLocked` when the rollback/retry test reopened the journal immediately after an assertion constructed a temporary lock-bearing store. The same scenario passed alone. The test now reads phase through a helper that drops the store before returning, and the exact scenario passed five consecutive runs before the full gate was repeated. Tests must never embed lock-owning resources in assertion expressions immediately before a reacquisition step. +- **Boundary**: Task 11 proves login-scoped supervision only. Task 12 must attach the live v0.2 runtime to the same entrypoint before `Registered`, `Healthy`, routing, or multi-host reachability can be claimed. + ## 2026-08-15 02:15 CST - **Change**: Removed the unchecked Directory capability-registration API and replaced the `--version` process assertion with a bootstrap-specific JSON output contract exercised through the real binary. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 53ed77c..bbd86bb 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -61,6 +61,7 @@ The demo provisions an ephemeral Domain Root and four Node Credentials, starts f | Exact-byte Credential, Envelope, Contract, and Event signatures | automated | unit/property tests | | Grant and Artifact read scope | automated | protocol and Axum tests | | Journal replay and operation idempotency | automated | restart test | +| Login-scoped service definition and supervisor | automated + native macOS smoke | runtime remains not ready until Task 12 | | Independent source metric reproduction | automated | separate implementations plus third oracle | | Four PIDs and four dynamic ports | automated | real child-process test | | Real ModelHub Intent projection | verified locally | Walkman env run `3cf7bccc-932b-4b77-9ae5-514f8a52f961` | diff --git a/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md index 6c74c64..2e0d647 100644 --- a/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md +++ b/docs/superpowers/specs/2026-08-14-node-bootstrap-and-pages-design.md @@ -302,6 +302,17 @@ at machine boot without a login session." Enabling Linux linger or installing a system service is outside the no-root default and requires a later, explicit deployment profile. +Task 11 implements an internal `node service-run` bootstrap supervisor so the +service artifact is executable before Task 12 wires the network runtime. The +supervisor revalidates the complete startup bundle and schema-2 journal, holds +the single-instance lock, handles termination, and opens no listener. Its +running process is observable separately from `runtime_ready`, which remains +false; it never claims `Registered` or `Healthy`. First start publishes the +definition, durably advances to `ServicePrepared`, then activates it. Activation +failure removes the definition and applies `RollbackService`, preserving the +credential. Task 12 evolves the same entrypoint rather than adding a second +service contract. This is a provisional, migratable integration boundary. + Private keys and local control tokens remain owner-only. The installer never uses `chmod 777`, disables host security controls, or installs a root service. diff --git a/plan/01-v2-multi-host-node-bootstrap.md b/plan/01-v2-multi-host-node-bootstrap.md new file mode 100644 index 0000000..608efa8 --- /dev/null +++ b/plan/01-v2-multi-host-node-bootstrap.md @@ -0,0 +1,59 @@ +# AgenNet Task 11 bootstrap-supervisor revision + +This focused revision amends Task 11 of +[`01-v1-multi-host-node-bootstrap.md`](01-v1-multi-host-node-bootstrap.md). +All other tasks and global constraints remain unchanged. + +## Goal + +Install and manage a real login-scoped user service without claiming the +Task 12 network runtime already exists. The service runs an internal +`agenet node service-run --config ` bootstrap supervisor. It +validates the persisted startup bundle and schema-2 bootstrap journal, holds +the journal's single-instance lock, handles termination gracefully, and makes +no listener, registration, readiness, or health claim. + +## Preconditions + +- Task 10 is complete at commit `ba2f394` and leaves both founding and joined + nodes at `CredentialIssued`. +- Task 9 startup-bundle validation and owner-only atomic publication remain the + persistence boundary. +- Task 12 will attach the v0.2 runtime to the same internal service entrypoint. + +## Steps + +1. Add failure-first rendering, runner, supervisor, and CLI tests. +2. Render owner-only LaunchAgent and systemd user definitions with absolute + argv paths, no shell, environment file, secret, sudo, system unit, or linger. +3. Publish the definition atomically, append `ServicePrepared`, then activate + and verify the supervisor. On activation failure, stop and remove the + definition and append `RollbackService`, preserving credentials. +4. Make start idempotently reconcile definition, process, and phase; make stop + retain the definition and `ServicePrepared`; report process state separately + from `runtime_ready: false`. +5. Verify both renderers and injected runners, run a uniquely labelled native + smoke test where the host session permits it, and record any real platform + restriction without weakening the automated contract. + +## Acceptance criteria + +- `node start`, `node stop`, and `node status` are Clap-valid and emit typed, + sanitized output. +- `ServicePrepared` is appended only after a durable service artifact exists. +- Activation failure restores `CredentialIssued` and removes only the service + artifact; credentials remain intact. +- A running supervisor has validated startup material, owns the bootstrap lock, + shuts down on a termination signal, opens no network listener, and never + reports `Registered` or `Healthy`. +- macOS and Linux definitions implement their Task 11 login-scoped defaults and + pass security/static scans plus the full Rust quality gates. + +## Risks + +- Login-scoped persistence is unavailable before the owner logs in; Linux + linger and system services remain out of scope. +- Task 11 proves service supervision, not runtime reachability. Until Task 12, + `runtime_ready` is always false. +- Platform service-manager behavior depends on an available GUI/systemd user + session; absence is a typed operational error, not a simulated success. diff --git a/src/cli/domain.rs b/src/cli/domain.rs index 2b7104e..1afbfbd 100644 --- a/src/cli/domain.rs +++ b/src/cli/domain.rs @@ -75,14 +75,14 @@ impl DomainArgs { } #[derive(Serialize)] -pub(super) struct DomainInitResult { +pub(crate) struct DomainInitResult { domain_id: String, founding_node_id: String, root_fingerprint_sha256: String, authority_endpoint: String, directory_endpoint: String, phase: &'static str, - next_commands: [&'static str; 1], + next_commands: [&'static str; 2], } pub fn execute(args: DomainArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { @@ -128,7 +128,7 @@ fn init(args: InitArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> output::emit( args.output, &format!( - "AgenNet Domain {} created. Next: agenet invite create --profile base", + "AgenNet Domain {} created. Next: agenet node start; then create invitations as needed", result.domain_id ), &result, @@ -167,7 +167,7 @@ fn prompt_new_passphrase(terminal: &impl SecretTerminal) -> Result Result<( let result = execute_at(&paths, args, terminal).await?; output::emit( format, - &format!( - "Node {} enrolled. Service installation is not available in this preview.", - result.node_id - ), + &format!("Node {} enrolled. Next: agenet node start", result.node_id), &result, ) } @@ -197,7 +194,7 @@ async fn execute_at( domain_id: bundle.domain_id.as_str().to_owned(), phase: "credential_issued", operation_id: pending.operation_id.to_string(), - next_command: None, + next_command: Some("agenet node start"), }; Ok(result) } @@ -654,7 +651,7 @@ mod tests { .await .expect("join"); assert_eq!(result.phase, "credential_issued"); - assert_eq!(result.next_command, None); + assert_eq!(result.next_command, Some("agenet node start")); assert_eq!( BootstrapStateStore::open(&paths.journal_file) .expect("state") diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d2cdc4d..553617b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,6 +2,8 @@ mod domain; mod invite; mod join; mod output; +#[path = "node.rs"] +mod service_node; use crate::{bootstrap::network::NetworkBoundary, demo, node}; use age::secrecy::SecretString; @@ -20,6 +22,15 @@ trait SecretTerminal { ) -> Result<(), crate::bootstrap::BootstrapError>; } +#[cfg(test)] +pub(crate) fn provision_test_domain( + paths: crate::bootstrap::NodePaths, + boundary: crate::bootstrap::network::NetworkBoundary, + passphrase: SecretString, +) -> Result<(), CliError> { + domain::provision(paths, boundary, passphrase).map(|_| ()) +} + struct ControllingTerminal; impl SecretTerminal for ControllingTerminal { @@ -83,6 +94,11 @@ struct NodeArgs { #[derive(Debug, Subcommand)] enum NodeBootstrapCommand { Join(join::JoinArgs), + Start(service_node::ServiceArgs), + Stop(service_node::ServiceArgs), + Status(service_node::ServiceArgs), + #[command(hide = true)] + ServiceRun(service_node::ServiceRunArgs), } #[derive(Debug, Args)] @@ -152,14 +168,29 @@ async fn run_cli(cli: Cli) -> i32 { let format = args.output(); (format, invite::execute(args, &ControllingTerminal)) } - Command::Node(args) => { - if let Some(NodeBootstrapCommand::Join(join_args)) = args.bootstrap_command { + Command::Node(args) => match args.bootstrap_command { + Some(NodeBootstrapCommand::Join(join_args)) => { let format = join_args.output; (format, join::execute(join_args, &ControllingTerminal).await) - } else { - (OutputFormat::Human, run_internal_node(args).await) } - } + Some(NodeBootstrapCommand::Start(service_args)) => { + let format = service_args.output; + (format, service_node::start(service_args)) + } + Some(NodeBootstrapCommand::Stop(service_args)) => { + let format = service_args.output; + (format, service_node::stop(service_args)) + } + Some(NodeBootstrapCommand::Status(service_args)) => { + let format = service_args.output; + (format, service_node::status(service_args)) + } + Some(NodeBootstrapCommand::ServiceRun(service_args)) => ( + OutputFormat::Human, + service_node::service_run(service_args).await, + ), + None => (OutputFormat::Human, run_internal_node(args).await), + }, Command::Demo(args) => ( OutputFormat::Human, demo::run(demo::DemoOptions { @@ -213,13 +244,34 @@ async fn run_internal_node(args: NodeArgs) -> Result<(), CliError> { #[cfg(test)] mod tests { - use clap::Parser; + use clap::{CommandFactory, Parser}; use super::Cli; #[test] fn advertised_domain_follow_up_is_a_clap_accepted_command() { - let parsed = Cli::try_parse_from(["agenet", "invite", "create", "--profile", "base"]); - assert!(parsed.is_ok()); + for command in [ + vec!["agenet", "invite", "create", "--profile", "base"], + vec!["agenet", "node", "start"], + ] { + assert!(Cli::try_parse_from(command).is_ok()); + } + } + + #[test] + fn public_service_commands_are_visible_and_internal_supervisor_is_hidden() { + let help = Cli::try_parse_from(["agenet", "node", "start", "--output", "json"]); + assert!(help.is_ok()); + assert!(Cli::try_parse_from(["agenet", "node", "stop"]).is_ok()); + assert!(Cli::try_parse_from(["agenet", "node", "status"]).is_ok()); + let mut command = Cli::command(); + let node = command.find_subcommand_mut("node").unwrap(); + let mut output = Vec::new(); + node.write_long_help(&mut output).unwrap(); + let help = String::from_utf8(output).unwrap(); + assert!(!help.contains("service-run")); + for public in ["start", "stop", "status"] { + assert!(help.contains(public)); + } } } diff --git a/src/cli/node.rs b/src/cli/node.rs new file mode 100644 index 0000000..165f80a --- /dev/null +++ b/src/cli/node.rs @@ -0,0 +1,441 @@ +use std::path::PathBuf; + +use clap::Args; +use serde::Serialize; + +use crate::{ + bootstrap::{BootstrapPhase, BootstrapStateStore, BootstrapTransition, NodePaths}, + service::{DirectServiceCommandRunner, ServiceError, ServiceSpec, UserServiceManager}, +}; + +use super::output::{self, CliError, OutputFormat}; + +const SERVICE_LABEL: &str = "org.nexa-language.agenet"; +const SERVICE_ADVANCE_OPERATION: &str = "node-service-prepare-v1"; +const SERVICE_ROLLBACK_OPERATION: &str = "node-service-rollback-v1"; + +#[derive(Debug, Args)] +pub struct ServiceArgs { + #[arg(long, value_enum, default_value = "human")] + pub output: OutputFormat, +} + +#[derive(Debug, Args)] +pub struct ServiceRunArgs { + #[arg(long)] + pub config: PathBuf, +} + +#[derive(Debug, Serialize)] +struct ServiceResult { + phase: &'static str, + service_installed: bool, + service_process: crate::service::ServiceProcessState, + runtime_ready: bool, + persistence: &'static str, +} + +pub fn start(args: ServiceArgs) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + let manager = platform_manager(service_spec(&paths)?); + start_with(&paths, manager.as_ref())?; + emit_status( + args.output, + &paths, + manager.as_ref(), + "AgenNet supervisor is running", + ) +} + +pub fn stop(args: ServiceArgs) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + let manager = platform_manager(service_spec(&paths)?); + let phase = current_service_phase(&paths)?; + if !matches!( + phase, + BootstrapPhase::ServicePrepared | BootstrapPhase::Registered | BootstrapPhase::Healthy + ) { + return Err(not_prepared()); + } + manager.stop().map_err(map_service)?; + emit_status( + args.output, + &paths, + manager.as_ref(), + "AgenNet supervisor is stopped; login-scoped service remains installed", + ) +} + +pub fn status(args: ServiceArgs) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + let manager = platform_manager(service_spec(&paths)?); + emit_status( + args.output, + &paths, + manager.as_ref(), + "AgenNet service status", + ) +} + +pub async fn service_run(args: ServiceRunArgs) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + crate::service::supervisor::run(&paths, &args.config) + .await + .map_err(map_service) +} + +fn start_with(paths: &NodePaths, manager: &dyn UserServiceManager) -> Result<(), CliError> { + let phase = current_service_phase(paths)?; + let rollback_on_failure = match phase { + BootstrapPhase::CredentialIssued => { + manager + .install(&service_spec(paths)?) + .map_err(map_service)?; + let mut state = + BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; + if let Err(error) = state.apply( + &operation_id(SERVICE_ADVANCE_OPERATION), + BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), + ) { + drop(state); + let _ = manager.uninstall(); + return Err(map_bootstrap(error)); + } + true + } + BootstrapPhase::ServicePrepared => { + manager + .install(&service_spec(paths)?) + .map_err(map_service)?; + true + } + BootstrapPhase::Registered | BootstrapPhase::Healthy => { + manager + .install(&service_spec(paths)?) + .map_err(map_service)?; + false + } + BootstrapPhase::Absent + | BootstrapPhase::BinaryInstalled + | BootstrapPhase::ReadyForEnrollment + | BootstrapPhase::Left => return Err(not_prepared()), + }; + if let Err(error) = manager.start() { + let _ = manager.stop(); + if rollback_on_failure { + let _ = manager.uninstall(); + rollback_service(paths)?; + } + return Err(map_service(error)); + } + Ok(()) +} + +fn rollback_service(paths: &NodePaths) -> Result<(), CliError> { + let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; + state + .apply( + &operation_id(SERVICE_ROLLBACK_OPERATION), + BootstrapTransition::RollbackService, + ) + .map_err(map_bootstrap)?; + Ok(()) +} + +fn operation_id(prefix: &str) -> String { + format!("{prefix}-{}", uuid::Uuid::new_v4()) +} + +fn emit_status( + format: OutputFormat, + paths: &NodePaths, + manager: &dyn UserServiceManager, + human: &str, +) -> Result<(), CliError> { + let status = manager.status().map_err(map_service)?; + let result = ServiceResult { + phase: phase_name(current_service_phase(paths)?), + service_installed: status.installed, + service_process: status.process, + runtime_ready: false, + persistence: "after_user_login", + }; + output::emit(format, human, &result) +} + +fn current_service_phase(paths: &NodePaths) -> Result { + match std::fs::symlink_metadata(&paths.journal_file) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(BootstrapPhase::Absent); + } + Ok(_) => {} + Err(_) => return Err(internal()), + } + BootstrapStateStore::open(&paths.journal_file) + .map(|state| state.phase()) + .map_err(map_bootstrap) +} + +fn service_spec(paths: &NodePaths) -> Result { + let label = if cfg!(target_os = "linux") { + "agenet" + } else { + SERVICE_LABEL + }; + let spec = ServiceSpec { + label: label.to_owned(), + executable: std::env::current_exe().map_err(|_| internal())?, + config: paths.config_file.clone(), + state_root: paths.state_dir.clone(), + definition: paths.service_definition.clone(), + }; + spec.validate().map_err(map_service)?; + Ok(spec) +} + +#[cfg(target_os = "macos")] +fn platform_manager(spec: ServiceSpec) -> Box { + Box::new(crate::service::macos::MacOsUserServiceManager::new( + spec, + unsafe { libc::geteuid() }, + DirectServiceCommandRunner, + )) +} + +#[cfg(target_os = "linux")] +fn platform_manager(spec: ServiceSpec) -> Box { + Box::new(crate::service::linux::LinuxUserServiceManager::new( + spec, + DirectServiceCommandRunner, + )) +} + +fn phase_name(phase: BootstrapPhase) -> &'static str { + match phase { + BootstrapPhase::Absent => "absent", + BootstrapPhase::BinaryInstalled => "binary_installed", + BootstrapPhase::ReadyForEnrollment => "ready_for_enrollment", + BootstrapPhase::CredentialIssued => "credential_issued", + BootstrapPhase::ServicePrepared => "service_prepared", + BootstrapPhase::Registered => "registered", + BootstrapPhase::Healthy => "healthy", + BootstrapPhase::Left => "left", + } +} + +fn map_service(error: ServiceError) -> CliError { + match error { + ServiceError::UserSessionUnavailable => CliError::new( + "UserServiceSessionUnavailable", + "A login-scoped user service session is unavailable.", + true, + ), + ServiceError::InvalidSpec | ServiceError::UnsafeServicePath => CliError::new( + "UnsafeServiceConfiguration", + "The user service configuration is unsafe.", + false, + ), + ServiceError::SupervisorInvalid => CliError::new( + "SupervisorStateInvalid", + "The supervisor rejected persisted node state.", + false, + ), + ServiceError::RenderFailed + | ServiceError::CommandUnavailable + | ServiceError::CommandFailed + | ServiceError::CommandTimedOut + | ServiceError::PersistenceUnavailable => CliError::new( + "UserServiceFailed", + "The login-scoped user service operation failed.", + true, + ), + } +} + +fn map_bootstrap(_: crate::bootstrap::BootstrapError) -> CliError { + internal() +} + +fn not_prepared() -> CliError { + CliError::new( + "CredentialRequired", + "A validated node credential is required before service preparation.", + false, + ) +} + +fn internal() -> CliError { + CliError::new( + "BootstrapStateInvalid", + "The persisted bootstrap state is invalid.", + false, + ) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + use std::sync::{Arc, Mutex}; + + use tempfile::TempDir; + + use super::*; + + #[derive(Default)] + struct FakeState { + installed: bool, + running: bool, + fail_start: bool, + starts: usize, + } + + struct FakeManager(Arc>); + + impl UserServiceManager for FakeManager { + fn render(&self, _: &ServiceSpec) -> Result, ServiceError> { + Ok(b"service".to_vec()) + } + + fn install(&self, _: &ServiceSpec) -> Result { + self.0.lock().unwrap().installed = true; + Ok(crate::service::ServiceStatus::stopped(true)) + } + + fn start(&self) -> Result { + let mut state = self.0.lock().unwrap(); + state.starts += 1; + if state.fail_start { + return Err(ServiceError::CommandFailed); + } + state.running = true; + Ok(crate::service::ServiceStatus::running()) + } + + fn stop(&self) -> Result { + let mut state = self.0.lock().unwrap(); + state.running = false; + Ok(crate::service::ServiceStatus::stopped(state.installed)) + } + + fn uninstall(&self) -> Result<(), ServiceError> { + self.0.lock().unwrap().installed = false; + Ok(()) + } + + fn status(&self) -> Result { + let state = self.0.lock().unwrap(); + Ok(if state.running { + crate::service::ServiceStatus::running() + } else { + crate::service::ServiceStatus::stopped(state.installed) + }) + } + } + + fn credential_paths() -> (TempDir, NodePaths) { + let temp = TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let paths = NodePaths::resolve( + crate::bootstrap::UserPlatform::MacOs, + &crate::bootstrap::NodePathEnvironment::new(root, None, None), + ) + .unwrap(); + paths.ensure_secure_layout().unwrap(); + std::fs::write(&paths.credential_file, b"credential-sentinel").unwrap(); + std::fs::set_permissions( + &paths.credential_file, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let mut state = BootstrapStateStore::open(&paths.journal_file).unwrap(); + for (operation, phase) in [ + ("test-binary", BootstrapPhase::BinaryInstalled), + ("test-ready", BootstrapPhase::ReadyForEnrollment), + ("test-credential", BootstrapPhase::CredentialIssued), + ] { + state + .apply(operation, BootstrapTransition::Advance(phase)) + .unwrap(); + } + drop(state); + (temp, paths) + } + + #[test] + fn service_status_never_claims_runtime_readiness() { + assert_eq!( + phase_name(BootstrapPhase::ServicePrepared), + "service_prepared" + ); + assert!(!crate::service::ServiceStatus::running().runtime_ready); + } + + #[test] + fn absent_status_does_not_create_a_bootstrap_journal() { + let temp = TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let paths = NodePaths::resolve( + crate::bootstrap::UserPlatform::MacOs, + &crate::bootstrap::NodePathEnvironment::new(root, None, None), + ) + .unwrap(); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::Absent + ); + assert!(!paths.journal_file.exists()); + assert!(!paths.state_dir.exists()); + } + + #[test] + fn activation_failure_removes_service_and_rolls_back_but_keeps_credential_phase() { + let (_temp, paths) = credential_paths(); + let state = Arc::new(Mutex::new(FakeState { + fail_start: true, + ..FakeState::default() + })); + assert!(start_with(&paths, &FakeManager(Arc::clone(&state))).is_err()); + assert!(!state.lock().unwrap().installed); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::CredentialIssued + ); + assert_eq!( + std::fs::read(&paths.credential_file).unwrap(), + b"credential-sentinel" + ); + + state.lock().unwrap().fail_start = false; + start_with(&paths, &FakeManager(Arc::clone(&state))).unwrap(); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::ServicePrepared + ); + + { + let mut current = state.lock().unwrap(); + current.running = false; + current.fail_start = true; + } + assert!(start_with(&paths, &FakeManager(Arc::clone(&state))).is_err()); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::CredentialIssued + ); + } + + #[test] + fn repeated_start_is_idempotent_and_never_advances_past_service_prepared() { + let (_temp, paths) = credential_paths(); + let state = Arc::new(Mutex::new(FakeState::default())); + let manager = FakeManager(Arc::clone(&state)); + start_with(&paths, &manager).unwrap(); + start_with(&paths, &manager).unwrap(); + assert!(state.lock().unwrap().running); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::ServicePrepared + ); + } +} diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index 0c2ca02..d6fc8fc 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -196,6 +196,37 @@ pub(crate) fn atomic_write_owner_only( atomic_write_owner_only_with_policy(path, bytes, replace, false) } +pub(crate) fn ensure_secure_user_service_dir(path: &Path) -> Result<(), RuntimeError> { + create_owner_only_components(path)?; + open_verified_owner_directory(path, false).map(|_| ()) +} + +pub(crate) fn remove_owner_only_user_service_file(path: &Path) -> Result<(), RuntimeError> { + let parent = open_verified_owner_directory(normalized_parent(path)?, false)?; + let name = c_name(path.file_name().ok_or(RuntimeError::Io)?)?; + let file = match openat_owner_file(&parent, &name, libc::O_RDONLY, false) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(RuntimeError::Io), + }; + let opened = file.metadata()?; + let mut current = std::mem::MaybeUninit::::uninit(); + cvt(unsafe { + libc::fstatat( + parent.as_raw_fd(), + name.as_ptr(), + current.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + })?; + let current = unsafe { current.assume_init() }; + if opened.dev() != current.st_dev as u64 || opened.ino() != current.st_ino { + return Err(RuntimeError::Io); + } + unlink_relative(&parent, &name)?; + parent.sync_all().map_err(Into::into) +} + pub(crate) fn atomic_write_owner_only_strict( path: &Path, bytes: &[u8], diff --git a/src/service/linux.rs b/src/service/linux.rs new file mode 100644 index 0000000..951f6cf --- /dev/null +++ b/src/service/linux.rs @@ -0,0 +1,140 @@ +use std::{ffi::OsString, sync::Arc}; + +use super::{ + ServiceCommandRunner, ServiceError, ServiceProcessState, ServiceSpec, ServiceStatus, + UserServiceManager, command, publish_definition, remove_definition, +}; + +pub fn render_systemd_user_unit(spec: &ServiceSpec) -> Result, ServiceError> { + spec.validate()?; + let executable = systemd_quote(&spec.executable.to_string_lossy())?; + let config = systemd_quote(&spec.config.to_string_lossy())?; + Ok(format!( + "[Unit]\nDescription=AgenNet bootstrap supervisor\nAfter=network.target\n\ + StartLimitIntervalSec=60\nStartLimitBurst=5\n\n\ + [Service]\nType=simple\nExecStart={executable} node service-run --config {config}\n\ + Restart=on-failure\nRestartSec=5s\nNoNewPrivileges=true\nPrivateTmp=true\n\ + ProtectSystem=strict\nProtectHome=read-only\n\ + ReadWritePaths={}\n\n[Install]\nWantedBy=default.target\n", + systemd_quote(&spec.state_root.to_string_lossy())? + ) + .into_bytes()) +} + +fn systemd_quote(value: &str) -> Result { + if value.is_empty() || value.chars().any(char::is_control) { + return Err(ServiceError::InvalidSpec); + } + Ok(format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%") + )) +} + +pub struct LinuxUserServiceManager { + spec: ServiceSpec, + runner: Arc, +} + +impl LinuxUserServiceManager { + pub fn new(spec: ServiceSpec, runner: impl ServiceCommandRunner + 'static) -> Self { + Self { + spec, + runner: Arc::new(runner), + } + } + + fn systemctl(&self, arguments: Vec) -> Result { + self.runner + .run(&command("/usr/bin/systemctl", arguments)) + .map(|value| value.success) + } +} + +impl UserServiceManager for LinuxUserServiceManager { + fn render(&self, spec: &ServiceSpec) -> Result, ServiceError> { + render_systemd_user_unit(spec) + } + + fn install(&self, spec: &ServiceSpec) -> Result { + if spec != &self.spec { + return Err(ServiceError::InvalidSpec); + } + publish_definition(spec, &self.render(spec)?)?; + if !self.systemctl(vec!["--user".into(), "daemon-reload".into()])? { + let _ = remove_definition(spec); + return Err(ServiceError::UserSessionUnavailable); + } + Ok(ServiceStatus::stopped(true)) + } + + fn start(&self) -> Result { + if self.status()?.process == ServiceProcessState::Running { + return Ok(ServiceStatus::running()); + } + if !self.systemctl(vec![ + "--user".into(), + "enable".into(), + "--now".into(), + "agenet.service".into(), + ])? { + return Err(ServiceError::UserSessionUnavailable); + } + let status = self.status()?; + if status.process != ServiceProcessState::Running { + return Err(ServiceError::CommandFailed); + } + Ok(status) + } + + fn stop(&self) -> Result { + if self.status()?.process == ServiceProcessState::Stopped { + return Ok(ServiceStatus::stopped(self.spec.definition.exists())); + } + if !self.systemctl(vec![ + "--user".into(), + "stop".into(), + "agenet.service".into(), + ])? { + return Err(ServiceError::CommandFailed); + } + Ok(ServiceStatus::stopped(self.spec.definition.exists())) + } + + fn uninstall(&self) -> Result<(), ServiceError> { + let _ = self.systemctl(vec![ + "--user".into(), + "disable".into(), + "--now".into(), + "agenet.service".into(), + ]); + remove_definition(&self.spec)?; + if !self.systemctl(vec!["--user".into(), "daemon-reload".into()])? { + return Err(ServiceError::UserSessionUnavailable); + } + Ok(()) + } + + fn status(&self) -> Result { + let installed = self.spec.definition.exists(); + let outcome = self.runner.run(&command( + "/usr/bin/systemctl", + vec![ + "--user".into(), + "is-active".into(), + "--quiet".into(), + "agenet.service".into(), + ], + ))?; + Ok(if outcome.success { + ServiceStatus::running() + } else if matches!(outcome.code, Some(3 | 4)) { + ServiceStatus::stopped(installed) + } else { + return Err(ServiceError::UserSessionUnavailable); + }) + } +} diff --git a/src/service/macos.rs b/src/service/macos.rs new file mode 100644 index 0000000..7fb2c0d --- /dev/null +++ b/src/service/macos.rs @@ -0,0 +1,168 @@ +use std::{ffi::OsString, fmt::Write as _, sync::Arc}; + +use super::{ + ServiceCommandRunner, ServiceError, ServiceProcessState, ServiceSpec, ServiceStatus, + UserServiceManager, command, publish_definition, remove_definition, +}; + +pub fn render_launch_agent(spec: &ServiceSpec) -> Result, ServiceError> { + spec.validate()?; + let mut xml = String::from( + "\n\ + \n\ + \n", + ); + push_key_string(&mut xml, "Label", &spec.label)?; + xml.push_str("ProgramArguments\n"); + for argument in [ + spec.executable.to_string_lossy().as_ref(), + "node", + "service-run", + "--config", + spec.config.to_string_lossy().as_ref(), + ] { + writeln!(xml, "{}", xml_escape(argument)) + .map_err(|_| ServiceError::RenderFailed)?; + } + xml.push_str("\nRunAtLoad\n"); + xml.push_str( + "KeepAliveSuccessfulExit\n\ + ThrottleInterval5\n", + ); + push_key_string( + &mut xml, + "StandardOutPath", + &spec.stdout_log().to_string_lossy(), + )?; + push_key_string( + &mut xml, + "StandardErrorPath", + &spec.stderr_log().to_string_lossy(), + )?; + xml.push_str("\n"); + Ok(xml.into_bytes()) +} + +fn push_key_string(output: &mut String, key: &str, value: &str) -> Result<(), ServiceError> { + writeln!( + output, + "{}{}", + xml_escape(key), + xml_escape(value) + ) + .map_err(|_| ServiceError::RenderFailed) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +pub struct MacOsUserServiceManager { + spec: ServiceSpec, + uid: u32, + runner: Arc, +} + +impl MacOsUserServiceManager { + pub fn new(spec: ServiceSpec, uid: u32, runner: impl ServiceCommandRunner + 'static) -> Self { + Self { + spec, + uid, + runner: Arc::new(runner), + } + } + + fn target(&self) -> OsString { + format!("gui/{}/{}", self.uid, self.spec.label).into() + } + + fn domain(&self) -> OsString { + format!("gui/{}", self.uid).into() + } + + fn query(&self) -> Result<(bool, bool), ServiceError> { + let result = self.runner.run(&command( + "/bin/launchctl", + vec!["print".into(), self.target()], + ))?; + let running = result.success + && std::str::from_utf8(&result.stdout) + .is_ok_and(|output| output.contains("state = running") && output.contains("pid =")); + Ok((result.success, running)) + } +} + +impl UserServiceManager for MacOsUserServiceManager { + fn render(&self, spec: &ServiceSpec) -> Result, ServiceError> { + render_launch_agent(spec) + } + + fn install(&self, spec: &ServiceSpec) -> Result { + if spec != &self.spec { + return Err(ServiceError::InvalidSpec); + } + publish_definition(spec, &self.render(spec)?)?; + Ok(ServiceStatus::stopped(true)) + } + + fn start(&self) -> Result { + let (loaded, running) = self.query()?; + if running { + return Ok(ServiceStatus::running()); + } + let arguments = if loaded { + vec!["kickstart".into(), "-kp".into(), self.target()] + } else { + vec![ + "bootstrap".into(), + self.domain(), + self.spec.definition.as_os_str().to_owned(), + ] + }; + let result = self.runner.run(&command("/bin/launchctl", arguments))?; + if !result.success { + return Err(ServiceError::UserSessionUnavailable); + } + let status = self.status()?; + if status.process != ServiceProcessState::Running { + return Err(ServiceError::CommandFailed); + } + Ok(status) + } + + fn stop(&self) -> Result { + let (loaded, _) = self.query()?; + if !loaded { + return Ok(ServiceStatus::stopped(self.spec.definition.exists())); + } + let result = self.runner.run(&command( + "/bin/launchctl", + vec!["bootout".into(), self.target()], + ))?; + if !result.success { + return Err(ServiceError::CommandFailed); + } + Ok(ServiceStatus::stopped(self.spec.definition.exists())) + } + + fn uninstall(&self) -> Result<(), ServiceError> { + let _ = self.stop(); + remove_definition(&self.spec) + } + + fn status(&self) -> Result { + let installed = self.spec.definition.exists(); + let (_, running) = self.query()?; + Ok(if running { + ServiceStatus::running() + } else { + ServiceStatus::stopped(installed) + }) + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs index 68c836c..04431ea 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -1 +1,292 @@ -//! User-service integration boundary for the v0.2 multi-host preview. +//! Login-scoped user-service integration for the v0.2 preview. + +pub mod linux; +pub mod macos; +pub mod supervisor; + +use std::{ + ffi::OsString, + fmt::{Debug, Formatter}, + io::Read, + path::{Component, Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +use crate::runtime::key_store::{ + atomic_write_owner_only, ensure_secure_user_service_dir, remove_owner_only_user_service_file, +}; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(10); +const POLL_INTERVAL: Duration = Duration::from_millis(10); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceSpec { + pub label: String, + pub executable: PathBuf, + pub config: PathBuf, + pub state_root: PathBuf, + pub definition: PathBuf, +} + +impl ServiceSpec { + pub fn validate(&self) -> Result<(), ServiceError> { + if self.label.is_empty() + || self.label.len() > 128 + || self.label.chars().any(|value| value.is_control()) + { + return Err(ServiceError::InvalidSpec); + } + for path in [ + &self.executable, + &self.config, + &self.state_root, + &self.definition, + ] { + validate_managed_path(path)?; + } + Ok(()) + } + + pub fn stdout_log(&self) -> PathBuf { + self.state_root.join("agenet-service.stdout.log") + } + + pub fn stderr_log(&self) -> PathBuf { + self.state_root.join("agenet-service.stderr.log") + } +} + +fn validate_managed_path(path: &Path) -> Result<(), ServiceError> { + if !path.is_absolute() + || path == Path::new("/") + || path + .components() + .any(|part| matches!(part, Component::ParentDir)) + || path.as_os_str().as_encoded_bytes().contains(&0) + || path.starts_with("/root") + || path.starts_with("/Library/LaunchDaemons") + || path.starts_with("/Library/LaunchAgents") + || path.starts_with("/etc/systemd") + || path.starts_with("/usr/lib/systemd") + { + return Err(ServiceError::InvalidSpec); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceProcessState { + Running, + Stopped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +pub struct ServiceStatus { + pub installed: bool, + pub process: ServiceProcessState, + pub runtime_ready: bool, +} + +impl ServiceStatus { + pub const fn stopped(installed: bool) -> Self { + Self { + installed, + process: ServiceProcessState::Stopped, + runtime_ready: false, + } + } + + pub const fn running() -> Self { + Self { + installed: true, + process: ServiceProcessState::Running, + runtime_ready: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceError { + InvalidSpec, + UnsafeServicePath, + RenderFailed, + CommandUnavailable, + CommandFailed, + CommandTimedOut, + UserSessionUnavailable, + SupervisorInvalid, + PersistenceUnavailable, +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for ServiceError {} + +#[derive(Clone, PartialEq, Eq)] +pub struct ServiceCommand { + pub program: PathBuf, + pub arguments: Vec, + pub timeout: Duration, +} + +impl Debug for ServiceCommand { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ServiceCommand") + .field("program", &self.program) + .field("argument_count", &self.arguments.len()) + .field("timeout", &self.timeout) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ServiceCommandOutcome { + pub success: bool, + pub code: Option, + pub stdout: Vec, +} + +impl Debug for ServiceCommandOutcome { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ServiceCommandOutcome") + .field("success", &self.success) + .field("code", &self.code) + .field("stdout_bytes", &self.stdout.len()) + .finish() + } +} + +pub trait ServiceCommandRunner: Send + Sync { + fn run(&self, command: &ServiceCommand) -> Result; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct DirectServiceCommandRunner; + +impl ServiceCommandRunner for DirectServiceCommandRunner { + fn run(&self, command: &ServiceCommand) -> Result { + if !command.program.is_absolute() || command.timeout.is_zero() { + return Err(ServiceError::InvalidSpec); + } + let mut child = Command::new(&command.program) + .args(&command.arguments) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| ServiceError::CommandUnavailable)?; + let stdout = child.stdout.take().ok_or(ServiceError::CommandFailed)?; + let reader = thread::spawn(move || { + let mut bytes = Vec::new(); + stdout + .take(64 * 1024 + 1) + .read_to_end(&mut bytes) + .map(|_| bytes) + }); + let deadline = Instant::now() + .checked_add(command.timeout) + .ok_or(ServiceError::CommandTimedOut)?; + loop { + match child.try_wait() { + Ok(Some(status)) => { + let stdout = reader + .join() + .map_err(|_| ServiceError::CommandFailed)? + .map_err(|_| ServiceError::CommandFailed)?; + if stdout.len() > 64 * 1024 { + return Err(ServiceError::CommandFailed); + } + return Ok(ServiceCommandOutcome { + success: status.success(), + code: status.code(), + stdout, + }); + } + Ok(None) if Instant::now() < deadline => thread::sleep(POLL_INTERVAL), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return Err(ServiceError::CommandTimedOut); + } + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + return Err(ServiceError::CommandFailed); + } + } + } + } +} + +pub trait UserServiceManager { + fn render(&self, spec: &ServiceSpec) -> Result, ServiceError>; + fn install(&self, spec: &ServiceSpec) -> Result; + fn start(&self) -> Result; + fn stop(&self) -> Result; + fn uninstall(&self) -> Result<(), ServiceError>; + fn status(&self) -> Result; +} + +pub(crate) fn publish_definition(spec: &ServiceSpec, bytes: &[u8]) -> Result<(), ServiceError> { + spec.validate()?; + let parent = spec.definition.parent().ok_or(ServiceError::InvalidSpec)?; + ensure_secure_user_service_dir(parent).map_err(|_| ServiceError::UnsafeServicePath)?; + match std::fs::symlink_metadata(&spec.definition) { + Ok(metadata) if !metadata.file_type().is_file() || metadata.file_type().is_symlink() => { + return Err(ServiceError::UnsafeServicePath); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(ServiceError::PersistenceUnavailable), + } + atomic_write_owner_only(&spec.definition, bytes, true) + .map_err(|_| ServiceError::PersistenceUnavailable) +} + +pub(crate) fn remove_definition(spec: &ServiceSpec) -> Result<(), ServiceError> { + remove_owner_only_user_service_file(&spec.definition) + .map_err(|_| ServiceError::UnsafeServicePath) +} + +pub(crate) fn command(program: &str, arguments: Vec) -> ServiceCommand { + ServiceCommand { + program: PathBuf::from(program), + arguments, + timeout: COMMAND_TIMEOUT, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_runner_times_out_and_redacts_captured_output_debug() { + let timeout = DirectServiceCommandRunner.run(&ServiceCommand { + program: PathBuf::from("/bin/sleep"), + arguments: vec!["1".into()], + timeout: Duration::from_millis(10), + }); + assert_eq!(timeout, Err(ServiceError::CommandTimedOut)); + + let outcome = DirectServiceCommandRunner + .run(&ServiceCommand { + program: PathBuf::from("/bin/echo"), + arguments: vec!["OUTPUT-SENTINEL".into()], + timeout: Duration::from_secs(1), + }) + .unwrap(); + assert!(!format!("{outcome:?}").contains("OUTPUT-SENTINEL")); + } +} diff --git a/src/service/supervisor.rs b/src/service/supervisor.rs new file mode 100644 index 0000000..96f15e2 --- /dev/null +++ b/src/service/supervisor.rs @@ -0,0 +1,191 @@ +use std::{future::Future, path::Path}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::VerifyingKey; + +use crate::{ + bootstrap::{ + BootstrapPhase, BootstrapStateStore, NodeConfigV1, NodePaths, load_startup_bundle, + }, + protocol::{BootstrapProfile, NodeRole}, + runtime::key_store::read_owner_only, +}; + +use super::ServiceError; + +pub async fn run(paths: &NodePaths, config: &Path) -> Result<(), ServiceError> { + run_until(paths, config, termination_signal()).await +} + +pub async fn run_until(paths: &NodePaths, config: &Path, shutdown: F) -> Result<(), ServiceError> +where + F: Future, +{ + let _state = validate_and_lock(paths, config)?; + shutdown.await; + Ok(()) +} + +fn validate_and_lock( + paths: &NodePaths, + config: &Path, +) -> Result { + if !config.is_absolute() || config != paths.config_file { + return Err(ServiceError::SupervisorInvalid); + } + let state = BootstrapStateStore::open(&paths.journal_file) + .map_err(|_| ServiceError::SupervisorInvalid)?; + if !matches!( + state.phase(), + BootstrapPhase::ServicePrepared | BootstrapPhase::Registered | BootstrapPhase::Healthy + ) { + return Err(ServiceError::SupervisorInvalid); + } + let config = paths + .read_config() + .map_err(|_| ServiceError::SupervisorInvalid)?; + let root = read_root(paths)?; + validate_bundle_for_profile(paths, &root, &config)?; + Ok(state) +} + +fn validate_bundle_for_profile( + paths: &NodePaths, + root: &VerifyingKey, + config: &NodeConfigV1, +) -> Result<(), ServiceError> { + let roles: &[NodeRole] = match config.profile { + BootstrapProfile::Base => &[NodeRole::Directory, NodeRole::Requester], + BootstrapProfile::Provider => &[NodeRole::Executor, NodeRole::Verifier], + BootstrapProfile::AgentCandidate => &[NodeRole::Requester], + }; + let now = unix_ms()?; + if roles + .iter() + .any(|role| load_startup_bundle(paths, root, *role, now).is_ok()) + { + Ok(()) + } else { + Err(ServiceError::SupervisorInvalid) + } +} + +fn read_root(paths: &NodePaths) -> Result { + let bytes = read_owner_only(&paths.root_public_key_file, 256) + .map_err(|_| ServiceError::SupervisorInvalid)?; + let value = std::str::from_utf8(&bytes) + .map_err(|_| ServiceError::SupervisorInvalid)? + .trim(); + let decoded = STANDARD + .decode(value) + .map_err(|_| ServiceError::SupervisorInvalid)?; + let raw: [u8; 32] = decoded + .try_into() + .map_err(|_| ServiceError::SupervisorInvalid)?; + VerifyingKey::from_bytes(&raw).map_err(|_| ServiceError::SupervisorInvalid) +} + +fn unix_ms() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|value| i64::try_from(value.as_millis()).ok()) + .filter(|value| *value > 0) + .ok_or(ServiceError::SupervisorInvalid) +} + +#[cfg(unix)] +async fn termination_signal() { + use tokio::signal::unix::{SignalKind, signal}; + + let mut terminate = match signal(SignalKind::terminate()) { + Ok(value) => value, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = terminate.recv() => {} + _ = tokio::signal::ctrl_c() => {} + } +} + +#[cfg(not(unix))] +async fn termination_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +#[cfg(test)] +mod tests { + use age::secrecy::SecretString; + + use super::*; + use crate::bootstrap::{ + BootstrapTransition, NodePathEnvironment, UserPlatform, network::NetworkBoundary, + }; + + fn provisioned_paths() -> (tempfile::TempDir, NodePaths) { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + std::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)) + .unwrap(); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(root, None, None), + ) + .unwrap(); + crate::cli::provision_test_domain( + paths.clone(), + NetworkBoundary::loopback_ipv4(), + SecretString::from("supervisor-test-passphrase"), + ) + .unwrap(); + (temp, paths) + } + + #[tokio::test] + async fn supervisor_rejects_credential_only_state() { + let (_temp, paths) = provisioned_paths(); + let result = run_until(&paths, &paths.config_file, async {}).await; + assert_eq!(result, Err(ServiceError::SupervisorInvalid)); + } + + #[tokio::test] + async fn supervisor_validates_bundle_holds_single_instance_lock_and_exits_cleanly() { + let (_temp, paths) = provisioned_paths(); + let mut state = BootstrapStateStore::open(&paths.journal_file).unwrap(); + state + .apply( + "supervisor-service", + BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), + ) + .unwrap(); + drop(state); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let (stop_tx, stop_rx) = tokio::sync::oneshot::channel(); + let config = paths.config_file.clone(); + let service_paths = paths.clone(); + let task = tokio::spawn(async move { + run_until(&service_paths, &config, async move { + let _ = ready_tx.send(()); + let _ = stop_rx.await; + }) + .await + }); + ready_rx.await.unwrap(); + assert!(matches!( + BootstrapStateStore::open(&paths.journal_file), + Err(crate::bootstrap::BootstrapError::StateLocked) + )); + stop_tx.send(()).unwrap(); + assert_eq!(task.await.unwrap(), Ok(())); + assert_eq!( + BootstrapStateStore::open(&paths.journal_file) + .unwrap() + .phase(), + BootstrapPhase::ServicePrepared + ); + } +} diff --git a/tests/fixtures/service_probe.rs b/tests/fixtures/service_probe.rs new file mode 100644 index 0000000..0581694 --- /dev/null +++ b/tests/fixtures/service_probe.rs @@ -0,0 +1,5 @@ +fn main() { + loop { + std::thread::park_timeout(std::time::Duration::from_secs(60)); + } +} diff --git a/tests/service_linux.rs b/tests/service_linux.rs new file mode 100644 index 0000000..ac5d19e --- /dev/null +++ b/tests/service_linux.rs @@ -0,0 +1,167 @@ +use std::{ + collections::VecDeque, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use agenet::service::{ + ServiceCommand, ServiceCommandOutcome, ServiceCommandRunner, ServiceError, ServiceProcessState, + ServiceSpec, UserServiceManager, + linux::{LinuxUserServiceManager, render_systemd_user_unit}, +}; + +#[derive(Clone)] +struct FakeRunner { + outcomes: Arc>>, + commands: Arc>>, +} + +impl ServiceCommandRunner for FakeRunner { + fn run(&self, command: &ServiceCommand) -> Result { + self.commands.lock().unwrap().push(command.clone()); + let code = self.outcomes.lock().unwrap().pop_front().unwrap_or(1); + Ok(ServiceCommandOutcome { + success: code == 0, + code: Some(code), + stdout: Vec::new(), + }) + } +} + +fn spec() -> ServiceSpec { + ServiceSpec { + label: "agenet".to_owned(), + executable: PathBuf::from("/home/test user/.local/bin/agenet"), + config: PathBuf::from("/home/test user/.config/agenet/node-config-v1.json"), + state_root: PathBuf::from("/home/test user/.local/state/agenet"), + definition: PathBuf::from("/home/test user/.config/systemd/user/agenet.service"), + } +} + +#[test] +fn systemd_unit_is_user_scoped_escaped_and_hardened() { + let text = String::from_utf8(render_systemd_user_unit(&spec()).expect("valid unit")) + .expect("unit is UTF-8"); + for expected in [ + "ExecStart=\"/home/test user/.local/bin/agenet\" node service-run --config \"/home/test user/.config/agenet/node-config-v1.json\"", + "Restart=on-failure", + "RestartSec=5s", + "NoNewPrivileges=true", + "PrivateTmp=true", + ] { + assert!(text.contains(expected), "missing {expected}: {text}"); + } + for forbidden in [ + "/bin/sh", + "sudo", + "EnvironmentFile", + "API_KEY", + ".env", + "WantedBy=multi-user.target", + ] { + assert!(!text.contains(forbidden), "contains {forbidden}: {text}"); + } + assert!(text.contains("WantedBy=default.target")); +} + +#[test] +fn systemd_unit_rejects_newlines_and_root_paths() { + let mut invalid = spec(); + invalid.label = "agenet\nInjected=true".to_owned(); + assert!(render_systemd_user_unit(&invalid).is_err()); + invalid = spec(); + invalid.state_root = PathBuf::from("/"); + assert!(render_systemd_user_unit(&invalid).is_err()); + invalid = spec(); + invalid.definition = PathBuf::from("/etc/systemd/system/agenet.service"); + assert!(render_systemd_user_unit(&invalid).is_err()); +} + +#[test] +fn systemd_unit_escapes_percent_specifiers_in_paths() { + let mut value = spec(); + value.executable = PathBuf::from("/home/test%u/.local/bin/agenet"); + let unit = String::from_utf8(render_systemd_user_unit(&value).unwrap()).unwrap(); + assert!(unit.contains("/home/test%%u/.local/bin/agenet")); +} + +#[test] +fn systemd_user_commands_reload_enable_stop_without_shell_or_linger() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let definition = root.join(".config/systemd/user/agenet.service"); + let state = root.join(".local/state/agenet"); + std::fs::create_dir_all(definition.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&state).unwrap(); + let spec = ServiceSpec { + label: "agenet".to_owned(), + executable: root.join("bin with spaces/agenet"), + config: root.join(".config/agenet/node-config-v1.json"), + state_root: state, + definition, + }; + let commands = Arc::new(Mutex::new(Vec::new())); + let runner = FakeRunner { + outcomes: Arc::new(Mutex::new(VecDeque::from([ + 0, // install daemon-reload + 3, 0, 0, // start: inactive, enable --now, active + 0, 0, // stop: active, stop + 3, // second stop: inactive + ]))), + commands: Arc::clone(&commands), + }; + let manager = LinuxUserServiceManager::new(spec.clone(), runner); + manager.install(&spec).unwrap(); + assert_eq!( + manager.start().unwrap().process, + ServiceProcessState::Running + ); + assert_eq!( + manager.stop().unwrap().process, + ServiceProcessState::Stopped + ); + assert_eq!( + manager.stop().unwrap().process, + ServiceProcessState::Stopped + ); + let commands = commands.lock().unwrap(); + assert!( + commands + .iter() + .all(|value| value.program == std::path::Path::new("/usr/bin/systemctl")) + ); + let all_args = commands + .iter() + .flat_map(|value| value.arguments.iter()) + .map(|value| value.to_string_lossy()) + .collect::>(); + assert!( + !all_args + .iter() + .any(|value| value == "sudo" || value == "linger") + ); + assert!(all_args.iter().any(|value| value == "--user")); +} + +#[test] +fn unavailable_systemd_user_session_is_typed() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let spec = ServiceSpec { + label: "agenet".to_owned(), + executable: root.join("agenet"), + config: root.join("config"), + state_root: root.join("state"), + definition: root.join("systemd/user/agenet.service"), + }; + let runner = FakeRunner { + outcomes: Arc::new(Mutex::new(VecDeque::from([1]))), + commands: Arc::new(Mutex::new(Vec::new())), + }; + let manager = LinuxUserServiceManager::new(spec.clone(), runner); + assert_eq!( + manager.install(&spec), + Err(ServiceError::UserSessionUnavailable) + ); + assert!(!spec.definition.exists()); +} diff --git a/tests/service_macos.rs b/tests/service_macos.rs new file mode 100644 index 0000000..36c194e --- /dev/null +++ b/tests/service_macos.rs @@ -0,0 +1,245 @@ +use std::{ + collections::VecDeque, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use agenet::service::{ + ServiceCommand, ServiceCommandOutcome, ServiceCommandRunner, ServiceError, ServiceProcessState, + ServiceSpec, UserServiceManager, + macos::{MacOsUserServiceManager, render_launch_agent}, +}; + +#[derive(Clone)] +struct FakeRunner { + outcomes: SharedOutcomes, + commands: Arc>>, +} + +type SharedOutcomes = Arc>>; + +impl ServiceCommandRunner for FakeRunner { + fn run(&self, command: &ServiceCommand) -> Result { + self.commands.lock().unwrap().push(command.clone()); + let (success, stdout) = self + .outcomes + .lock() + .unwrap() + .pop_front() + .unwrap_or((false, b"")); + Ok(ServiceCommandOutcome { + success, + code: Some(if success { 0 } else { 1 }), + stdout: stdout.to_vec(), + }) + } +} + +fn spec() -> ServiceSpec { + ServiceSpec { + label: "org.nexa-language.agenet".to_owned(), + executable: PathBuf::from("/Users/test user/.local/bin/agenet"), + config: PathBuf::from( + "/Users/test user/Library/Application Support/AgenNet/node-config-v1.json", + ), + state_root: PathBuf::from("/Users/test user/Library/Application Support/AgenNet"), + definition: PathBuf::from( + "/Users/test user/Library/LaunchAgents/org.nexa-language.agenet.plist", + ), + } +} + +#[test] +fn launch_agent_is_login_scoped_argv_without_secrets_or_shell() { + let rendered = render_launch_agent(&spec()).expect("valid LaunchAgent"); + #[cfg(target_os = "macos")] + { + let value = plist::Value::from_reader_xml(rendered.as_slice()).expect("valid plist XML"); + let arguments = value + .as_dictionary() + .and_then(|dictionary| dictionary.get("ProgramArguments")) + .and_then(plist::Value::as_array) + .expect("ProgramArguments array"); + assert_eq!(arguments[1].as_string(), Some("node")); + assert_eq!(arguments[2].as_string(), Some("service-run")); + } + let text = String::from_utf8(rendered).expect("plist is UTF-8"); + for expected in [ + "org.nexa-language.agenet", + "/Users/test user/.local/bin/agenet", + "service-run", + "/Users/test user/Library/Application Support/AgenNet/node-config-v1.json", + "RunAtLoad", + "KeepAlive", + "agenet-service.stdout.log", + "agenet-service.stderr.log", + ] { + assert!(text.contains(expected), "missing {expected}: {text}"); + } + for forbidden in ["/bin/sh", "sudo", "EnvironmentVariables", "API_KEY", ".env"] { + assert!(!text.contains(forbidden), "contains {forbidden}: {text}"); + } +} + +#[test] +fn launch_agent_rejects_relative_root_and_secret_arguments() { + let mut invalid = spec(); + invalid.executable = PathBuf::from("agenet"); + assert!(render_launch_agent(&invalid).is_err()); + invalid = spec(); + invalid.config = PathBuf::from("/"); + assert!(render_launch_agent(&invalid).is_err()); + invalid = spec(); + invalid.definition = PathBuf::from("/Library/LaunchDaemons/org.nexa-language.agenet.plist"); + assert!(render_launch_agent(&invalid).is_err()); +} + +#[test] +fn launchctl_install_start_stop_are_idempotent_argv_operations() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let service_dir = root.join("Library/LaunchAgents"); + let state = root.join("Library/Application Support/AgenNet"); + std::fs::create_dir_all(&service_dir).unwrap(); + std::fs::create_dir_all(&state).unwrap(); + let spec = ServiceSpec { + label: "org.nexa-language.agenet.test".to_owned(), + executable: root.join("bin with spaces/agenet"), + config: state.join("node-config-v1.json"), + state_root: state, + definition: service_dir.join("org.nexa-language.agenet.test.plist"), + }; + let commands = Arc::new(Mutex::new(Vec::new())); + let runner = FakeRunner { + outcomes: Arc::new(Mutex::new(VecDeque::from([ + (false, &b""[..]), + (true, &b""[..]), + (true, &b"state = running\npid = 42\n"[..]), + (true, &b"state = running\npid = 42\n"[..]), + (true, &b""[..]), + (false, &b""[..]), + ]))), + commands: Arc::clone(&commands), + }; + let manager = MacOsUserServiceManager::new(spec.clone(), 501, runner); + manager.install(&spec).unwrap(); + assert_eq!( + manager.start().unwrap().process, + ServiceProcessState::Running + ); + assert_eq!( + manager.stop().unwrap().process, + ServiceProcessState::Stopped + ); + assert_eq!( + manager.stop().unwrap().process, + ServiceProcessState::Stopped + ); + let commands = commands.lock().unwrap(); + assert!( + commands + .iter() + .all(|value| value.program == std::path::Path::new("/bin/launchctl")) + ); + assert!( + commands + .iter() + .all(|value| value.arguments.iter().all(|arg| arg != "/bin/sh")) + ); + assert!(spec.definition.exists()); +} + +#[test] +fn launch_agent_install_rejects_symlink_definition() { + use std::os::unix::fs::symlink; + + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let target = root.join("target"); + std::fs::write(&target, "unchanged").unwrap(); + let definition = root.join("service.plist"); + symlink(&target, &definition).unwrap(); + let spec = ServiceSpec { + label: "org.nexa-language.agenet.test".to_owned(), + executable: root.join("agenet"), + config: root.join("config"), + state_root: root.clone(), + definition, + }; + let runner = FakeRunner { + outcomes: Arc::new(Mutex::new(VecDeque::new())), + commands: Arc::new(Mutex::new(Vec::new())), + }; + let manager = MacOsUserServiceManager::new(spec.clone(), 501, runner); + assert!(manager.install(&spec).is_err()); + assert_eq!(std::fs::read_to_string(target).unwrap(), "unchanged"); +} + +#[test] +fn launch_agent_install_rejects_group_writable_parent() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let service_dir = root.join("LaunchAgents"); + std::fs::create_dir(&service_dir).unwrap(); + std::fs::set_permissions(&service_dir, std::fs::Permissions::from_mode(0o770)).unwrap(); + let spec = ServiceSpec { + label: "org.nexa-language.agenet.test".to_owned(), + executable: root.join("agenet"), + config: root.join("config"), + state_root: root.clone(), + definition: service_dir.join("service.plist"), + }; + let runner = FakeRunner { + outcomes: Arc::new(Mutex::new(VecDeque::new())), + commands: Arc::new(Mutex::new(Vec::new())), + }; + let manager = MacOsUserServiceManager::new(spec.clone(), 501, runner); + assert_eq!(manager.install(&spec), Err(ServiceError::UnsafeServicePath)); +} + +#[test] +#[ignore = "mutates one uniquely labelled LaunchAgent; run explicitly on macOS"] +#[cfg(target_os = "macos")] +fn native_launch_agent_smoke_uses_tested_uninstall_path() { + let probe = std::env::var_os("AGENET_NATIVE_SERVICE_PROBE") + .map(PathBuf::from) + .expect("probe path is explicitly supplied"); + assert!(probe.is_absolute()); + let base = directories::BaseDirs::new().expect("user directories"); + let temp = tempfile::TempDir::new().unwrap(); + let state = temp.path().canonicalize().unwrap(); + let label = format!("org.nexa-language.agenet.smoke.{}", uuid::Uuid::new_v4()); + let definition = base + .home_dir() + .join("Library/LaunchAgents") + .join(format!("{label}.plist")); + let spec = ServiceSpec { + label, + executable: probe, + config: state.join("nonsecret-test-config"), + state_root: state, + definition: definition.clone(), + }; + let manager = MacOsUserServiceManager::new( + spec.clone(), + unsafe { libc::geteuid() }, + agenet::service::DirectServiceCommandRunner, + ); + manager.install(&spec).unwrap(); + assert_eq!( + manager.start().unwrap().process, + ServiceProcessState::Running + ); + assert_eq!( + manager.stop().unwrap().process, + ServiceProcessState::Stopped + ); + manager.uninstall().unwrap(); + assert!(!definition.exists()); + assert_eq!( + manager.status().unwrap().process, + ServiceProcessState::Stopped + ); +} From bad35b3969835bf5df5891026899214eabe8c0b1 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 03:39:38 +0800 Subject: [PATCH 34/67] [bug] Fix service rollback safety Root cause: Cleanup calls were treated as proof and ancestor trust was incomplete. Solution: Verify cleanup facts and pin the full service path walk. Risks: Unsafe paths and uncertain cleanup require explicit retry. Dependency: Bootstrap Task 11 commit 0ee88e2. Links: plan/01-v2-multi-host-node-bootstrap.md Post-mortem: Model compensation as facts, not best-effort calls. --- README.md | 13 +- ROADMAP.md | 10 + docs/design/agenet-v0.1.md | 2 +- plan/01-v2-multi-host-node-bootstrap.md | 25 +- src/cli/node.rs | 242 +++++++++++++++-- src/runtime/key_store.rs | 329 +++++++++++++++++++++++- src/service/linux.rs | 6 +- src/service/macos.rs | 2 +- src/service/mod.rs | 16 +- tests/service_linux.rs | 29 +++ tests/service_macos.rs | 34 ++- 11 files changed, 648 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 425e7a1..afd4115 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,15 @@ agenet node stop `ServicePrepared`, and only then asks the login-scoped service manager to start it. macOS uses `~/Library/LaunchAgents/org.nexa-language.agenet.plist`; Linux uses -`~/.config/systemd/user/agenet.service`. An activation failure removes that -definition, rolls the journal back to `CredentialIssued`, and retains all -credential material. Repeated start and stop operations reconcile the existing -definition and process without advancing to `Registered` or `Healthy`. +`~/.config/systemd/user/agenet.service`. Every ancestor of that absolute path +is opened without following symlinks and must be root/current-user owned and +not group/world writable. An activation failure rolls the journal back to +`CredentialIssued` only after the platform manager verifies the process is +stopped and the exact definition is absent. Otherwise it retains +`ServicePrepared`, returns `ServiceRollbackIncomplete`, and keeps all +credential material. A later start republishes and reconciles an uncertain +post-delete state. Repeated start and stop operations do not advance to +`Registered` or `Healthy`. The Task 11 service runs the internal bootstrap supervisor. It strictly loads the persisted config, Root/Authority/Node credential chain, signing key, peer diff --git a/ROADMAP.md b/ROADMAP.md index c353c03..a2b53e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,15 @@ # ROADMAP +## 2026-08-15 03:26 CST + +- **Change**: Corrected Task 11 activation compensation and service-artifact pathname trust after review. Rollback now requires observed stopped process plus exact artifact absence; service publication and deletion walk and pin every absolute ancestor from `/`. +- **Files**: `src/cli/node.rs`, `src/runtime/key_store.rs`, macOS/Linux service managers and tests, the focused v2 plan, this roadmap, and ignored Task 11 review evidence/report. +- **Root cause / classification**: **技术盲区 / 计划集成缺口**. The first implementation treated best-effort cleanup calls as proof of cleanup and validated only the final service parent before mutation. A failed stop/remove/status could therefore be followed by a false `CredentialIssued` rollback, while an unsafe or replaced ancestor remained outside the pinned-object boundary. +- **Solution**: Call platform uninstall once and propagate macOS `bootout` or Linux `disable --now` failure. Keep `ServicePrepared` and return sanitized `ServiceRollbackIncomplete` unless process state, manager installation state, and exact artifact absence all agree. Resolve every path component using `openat` with `O_DIRECTORY|O_NOFOLLOW`, accept only root/current-euid ownership with no group/world write, create missing user-owned components as `0700`, and perform atomic publish/delete through the same pinned final-parent descriptor. +- **Evidence**: A filesystem-backed lifecycle fake asserts artifact inode and durable `ServicePrepared` before activation, then covers stop failure, removal failure, command timeout, post-delete status uncertainty, credential retention, and retry reconciliation. Path tests cover writable/foreign-owner policy, `/tmp`, ancestor symlinks, ancestor replacement after validation, final-file binding, and temporary cleanup. Platform tests prove failed bootout/disable retains the artifact. +- **Prevention**: Model compensation as a fact table over journal, process, and filesystem observables; never equate a method invocation with a completed side effect. Every security-sensitive pathname operation must document and test its complete ancestor trust chain and must mutate through the same descriptor walk that performed validation. +- **Boundary**: Post-delete durability uncertainty intentionally leaves `ServicePrepared` even when the current pathname is absent. The next start republishes and reconciles; no cleanup error deletes credentials or claims runtime readiness. + ## 2026-08-15 02:45 CST - **Change**: Added recoverable macOS LaunchAgent and Linux systemd user-service management, explicit `node start|stop|status`, and a bootstrap-only service supervisor. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index bbd86bb..75f8118 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -61,7 +61,7 @@ The demo provisions an ephemeral Domain Root and four Node Credentials, starts f | Exact-byte Credential, Envelope, Contract, and Event signatures | automated | unit/property tests | | Grant and Artifact read scope | automated | protocol and Axum tests | | Journal replay and operation idempotency | automated | restart test | -| Login-scoped service definition and supervisor | automated + native macOS smoke | runtime remains not ready until Task 12 | +| Login-scoped service definition and supervisor | automated + native macOS smoke | root-anchored no-follow path walk and fact-checked rollback; runtime remains not ready until Task 12 | | Independent source metric reproduction | automated | separate implementations plus third oracle | | Four PIDs and four dynamic ports | automated | real child-process test | | Real ModelHub Intent projection | verified locally | Walkman env run `3cf7bccc-932b-4b77-9ae5-514f8a52f961` | diff --git a/plan/01-v2-multi-host-node-bootstrap.md b/plan/01-v2-multi-host-node-bootstrap.md index 608efa8..40cf676 100644 --- a/plan/01-v2-multi-host-node-bootstrap.md +++ b/plan/01-v2-multi-host-node-bootstrap.md @@ -26,13 +26,19 @@ no listener, registration, readiness, or health claim. 1. Add failure-first rendering, runner, supervisor, and CLI tests. 2. Render owner-only LaunchAgent and systemd user definitions with absolute argv paths, no shell, environment file, secret, sudo, system unit, or linger. -3. Publish the definition atomically, append `ServicePrepared`, then activate - and verify the supervisor. On activation failure, stop and remove the - definition and append `RollbackService`, preserving credentials. -4. Make start idempotently reconcile definition, process, and phase; make stop +3. Resolve every absolute service-path ancestor from a root directory + descriptor, rejecting symlinks, foreign owners, and group/world-writable + directories. Publish the definition atomically, append `ServicePrepared`, + then activate and verify the supervisor. +4. On activation failure, invoke the platform uninstall once. Append + `RollbackService` only after the manager reports the process stopped and + the exact service artifact is observably absent. Any stop, removal, status, + or durability uncertainty retains `ServicePrepared` and returns the stable + `ServiceRollbackIncomplete` error while preserving credentials. +5. Make start idempotently reconcile definition, process, and phase; make stop retain the definition and `ServicePrepared`; report process state separately from `runtime_ready: false`. -5. Verify both renderers and injected runners, run a uniquely labelled native +6. Verify both renderers and injected runners, run a uniquely labelled native smoke test where the host session permits it, and record any real platform restriction without weakening the automated contract. @@ -41,8 +47,10 @@ no listener, registration, readiness, or health claim. - `node start`, `node stop`, and `node status` are Clap-valid and emit typed, sanitized output. - `ServicePrepared` is appended only after a durable service artifact exists. -- Activation failure restores `CredentialIssued` and removes only the service - artifact; credentials remain intact. +- Activation failure restores `CredentialIssued` only after stopped process + and durable artifact removal are both verified. Uncertain compensation + retains `ServicePrepared`, reports `ServiceRollbackIncomplete`, and preserves + the credential for explicit reconciliation on a later start/status attempt. - A running supervisor has validated startup material, owns the bootstrap lock, shuts down on a termination signal, opens no network listener, and never reports `Registered` or `Healthy`. @@ -57,3 +65,6 @@ no listener, registration, readiness, or health claim. `runtime_ready` is always false. - Platform service-manager behavior depends on an available GUI/systemd user session; absence is a typed operational error, not a simulated success. +- A post-delete directory-sync or status failure can leave the artifact absent + while the journal remains `ServicePrepared`; retry republishes and reconciles + instead of guessing whether cleanup was durable. diff --git a/src/cli/node.rs b/src/cli/node.rs index 165f80a..a556bd8 100644 --- a/src/cli/node.rs +++ b/src/cli/node.rs @@ -98,7 +98,7 @@ fn start_with(paths: &NodePaths, manager: &dyn UserServiceManager) -> Result<(), BootstrapTransition::Advance(BootstrapPhase::ServicePrepared), ) { drop(state); - let _ = manager.uninstall(); + verify_service_removed(paths, manager)?; return Err(map_bootstrap(error)); } true @@ -121,16 +121,45 @@ fn start_with(paths: &NodePaths, manager: &dyn UserServiceManager) -> Result<(), | BootstrapPhase::Left => return Err(not_prepared()), }; if let Err(error) = manager.start() { - let _ = manager.stop(); if rollback_on_failure { - let _ = manager.uninstall(); - rollback_service(paths)?; + compensate_failed_activation(paths, manager)?; } return Err(map_service(error)); } Ok(()) } +fn compensate_failed_activation( + paths: &NodePaths, + manager: &dyn UserServiceManager, +) -> Result<(), CliError> { + verify_service_removed(paths, manager)?; + rollback_service(paths).map_err(|_| rollback_incomplete()) +} + +fn verify_service_removed( + paths: &NodePaths, + manager: &dyn UserServiceManager, +) -> Result<(), CliError> { + manager.uninstall().map_err(|_| rollback_incomplete())?; + let status = manager.status().map_err(|_| rollback_incomplete())?; + if status.process != crate::service::ServiceProcessState::Stopped + || status.installed + || !service_artifact_absent(&paths.service_definition)? + { + return Err(rollback_incomplete()); + } + Ok(()) +} + +fn service_artifact_absent(path: &std::path::Path) -> Result { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Ok(_) => Ok(false), + Err(_) => Err(rollback_incomplete()), + } +} + fn rollback_service(paths: &NodePaths) -> Result<(), CliError> { let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; state @@ -272,8 +301,17 @@ fn internal() -> CliError { ) } +fn rollback_incomplete() -> CliError { + CliError::new( + "ServiceRollbackIncomplete", + "The user service cleanup could not be verified; persisted state was retained.", + true, + ) +} + #[cfg(test)] mod tests { + use std::os::unix::fs::MetadataExt; use std::os::unix::fs::PermissionsExt; use std::sync::{Arc, Mutex}; @@ -283,10 +321,17 @@ mod tests { #[derive(Default)] struct FakeState { - installed: bool, running: bool, - fail_start: bool, + start_error: Option, + fail_stop: bool, + fail_remove: bool, + fail_remove_after_delete: bool, + fail_status: bool, starts: usize, + spec: Option, + phase_at_start: Option, + artifact_inode_at_start: Option, + events: Vec<&'static str>, } struct FakeManager(Arc>); @@ -296,16 +341,34 @@ mod tests { Ok(b"service".to_vec()) } - fn install(&self, _: &ServiceSpec) -> Result { - self.0.lock().unwrap().installed = true; + fn install( + &self, + spec: &ServiceSpec, + ) -> Result { + crate::service::publish_definition(spec, b"test-service-definition")?; + let mut state = self.0.lock().unwrap(); + state.spec = Some(spec.clone()); + state.events.push("install"); Ok(crate::service::ServiceStatus::stopped(true)) } fn start(&self) -> Result { let mut state = self.0.lock().unwrap(); state.starts += 1; - if state.fail_start { - return Err(ServiceError::CommandFailed); + state.events.push("start"); + let spec = state.spec.clone().ok_or(ServiceError::InvalidSpec)?; + state.phase_at_start = Some( + BootstrapStateStore::open(&spec.state_root.join("bootstrap-state-v1.jsonl")) + .map_err(|_| ServiceError::SupervisorInvalid)? + .phase(), + ); + state.artifact_inode_at_start = Some( + std::fs::symlink_metadata(&spec.definition) + .map_err(|_| ServiceError::PersistenceUnavailable)? + .ino(), + ); + if let Some(error) = state.start_error { + return Err(error); } state.running = true; Ok(crate::service::ServiceStatus::running()) @@ -313,21 +376,48 @@ mod tests { fn stop(&self) -> Result { let mut state = self.0.lock().unwrap(); + state.events.push("stop"); + if state.fail_stop { + return Err(ServiceError::CommandFailed); + } state.running = false; - Ok(crate::service::ServiceStatus::stopped(state.installed)) + let installed = state + .spec + .as_ref() + .is_some_and(|spec| spec.definition.exists()); + Ok(crate::service::ServiceStatus::stopped(installed)) } fn uninstall(&self) -> Result<(), ServiceError> { - self.0.lock().unwrap().installed = false; + self.stop()?; + let mut state = self.0.lock().unwrap(); + state.events.push("uninstall"); + if state.fail_remove { + return Err(ServiceError::PersistenceUnavailable); + } + let spec = state.spec.clone().ok_or(ServiceError::InvalidSpec)?; + let fail_after_delete = state.fail_remove_after_delete; + drop(state); + crate::service::remove_definition(&spec)?; + if fail_after_delete { + return Err(ServiceError::PersistenceUnavailable); + } Ok(()) } fn status(&self) -> Result { let state = self.0.lock().unwrap(); + if state.fail_status { + return Err(ServiceError::CommandTimedOut); + } + let installed = state + .spec + .as_ref() + .is_some_and(|spec| spec.definition.exists()); Ok(if state.running { crate::service::ServiceStatus::running() } else { - crate::service::ServiceStatus::stopped(state.installed) + crate::service::ServiceStatus::stopped(installed) }) } } @@ -392,11 +482,12 @@ mod tests { fn activation_failure_removes_service_and_rolls_back_but_keeps_credential_phase() { let (_temp, paths) = credential_paths(); let state = Arc::new(Mutex::new(FakeState { - fail_start: true, + start_error: Some(ServiceError::CommandTimedOut), ..FakeState::default() })); - assert!(start_with(&paths, &FakeManager(Arc::clone(&state))).is_err()); - assert!(!state.lock().unwrap().installed); + let error = start_with(&paths, &FakeManager(Arc::clone(&state))).unwrap_err(); + assert_eq!(error.code, "UserServiceFailed"); + assert!(!paths.service_definition.exists()); assert_eq!( current_service_phase(&paths).unwrap(), BootstrapPhase::CredentialIssued @@ -406,7 +497,13 @@ mod tests { b"credential-sentinel" ); - state.lock().unwrap().fail_start = false; + assert_eq!( + state.lock().unwrap().phase_at_start, + Some(BootstrapPhase::ServicePrepared) + ); + assert!(state.lock().unwrap().artifact_inode_at_start.is_some()); + assert_eq!(state.lock().unwrap().events[..2], ["install", "start"]); + state.lock().unwrap().start_error = None; start_with(&paths, &FakeManager(Arc::clone(&state))).unwrap(); assert_eq!( current_service_phase(&paths).unwrap(), @@ -416,7 +513,7 @@ mod tests { { let mut current = state.lock().unwrap(); current.running = false; - current.fail_start = true; + current.start_error = Some(ServiceError::CommandFailed); } assert!(start_with(&paths, &FakeManager(Arc::clone(&state))).is_err()); assert_eq!( @@ -425,6 +522,115 @@ mod tests { ); } + #[test] + fn failed_stop_keeps_service_prepared_artifact_process_and_credential() { + let (_temp, paths) = credential_paths(); + let state = Arc::new(Mutex::new(FakeState { + running: true, + start_error: Some(ServiceError::CommandFailed), + fail_stop: true, + ..FakeState::default() + })); + let error = start_with(&paths, &FakeManager(Arc::clone(&state))).unwrap_err(); + assert_eq!(error.code, "ServiceRollbackIncomplete"); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::ServicePrepared + ); + assert!(paths.service_definition.exists()); + assert!(state.lock().unwrap().running); + assert_eq!( + state + .lock() + .unwrap() + .events + .iter() + .filter(|event| **event == "stop") + .count(), + 1 + ); + assert_eq!( + std::fs::read(&paths.credential_file).unwrap(), + b"credential-sentinel" + ); + } + + #[test] + fn failed_artifact_removal_keeps_service_prepared_with_stopped_process() { + let (_temp, paths) = credential_paths(); + let state = Arc::new(Mutex::new(FakeState { + start_error: Some(ServiceError::CommandFailed), + fail_remove: true, + ..FakeState::default() + })); + let error = start_with(&paths, &FakeManager(Arc::clone(&state))).unwrap_err(); + assert_eq!(error.code, "ServiceRollbackIncomplete"); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::ServicePrepared + ); + assert!(paths.service_definition.exists()); + assert!(!state.lock().unwrap().running); + assert_eq!( + std::fs::read(&paths.credential_file).unwrap(), + b"credential-sentinel" + ); + } + + #[test] + fn post_delete_status_uncertainty_stays_prepared_and_next_start_reconciles() { + let (_temp, paths) = credential_paths(); + let state = Arc::new(Mutex::new(FakeState { + start_error: Some(ServiceError::CommandFailed), + fail_status: true, + ..FakeState::default() + })); + let error = start_with(&paths, &FakeManager(Arc::clone(&state))).unwrap_err(); + assert_eq!(error.code, "ServiceRollbackIncomplete"); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::ServicePrepared + ); + assert!(!paths.service_definition.exists()); + { + let mut current = state.lock().unwrap(); + current.start_error = None; + current.fail_status = false; + } + start_with(&paths, &FakeManager(Arc::clone(&state))).unwrap(); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::ServicePrepared + ); + assert!(paths.service_definition.exists()); + assert!(state.lock().unwrap().running); + } + + #[test] + fn post_delete_sync_uncertainty_stays_prepared_and_next_start_reconciles() { + let (_temp, paths) = credential_paths(); + let state = Arc::new(Mutex::new(FakeState { + start_error: Some(ServiceError::CommandFailed), + fail_remove_after_delete: true, + ..FakeState::default() + })); + let error = start_with(&paths, &FakeManager(Arc::clone(&state))).unwrap_err(); + assert_eq!(error.code, "ServiceRollbackIncomplete"); + assert_eq!( + current_service_phase(&paths).unwrap(), + BootstrapPhase::ServicePrepared + ); + assert!(!paths.service_definition.exists()); + { + let mut current = state.lock().unwrap(); + current.start_error = None; + current.fail_remove_after_delete = false; + } + start_with(&paths, &FakeManager(Arc::clone(&state))).unwrap(); + assert!(paths.service_definition.exists()); + assert!(state.lock().unwrap().running); + } + #[test] fn repeated_start_is_idempotent_and_never_advances_past_service_prepared() { let (_temp, paths) = credential_paths(); diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index d6fc8fc..4a331b8 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -5,7 +5,7 @@ use std::{ fs::{DirBuilderExt, MetadataExt}, io::{AsRawFd, FromRawFd}, }, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, }; use base64::{Engine, engine::general_purpose::STANDARD}; @@ -196,14 +196,19 @@ pub(crate) fn atomic_write_owner_only( atomic_write_owner_only_with_policy(path, bytes, replace, false) } -pub(crate) fn ensure_secure_user_service_dir(path: &Path) -> Result<(), RuntimeError> { - create_owner_only_components(path)?; - open_verified_owner_directory(path, false).map(|_| ()) +pub(crate) fn remove_owner_only_user_service_file(path: &Path) -> Result<(), RuntimeError> { + remove_owner_only_user_service_file_with_hook(path, || {}) } -pub(crate) fn remove_owner_only_user_service_file(path: &Path) -> Result<(), RuntimeError> { - let parent = open_verified_owner_directory(normalized_parent(path)?, false)?; - let name = c_name(path.file_name().ok_or(RuntimeError::Io)?)?; +fn remove_owner_only_user_service_file_with_hook( + path: &Path, + after_walk: AfterWalk, +) -> Result<(), RuntimeError> +where + AfterWalk: FnOnce(), +{ + let (parent, name) = open_secure_service_parent(path, false)?; + after_walk(); let file = match openat_owner_file(&parent, &name, libc::O_RDONLY, false) { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), @@ -227,6 +232,152 @@ pub(crate) fn remove_owner_only_user_service_file(path: &Path) -> Result<(), Run parent.sync_all().map_err(Into::into) } +pub(crate) fn publish_owner_only_user_service_file( + path: &Path, + bytes: &[u8], +) -> Result<(), RuntimeError> { + publish_owner_only_user_service_file_with_hooks( + path, + bytes, + write_temp_fully, + || {}, + |parent| parent.sync_all().map_err(Into::into), + ) +} + +fn publish_owner_only_user_service_file_with_hooks( + path: &Path, + bytes: &[u8], + temp_writer: TempWriter, + after_walk: AfterWalk, + sync_parent: SyncParent, +) -> Result<(), RuntimeError> +where + TempWriter: FnOnce(&mut File, &[u8]) -> Result<(), RuntimeError>, + AfterWalk: FnOnce(), + SyncParent: FnOnce(&File) -> Result<(), RuntimeError>, +{ + let (parent, final_name) = open_secure_service_parent(path, true)?; + after_walk(); + validate_existing_service_file(&parent, &final_name)?; + atomic_write_owner_only_at( + &parent, + (path.file_name().ok_or(RuntimeError::Io)?, final_name), + bytes, + true, + temp_writer, + || {}, + sync_parent, + ) +} + +fn validate_existing_service_file( + parent: &File, + name: &std::ffi::CStr, +) -> Result<(), RuntimeError> { + match openat_owner_file(parent, name, libc::O_RDONLY, false) { + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(RuntimeError::Io), + } +} + +fn open_secure_service_parent( + path: &Path, + create_missing: bool, +) -> Result<(File, std::ffi::CString), RuntimeError> { + if !path.is_absolute() { + return Err(RuntimeError::Io); + } + let final_name = c_name(path.file_name().ok_or(RuntimeError::Io)?)?; + let mut directory = open_root_directory()?; + validate_service_directory(&directory.metadata()?)?; + let parent = normalized_parent(path)?; + for component in parent.components() { + match component { + Component::RootDir => continue, + Component::Normal(name) => { + directory = open_or_create_service_component(&directory, name, create_missing)?; + } + _ => return Err(RuntimeError::Io), + } + } + Ok((directory, final_name)) +} + +fn open_root_directory() -> Result { + let raw = unsafe { + libc::open( + c"/".as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if raw < 0 { + return Err(RuntimeError::Io); + } + Ok(unsafe { File::from_raw_fd(raw) }) +} + +fn open_or_create_service_component( + parent: &File, + name: &std::ffi::OsStr, + create_missing: bool, +) -> Result { + let name = c_name(name)?; + match openat_directory(parent, &name) { + Ok(directory) => { + validate_service_directory(&directory.metadata()?)?; + Ok(directory) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound && create_missing => { + let owner = parent.metadata()?.uid(); + if owner != unsafe { libc::geteuid() } { + return Err(RuntimeError::Io); + } + cvt(unsafe { libc::mkdirat(parent.as_raw_fd(), name.as_ptr(), 0o700) })?; + parent.sync_all()?; + let directory = openat_directory(parent, &name).map_err(|_| RuntimeError::Io)?; + let metadata = directory.metadata()?; + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o777 != 0o700 { + return Err(RuntimeError::Io); + } + Ok(directory) + } + Err(_) => Err(RuntimeError::Io), + } +} + +fn openat_directory(parent: &File, name: &std::ffi::CStr) -> std::io::Result { + let raw = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if raw < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(unsafe { File::from_raw_fd(raw) }) +} + +fn validate_service_directory(metadata: &fs::Metadata) -> Result<(), RuntimeError> { + if service_directory_values( + metadata.file_type().is_dir() && !metadata.file_type().is_symlink(), + metadata.uid(), + metadata.mode(), + unsafe { libc::geteuid() }, + ) { + Ok(()) + } else { + Err(RuntimeError::Io) + } +} + +fn service_directory_values(is_directory: bool, owner: u32, mode: u32, euid: u32) -> bool { + is_directory && (owner == 0 || owner == euid) && mode & 0o022 == 0 +} + pub(crate) fn atomic_write_owner_only_strict( path: &Path, bytes: &[u8], @@ -313,9 +464,35 @@ where let parent_path = normalized_parent(path)?; let parent = open_verified_owner_directory(parent_path, strict_parent)?; let final_name = c_name(path.file_name().ok_or(RuntimeError::Io)?)?; + atomic_write_owner_only_at( + &parent, + (path.file_name().ok_or(RuntimeError::Io)?, final_name), + bytes, + replace, + temp_writer, + before_publish, + sync_parent, + ) +} + +fn atomic_write_owner_only_at( + parent: &File, + names: (&std::ffi::OsStr, std::ffi::CString), + bytes: &[u8], + replace: bool, + temp_writer: TempWriter, + before_publish: BeforePublish, + sync_parent: SyncParent, +) -> Result<(), RuntimeError> +where + TempWriter: FnOnce(&mut File, &[u8]) -> Result<(), RuntimeError>, + BeforePublish: FnOnce(), + SyncParent: FnOnce(&File) -> Result<(), RuntimeError>, +{ + let (display_name, final_name) = names; let temp_name = c_name(std::ffi::OsStr::new(&format!( ".{}.{}.tmp", - path.file_name().ok_or(RuntimeError::Io)?.to_string_lossy(), + display_name.to_string_lossy(), uuid::Uuid::new_v4() )))?; let temp_raw = unsafe { @@ -329,7 +506,7 @@ where if temp_raw < 0 { return Err(RuntimeError::Io); } - let mut pending = PendingTemp::new(&parent, temp_name); + let mut pending = PendingTemp::new(parent, temp_name); let mut file = unsafe { File::from_raw_fd(temp_raw) }; if !safe_regular(&file.metadata()?) { return Err(RuntimeError::Io); @@ -357,10 +534,10 @@ where 0, ) })?; - unlink_relative(&parent, pending.name())?; + unlink_relative(parent, pending.name())?; pending.disarm(); } - sync_parent(&parent) + sync_parent(parent) } fn write_temp_fully(file: &mut File, bytes: &[u8]) -> Result<(), RuntimeError> { @@ -708,4 +885,134 @@ mod tests { b"attacker-sentinel" ); } + + #[test] + fn service_directory_policy_rejects_foreign_and_writable_ancestors() { + let euid = unsafe { libc::geteuid() }; + assert!(service_directory_values(true, 0, 0o040755, euid)); + assert!(service_directory_values(true, euid, 0o040700, euid)); + assert!(!service_directory_values( + true, + euid.wrapping_add(1), + 0o040700, + euid + )); + assert!(!service_directory_values(true, euid, 0o040770, euid)); + assert!(!service_directory_values(true, 0, 0o041777, euid)); + assert!(!service_directory_values(false, euid, 0o100600, euid)); + } + + #[test] + fn service_publication_rejects_group_writable_or_symlink_ancestor() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let writable = root.join("writable"); + let final_parent = writable.join("LaunchAgents"); + fs::create_dir_all(&final_parent).unwrap(); + fs::set_permissions(&writable, fs::Permissions::from_mode(0o770)).unwrap(); + fs::set_permissions(&final_parent, fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + publish_owner_only_user_service_file(&final_parent.join("service.plist"), b"unit"), + Err(RuntimeError::Io) + ); + + fs::set_permissions(&writable, fs::Permissions::from_mode(0o700)).unwrap(); + let trusted = root.join("trusted"); + fs::create_dir(&trusted).unwrap(); + std::os::unix::fs::symlink(&trusted, root.join("alias")).unwrap(); + assert_eq!( + publish_owner_only_user_service_file(&root.join("alias/service.plist"), b"unit"), + Err(RuntimeError::Io) + ); + } + + #[test] + fn service_publication_rejects_world_writable_tmp_ancestor() { + let temp = tempfile::Builder::new() + .prefix("agenet-service-path-") + .tempdir_in("/tmp") + .unwrap(); + assert_eq!( + publish_owner_only_user_service_file(&temp.path().join("service.plist"), b"unit"), + Err(RuntimeError::Io) + ); + } + + #[test] + fn service_publication_and_delete_remain_anchored_after_ancestor_replacement() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let managed = root.join("managed"); + let parent = managed.join("LaunchAgents"); + let detached = root.join("detached"); + let attacker = root.join("attacker"); + let attacker_parent = attacker.join("LaunchAgents"); + fs::create_dir_all(&parent).unwrap(); + fs::create_dir_all(&attacker_parent).unwrap(); + for path in [&managed, &parent, &attacker, &attacker_parent] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write(attacker_parent.join("service.plist"), b"attacker").unwrap(); + fs::set_permissions( + attacker_parent.join("service.plist"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let definition = parent.join("service.plist"); + + publish_owner_only_user_service_file_with_hooks( + &definition, + b"trusted", + write_temp_fully, + || { + fs::rename(&managed, &detached).unwrap(); + std::os::unix::fs::symlink(&attacker, &managed).unwrap(); + }, + |directory| directory.sync_all().map_err(Into::into), + ) + .unwrap(); + assert_eq!( + fs::read(detached.join("LaunchAgents/service.plist")).unwrap(), + b"trusted" + ); + assert_eq!( + fs::read(attacker_parent.join("service.plist")).unwrap(), + b"attacker" + ); + + fs::remove_file(&managed).unwrap(); + fs::rename(&detached, &managed).unwrap(); + remove_owner_only_user_service_file_with_hook(&definition, || { + fs::rename(&managed, &detached).unwrap(); + std::os::unix::fs::symlink(&attacker, &managed).unwrap(); + }) + .unwrap(); + assert!(!detached.join("LaunchAgents/service.plist").exists()); + assert_eq!( + fs::read(attacker_parent.join("service.plist")).unwrap(), + b"attacker" + ); + } + + #[test] + fn service_temp_is_removed_when_writer_fails() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let definition = root.join("missing/LaunchAgents/service.plist"); + assert_eq!( + publish_owner_only_user_service_file_with_hooks( + &definition, + b"new", + fail_after_partial_write, + || {}, + |directory| directory.sync_all().map_err(Into::into), + ), + Err(RuntimeError::Io) + ); + let entries = fs::read_dir(definition.parent().unwrap()).unwrap().count(); + assert_eq!(entries, 0); + } } diff --git a/src/service/linux.rs b/src/service/linux.rs index 951f6cf..c4a9407 100644 --- a/src/service/linux.rs +++ b/src/service/linux.rs @@ -105,12 +105,14 @@ impl UserServiceManager for LinuxUserServiceManager { } fn uninstall(&self) -> Result<(), ServiceError> { - let _ = self.systemctl(vec![ + if !self.systemctl(vec![ "--user".into(), "disable".into(), "--now".into(), "agenet.service".into(), - ]); + ])? { + return Err(ServiceError::UserSessionUnavailable); + } remove_definition(&self.spec)?; if !self.systemctl(vec!["--user".into(), "daemon-reload".into()])? { return Err(ServiceError::UserSessionUnavailable); diff --git a/src/service/macos.rs b/src/service/macos.rs index 7fb2c0d..ad82ddb 100644 --- a/src/service/macos.rs +++ b/src/service/macos.rs @@ -152,7 +152,7 @@ impl UserServiceManager for MacOsUserServiceManager { } fn uninstall(&self) -> Result<(), ServiceError> { - let _ = self.stop(); + self.stop()?; remove_definition(&self.spec) } diff --git a/src/service/mod.rs b/src/service/mod.rs index 04431ea..c12585a 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -15,7 +15,7 @@ use std::{ }; use crate::runtime::key_store::{ - atomic_write_owner_only, ensure_secure_user_service_dir, remove_owner_only_user_service_file, + publish_owner_only_user_service_file, remove_owner_only_user_service_file, }; const COMMAND_TIMEOUT: Duration = Duration::from_secs(10); @@ -240,18 +240,8 @@ pub trait UserServiceManager { pub(crate) fn publish_definition(spec: &ServiceSpec, bytes: &[u8]) -> Result<(), ServiceError> { spec.validate()?; - let parent = spec.definition.parent().ok_or(ServiceError::InvalidSpec)?; - ensure_secure_user_service_dir(parent).map_err(|_| ServiceError::UnsafeServicePath)?; - match std::fs::symlink_metadata(&spec.definition) { - Ok(metadata) if !metadata.file_type().is_file() || metadata.file_type().is_symlink() => { - return Err(ServiceError::UnsafeServicePath); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(_) => return Err(ServiceError::PersistenceUnavailable), - } - atomic_write_owner_only(&spec.definition, bytes, true) - .map_err(|_| ServiceError::PersistenceUnavailable) + publish_owner_only_user_service_file(&spec.definition, bytes) + .map_err(|_| ServiceError::UnsafeServicePath) } pub(crate) fn remove_definition(spec: &ServiceSpec) -> Result<(), ServiceError> { diff --git a/tests/service_linux.rs b/tests/service_linux.rs index ac5d19e..8bafa5f 100644 --- a/tests/service_linux.rs +++ b/tests/service_linux.rs @@ -165,3 +165,32 @@ fn unavailable_systemd_user_session_is_typed() { ); assert!(!spec.definition.exists()); } + +#[test] +fn systemd_uninstall_preserves_artifact_when_disable_fails() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let spec = ServiceSpec { + label: "agenet".to_owned(), + executable: root.join("agenet"), + config: root.join("config"), + state_root: root.join("state"), + definition: root.join("systemd/user/agenet.service"), + }; + let commands = Arc::new(Mutex::new(Vec::new())); + let runner = FakeRunner { + outcomes: Arc::new(Mutex::new(VecDeque::from([ + 0, // install daemon-reload + 1, // uninstall disable --now + ]))), + commands: Arc::clone(&commands), + }; + let manager = LinuxUserServiceManager::new(spec.clone(), runner); + manager.install(&spec).unwrap(); + assert_eq!( + manager.uninstall(), + Err(ServiceError::UserSessionUnavailable) + ); + assert!(spec.definition.exists()); + assert_eq!(commands.lock().unwrap().len(), 2); +} diff --git a/tests/service_macos.rs b/tests/service_macos.rs index 36c194e..27109bc 100644 --- a/tests/service_macos.rs +++ b/tests/service_macos.rs @@ -181,9 +181,11 @@ fn launch_agent_install_rejects_group_writable_parent() { let temp = tempfile::TempDir::new().unwrap(); let root = temp.path().canonicalize().unwrap(); - let service_dir = root.join("LaunchAgents"); - std::fs::create_dir(&service_dir).unwrap(); - std::fs::set_permissions(&service_dir, std::fs::Permissions::from_mode(0o770)).unwrap(); + let unsafe_ancestor = root.join("Library"); + let service_dir = unsafe_ancestor.join("LaunchAgents"); + std::fs::create_dir_all(&service_dir).unwrap(); + std::fs::set_permissions(&unsafe_ancestor, std::fs::Permissions::from_mode(0o770)).unwrap(); + std::fs::set_permissions(&service_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); let spec = ServiceSpec { label: "org.nexa-language.agenet.test".to_owned(), executable: root.join("agenet"), @@ -199,6 +201,32 @@ fn launch_agent_install_rejects_group_writable_parent() { assert_eq!(manager.install(&spec), Err(ServiceError::UnsafeServicePath)); } +#[test] +fn launch_agent_uninstall_preserves_artifact_when_bootout_fails() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let service_dir = root.join("Library/LaunchAgents"); + std::fs::create_dir_all(&service_dir).unwrap(); + let spec = ServiceSpec { + label: "org.nexa-language.agenet.test".to_owned(), + executable: root.join("agenet"), + config: root.join("config"), + state_root: root.clone(), + definition: service_dir.join("service.plist"), + }; + let runner = FakeRunner { + outcomes: Arc::new(Mutex::new(VecDeque::from([ + (true, &b"state = running\npid = 42\n"[..]), + (false, &b"sentinel-command-output"[..]), + ]))), + commands: Arc::new(Mutex::new(Vec::new())), + }; + let manager = MacOsUserServiceManager::new(spec.clone(), 501, runner); + manager.install(&spec).unwrap(); + assert_eq!(manager.uninstall(), Err(ServiceError::CommandFailed)); + assert!(spec.definition.exists()); +} + #[test] #[ignore = "mutates one uniquely labelled LaunchAgent; run explicitly on macOS"] #[cfg(target_os = "macos")] From e6ecb544069bb6c81007243f9a00e500561ca2e5 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 04:51:29 +0800 Subject: [PATCH 35/67] [feat][Bootstrap][12/14] Integrate host runtime Root cause: NA Solution: Load signed roles, exact Directory seeds, live policy, and mTLS host, Authority, and loopback demo runtimes. Risks: Loopback simulation is not physical multi-host evidence. Dependency: Bootstrap step 11 at bad35b3. Links: docs/design/agenet-v0.1.md --- README.md | 57 +- ROADMAP.md | 9 + docs/design/agenet-v0.1.md | 11 +- plan/01-v2-multi-host-node-bootstrap.md | 26 + src/bootstrap/config.rs | 22 +- src/bootstrap/enrollment.rs | 15 +- src/bootstrap/invitation.rs | 89 ++-- src/bootstrap/journal.rs | 10 +- src/bootstrap/paths.rs | 2 + src/cli/domain.rs | 44 +- src/cli/invite.rs | 2 +- src/cli/join.rs | 17 +- src/cli/mod.rs | 28 +- src/cli/node.rs | 23 +- src/demo.rs | 199 ++++++- src/node.rs | 224 ++++++-- src/protocol/enrollment.rs | 10 +- src/protocol/envelope.rs | 4 + src/protocol/mod.rs | 6 +- src/protocol/types.rs | 7 + src/runtime/artifact_access.rs | 23 +- src/runtime/clock.rs | 34 ++ src/runtime/directory.rs | 8 + src/runtime/error.rs | 3 + src/runtime/host.rs | 682 ++++++++++++++++++++++++ src/runtime/identity.rs | 44 +- src/runtime/mod.rs | 4 + src/runtime/provider.rs | 84 ++- src/runtime/recorder.rs | 43 +- src/runtime/requester.rs | 93 +++- src/service/mod.rs | 1 + src/service/supervisor.rs | 58 +- src/transport/client.rs | 125 ++++- src/transport/directory.rs | 89 +++- src/transport/enrollment.rs | 4 +- src/transport/mod.rs | 5 +- src/transport/node.rs | 209 ++++++-- src/transport/revocation.rs | 32 ++ src/transport/tls.rs | 39 +- tests/bootstrap_config.rs | 27 +- tests/enrollment_protocol.rs | 17 +- tests/host_runtime.rs | 49 ++ tests/http_directory.rs | 84 ++- tests/http_enrollment.rs | 7 +- tests/http_revocation.rs | 28 +- tests/invitation_store.rs | 30 +- tests/multiprocess_demo.rs | 35 +- tests/network_boundary.rs | 8 + 48 files changed, 2294 insertions(+), 376 deletions(-) create mode 100644 src/runtime/clock.rs create mode 100644 src/runtime/host.rs create mode 100644 tests/host_runtime.rs diff --git a/README.md b/README.md index afd4115..1239e2d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ including licenses, transitive footprint, and removal boundaries, is in Task 9 introduces a deliberately versioned local persistence boundary; it is not a promise that these schemas or phase choices will never change. -`agenet.node-config` schema 1 is a bounded, deny-unknown-fields JSON document. +`agenet.node-config` schema 2 is a bounded, deny-unknown-fields JSON document. It carries the Domain, bootstrap profile, network boundary, Directory seeds, Authority endpoint, and revocation endpoint. Private-overlay endpoints are exact IP-literal HTTPS origins with no credentials, query, fragment, DNS name, @@ -46,8 +46,8 @@ must be current-user `0700` non-symlink directories; owner-only files must be regular current-user `0600` files and are opened with no-follow, nonblocking, bounded reads. Startup material is returned only after credential Root/domain/role/profile/time, signing-key, TLS chain/key, NodeId, exact IP SAN, -and certificate-time validation succeeds. Building the live service from this -validated bundle remains Task 12. +and certificate-time validation succeeds. Each Directory seed is the exact +versioned pair `{ endpoint, node_id }`; URL-only legacy seeds fail closed. The credential, signing key, certificate, TLS key, and CA are intentionally separate files, not a claimed multi-file transaction. A writer must hold the @@ -121,14 +121,29 @@ credential material. A later start republishes and reconciles an uncertain post-delete state. Repeated start and stop operations do not advance to `Registered` or `Healthy`. -The Task 11 service runs the internal bootstrap supervisor. It strictly loads +The service runs the internal bootstrap supervisor. It strictly loads the persisted config, Root/Authority/Node credential chain, signing key, peer TLS identity, and schema-2 journal; holds the single-instance journal lock; and -exits gracefully on termination. It intentionally opens no network listener. -Status therefore reports `service_process` independently and always reports -`runtime_ready: false` in this phase. Task 12 will attach the v0.2 runtime to -this same entrypoint. This boundary is provisional and may migrate as physical -multi-device evidence exposes better lifecycle semantics. +exits gracefully on termination. Task 12 attaches the v0.2 peer runtime to this +same entrypoint. It loads the current revocation cache (refreshing through the +pinned Authority CA if necessary), derives handlers only from verified signed +roles, binds the exact configured address, performs an mTLS health probe, and +registers signed provider manifests before publishing an owner-only readiness +artifact. Registration/startup failure withdraws readiness and reaps the +listener. `runtime_ready` means this base peer runtime is listening under +current policy; it does not imply a Requester pursuit interface is enabled. +Without explicit secure local-control/model configuration, a signed Requester +role remains health-only and reports Requester disabled. Bootstrap profile is +never used as an authorization role or capability grant. + +A verified founding Directory additionally loads a typed founding-only +Authority runtime from the existing owner-only Authority credential/signing +key, CA certificate/key, invitation state, enrollment-result directory, and +revocation Authority state. It never opens the Domain Root private keystore. +The supervisor signs a short-lived exact-IP server leaf, self-checks separate +server-auth TLS enrollment and revocation listeners, keeps revocation snapshots +fresh with the online Authority key, and cancels all listeners if any required +surface fails. Non-Directory credentials never load these Authority files. These services persist only after the owning user logs in. AgenNet does not use `sudo`, install a system service, enable Linux linger, embed an environment @@ -136,7 +151,7 @@ file, or put secrets in the service definition. ## MVP boundary -The MVP runs four independent processes on different `127.0.0.1` ports: +The demo runs four independent processes on dynamic loopback ports: - Directory/Router - Requester Agent @@ -145,9 +160,13 @@ The MVP runs four independent processes on different `127.0.0.1` ports: The first real Capability is `source.metrics.v1`: it computes a source Artifact's SHA-256 digest, byte count, line count, and non-empty line count. The Verifier independently recomputes the same metrics. Delivery does not become `Accepted` until verification succeeds. -The original MVP validates loopback coordination semantics. The provisional -v0.2 transport now adds an Authority-CA mutual-TLS path for private-overlay -listeners, but physical multi-machine reachability is not yet verified. It does +The demo now uses Authority-CA mutual TLS for every peer hop, exact-IP SANs, +NodeId-bound signed envelopes, current revocation snapshots, and dynamically +signed Directory results. It probes `127.0.0.2` through `127.0.0.5`; hosts that +do not expose those aliases fall back to distinct ports on `127.0.0.1`. The +summary labels these as `simulated_loopback_aliases_mtls` or +`loopback_ports_mtls` beneath `loopback_harness`. Neither is physical +multi-machine evidence. It does **not** validate distributed failover, quota accounting, arbitrary code sandboxing, or Internet-scale discovery. @@ -165,7 +184,7 @@ Required Walkman alias names are `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `VLM_M The successful command prints one JSON summary containing: -- four distinct PIDs, loopback addresses, Node IDs, and state directories; +- four distinct PIDs, HTTPS loopback addresses, Node IDs, and state directories; - source and verification Contract IDs; - `Proposed → Active → Running → Delivered → Accepted`; - the immutable Artifact hash and both metric results; @@ -177,11 +196,11 @@ Private keys and the local control token are stored with mode `0600`. Runtime st ## Architecture ```text -demo harness - ├─ Directory 127.0.0.1:dynamic - ├─ Requester 127.0.0.1:dynamic ── real LLM decision - ├─ Executor 127.0.0.1:dynamic ── source.metrics.v1 - └─ Verifier 127.0.0.1:dynamic ── source.metrics.verify.v1 +demo harness (HTTPS/mTLS; loopback aliases when assigned) + ├─ Directory loopback:dynamic + ├─ Requester loopback:dynamic ── real LLM decision + ├─ Executor loopback:dynamic ── source.metrics.v1 + └─ Verifier loopback:dynamic ── source.metrics.verify.v1 Requester → Directory → signed CandidateSet Requester → Executor → bilateral source Contract → Delivered Evidence diff --git a/ROADMAP.md b/ROADMAP.md index a2b53e0..e934df8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Task 12 host runtime, exact Directory identity, and mTLS loopback flow + +- **Change**: Attached the validated v0.2 host runtime to `node service-run`, migrated Directory seeds and enrollment artifacts to exact endpoint/NodeId pairs, added request-time clock/revocation enforcement, role/ceiling-safe Provider dispatch, readiness lifecycle, and an HTTPS/mTLS four-process loopback flow. +- **Files**: bootstrap invitation/enrollment/config migration, runtime identity/clock/host/provider/requester/recorder/artifact modules, peer TLS/Directory/revocation transports, node/demo/supervisor CLI, focused tests, README/design/v2 plan, and ignored Task 12 evidence. +- **Decision reason**: A URL did not authenticate a Directory, BootstrapProfile was incorrectly positioned to influence runtime roles, a fixed startup timestamp could keep expired/revoked identities effective, and Provider registration occurred before transport readiness. Exact signed identities, verified role sets, a live clock, and health-before-registration are required security boundaries. +- **Post-mortem (误解需求)**: The initial runtime model assumed one profile meant one role and kept a fixed validation timestamp. Prevention: every public effect derives authority from `VerifiedNodeClaims`, maps capability kind to a signed role and ceiling, and rechecks credential/revocation policy at request time. +- **Post-mortem (技术盲区)**: The first seed format persisted only a URL, so TLS could not bind the intended Directory NodeId without guessing. Prevention: version all invitation/enrollment/config consumers together and require `DirectorySeed { endpoint, node_id }`, rejecting duplicates and cross-pairs. +- **Evidence boundary**: The demo is `loopback_harness`; it uses mTLS and probes loopback aliases but falls back to distinct ports where aliases are unavailable. This is not physical multi-host, sandbox, failover, or Internet-scale evidence. Task 14 still owns physical-device acceptance. + ## 2026-08-15 03:26 CST - **Change**: Corrected Task 11 activation compensation and service-artifact pathname trust after review. Rollback now requires observed stopped process plus exact artifact absence; service publication and deletion walk and pin every absolute ancestor from `/`. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 75f8118..9ce0c9f 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -18,7 +18,7 @@ AgenNet is a coordination substrate in which capabilities, intents, grants, cont The reference runtime is deployed as four independent processes with unique identities, ports, and state directories. The Requester knows only a Directory seed. It uses a real LLM adapter to translate a natural-language goal into a typed Intent, dynamically discovers an Executor and Verifier, signs two Contracts, and accepts only independently reproduced source metrics. -Peer traffic is signed with Ed25519 but uses plaintext HTTP restricted to loopback. Filesystem paths never appear in peer protocol objects; content is imported into a requester-owned content-addressed Artifact store and read only through a matching Contract and Grant. +Peer traffic is signed with Ed25519 and the current v0.2 demo also runs every peer hop through Authority-CA mTLS. Loopback aliases are probed and used when assigned; otherwise distinct dynamic ports share `127.0.0.1`. This is simulated transport evidence, not a physical multi-host claim. Filesystem paths never appear in peer protocol objects; content is imported into a requester-owned content-addressed Artifact store and read only through a matching Contract and Grant. ## Signed protocol kernel @@ -52,7 +52,7 @@ Only the Requester can append `Accepted`, and it does so only after matching the The Decision layer receives only the natural-language goal, the strict Intent projection schema, and the public Capability kind. Two explicitly tested wire styles exist: `openai_chat_completions_v1` appends `/chat/completions` when required and uses a sensitive Authorization header; the Walkman-backed manual demo uses `gemini_multimodal_inline_v1`, treats the configured URL as a complete endpoint, places the credential in the `ak` query parameter, and sends inline text content. Neither style puts credentials in Debug output or logs. Both use `temperature: 0`, limit responses to 64 KiB, perform at most one real format-repair call, reject redirects, and have no manual-demo fallback. -The demo provisions an ephemeral Domain Root and four Node Credentials, starts four copies of the `agenet node` binary on `127.0.0.1:0`, waits for signed Capability registration, submits one local pursuit with a bearer token read from a `0600` file, enforces a 90-second outer timeout, sends SIGTERM, and retains state for audit. +The demo provisions an ephemeral Domain Root, one Authority, four v0.3 Node Credentials, exact-IP peer certificates, and a current signed revocation snapshot. It starts four copies of the `agenet node` binary on dynamic HTTPS loopback listeners, waits for mTLS health before signed Capability registration, submits one local pursuit with a bearer token read from a `0600` file, enforces a 90-second outer timeout, sends SIGTERM, and retains state for audit. ## Validation matrix @@ -61,17 +61,18 @@ The demo provisions an ephemeral Domain Root and four Node Credentials, starts f | Exact-byte Credential, Envelope, Contract, and Event signatures | automated | unit/property tests | | Grant and Artifact read scope | automated | protocol and Axum tests | | Journal replay and operation idempotency | automated | restart test | -| Login-scoped service definition and supervisor | automated + native macOS smoke | root-anchored no-follow path walk and fact-checked rollback; runtime remains not ready until Task 12 | +| Login-scoped service and host runtime | automated | validated bundle, live policy clock, exact bind, mTLS self-probe, readiness withdrawal | | Independent source metric reproduction | automated | separate implementations plus third oracle | | Four PIDs and four dynamic ports | automated | real child-process test | | Real ModelHub Intent projection | verified locally | Walkman env run `3cf7bccc-932b-4b77-9ae5-514f8a52f961` | -| TLS or secure multi-machine sessions | not implemented | deferred | +| Four-process loopback mTLS session | automated | aliases when available, otherwise distinct loopback ports; not physical hosts | +| Physical secure multi-machine sessions | not verified | deferred to Task 14 | | Sandbox for arbitrary code | not implemented | deferred | | Quota, replication, failover, and federation | not implemented | deferred | ## Deliberately deferred -- TLS and cross-machine peer sessions +- physical cross-machine acceptance - replicated state and Recorder failover - quota reservation and revocation - leases, checkpoints, and relays diff --git a/plan/01-v2-multi-host-node-bootstrap.md b/plan/01-v2-multi-host-node-bootstrap.md index 40cf676..ef32155 100644 --- a/plan/01-v2-multi-host-node-bootstrap.md +++ b/plan/01-v2-multi-host-node-bootstrap.md @@ -68,3 +68,29 @@ no listener, registration, readiness, or health claim. - A post-delete directory-sync or status failure can leave the artifact absent while the journal remains `ServicePrepared`; retry republishes and reconciles instead of guessing whether cleanup was durable. + +## Task 12 protocol and runtime amendment + +Task 12 found that URL-only Directory seeds and single-role runtime views were +load-bearing ambiguities. The version mapping is now explicit: + +- Invitation public claims, handoff, and invitation journal use v4. +- Enrollment request, wire protocol, and durable result use v0.4. +- Node config uses schema 2 with `DirectorySeed { endpoint, node_id }`. +- Node Credential remains v0.3 and bootstrap journal remains schema 2. + +Legacy URL-only forms fail closed. Seed ordering is signed; duplicate endpoint +or NodeId entries are rejected, and TLS plus signed responses must both match +the exact seed NodeId. Authorization comes from the full verified signed role +set. Bootstrap profile is enrollment policy only. A Provider may publish +Executor and Verifier manifests from one mTLS endpoint, but Contract capability +dispatch occurs only after the signed role, Grant, Contract capability, and +credential ceiling all agree. + +The host supervisor loads config, credential chain, TLS identity, network +boundary, and current revocation state before exact bind. It publishes runtime +readiness only after mTLS health and required registration. A signed Requester +without explicit secure local-control/model configuration remains a healthy +base runtime with pursuits disabled. All request-time credential and revocation +checks use an injected live clock. The demo is an ephemeral loopback harness; +its alias/port mTLS evidence cannot replace Task 14 physical-device acceptance. diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index 38db8dd..7cf691d 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize}; use url::Url; use crate::{ - protocol::{BootstrapProfile, CredentialChain, DomainId, NodeRole, verify_credential_chain}, + protocol::{ + BootstrapProfile, CredentialChain, DirectorySeed, DomainId, NodeRole, + verify_credential_chain, + }, transport::{PeerTlsIdentity, tls::validate_persisted_peer_identity}, }; use zeroize::Zeroizing; @@ -17,7 +20,7 @@ use super::{ }; const CONFIG_FORMAT: &str = "agenet.node-config"; -const CONFIG_SCHEMA_VERSION: u32 = 1; +const CONFIG_SCHEMA_VERSION: u32 = 2; const MAX_CONFIG_BYTES: usize = 64 * 1024; const MAX_DIRECTORY_SEEDS: usize = 8; @@ -30,7 +33,8 @@ pub struct NodeConfigV1 { pub domain_id: DomainId, pub profile: BootstrapProfile, pub network: NetworkBoundary, - pub directory_seeds: Vec, + pub peer_port: u16, + pub directory_seeds: Vec, pub authority_endpoint: Url, pub revocation_endpoint: Url, } @@ -132,6 +136,7 @@ impl NodeConfigV1 { || DomainId::new(self.domain_id.as_str()).is_err() || self.directory_seeds.is_empty() || self.directory_seeds.len() > MAX_DIRECTORY_SEEDS + || (self.peer_port == 0 && self.network.kind != OverlayKind::Loopback) { return Err(BootstrapError::InvalidConfig); } @@ -150,12 +155,15 @@ impl NodeConfigV1 { { return Err(BootstrapError::InvalidConfig); } - let mut unique = BTreeSet::new(); - for endpoint in &self.directory_seeds { - if !unique.insert(endpoint.as_str()) { + let mut unique_endpoints = BTreeSet::new(); + let mut unique_nodes = BTreeSet::new(); + for seed in &self.directory_seeds { + if !unique_endpoints.insert(seed.endpoint.as_str()) + || !unique_nodes.insert(seed.node_id.as_str()) + { return Err(BootstrapError::InvalidConfig); } - validate_endpoint(&self.network, endpoint)?; + validate_endpoint(&self.network, &seed.endpoint)?; } validate_endpoint(&self.network, &self.authority_endpoint)?; validate_endpoint(&self.network, &self.revocation_endpoint) diff --git a/src/bootstrap/enrollment.rs b/src/bootstrap/enrollment.rs index f9258b5..80da713 100644 --- a/src/bootstrap/enrollment.rs +++ b/src/bootstrap/enrollment.rs @@ -33,7 +33,7 @@ use super::{ InvitationPublicClaims, InvitationStore, ReservationStatus, }; -const ENROLLMENT_WIRE_VERSION: &str = "agenet.enrollment-wire.v0.3"; +const ENROLLMENT_WIRE_VERSION: &str = "agenet.enrollment-wire.v0.4"; const MAX_RESULT_BYTES: usize = 256 * 1024; pub(crate) const MAX_ENROLLMENT_REQUEST_BYTES: usize = 256 * 1024; @@ -524,7 +524,7 @@ impl EnrollmentAuthority { return Err(EnrollmentError::TransportFailed); } let result = DurableEnrollmentResult { - format_version: "agenet.enrollment-result.v0.3".to_owned(), + format_version: "agenet.enrollment-result.v0.4".to_owned(), invitation_id: request.public_claims.invitation_id, operation_id: verified.claims.operation_id, exact_request_sha256: verified.request_digest, @@ -681,7 +681,7 @@ impl EnrollmentAuthority { } let result: DurableEnrollmentResult = serde_json::from_slice(&encoded).map_err(|_| EnrollmentError::PersistenceFailed)?; - if result.format_version != "agenet.enrollment-result.v0.3" + if result.format_version != "agenet.enrollment-result.v0.4" || result.invitation_id != invitation_id || result.operation_id != operation_id || result.issued_at_ms <= 0 @@ -1081,12 +1081,13 @@ mod tests { let handoff = invitations .create( super::super::InvitationSpec { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), domain_id: authority_credential.claims.domain_id.clone(), authority_endpoint: endpoint.clone(), - directory_seeds: vec![ - Url::parse("https://127.0.0.1:9443/").expect("directory"), - ], + directory_seeds: vec![crate::protocol::DirectorySeed { + endpoint: Url::parse("https://127.0.0.1:9443/").expect("directory"), + node_id: NodeId::new("node:directory:test").expect("node id"), + }], network_kind: super::super::network::OverlayKind::Loopback, allowed_cidrs: vec!["127.0.0.1/32".parse().expect("CIDR")], root_sha256: fingerprint_bytes(root.verifying_key().as_bytes()), diff --git a/src/bootstrap/invitation.rs b/src/bootstrap/invitation.rs index 9e708b0..1be6a3a 100644 --- a/src/bootstrap/invitation.rs +++ b/src/bootstrap/invitation.rs @@ -18,7 +18,7 @@ use sha2::{Digest, Sha256}; use uuid::Uuid; use zeroize::{Zeroize, Zeroizing}; -use crate::protocol::{BootstrapProfile, CapabilityKind, DomainId, NodeId}; +use crate::protocol::{BootstrapProfile, CapabilityKind, DirectorySeed, DomainId, NodeId}; use super::{BootstrapError, journal::DurableJournal, network::OverlayKind}; @@ -34,7 +34,7 @@ const MAXIMUM_ATTEMPTS: u8 = 5; const MAX_INVITATIONS: usize = 10_000; const MAX_DIRECTORY_SEEDS: usize = 16; const MAX_CAPABILITIES: usize = 64; -const HANDOFF_FORMAT_V3: &str = "agenet.invitation-handoff.v3"; +const HANDOFF_FORMAT_V4: &str = "agenet.invitation-handoff.v4"; const MAX_HANDOFF_BYTES: usize = 16 * 1024; type HmacSha256 = Hmac; @@ -75,8 +75,7 @@ pub struct InvitationPublicClaims { pub domain_id: DomainId, #[serde(with = "url_serde")] pub authority_endpoint: Url, - #[serde(with = "url_vec_serde")] - pub directory_seeds: Vec, + pub directory_seeds: Vec, pub network_kind: OverlayKind, pub allowed_cidrs: Vec, pub root_sha256: String, @@ -95,8 +94,7 @@ pub struct InvitationSpec { pub domain_id: DomainId, #[serde(with = "url_serde")] pub authority_endpoint: Url, - #[serde(with = "url_vec_serde")] - pub directory_seeds: Vec, + pub directory_seeds: Vec, pub network_kind: OverlayKind, pub allowed_cidrs: Vec, pub root_sha256: String, @@ -280,7 +278,7 @@ fn encode_handoff_with_limit( ) -> Result>, BootstrapError> { validate_public_claims(&handoff.public_claims)?; let wire = InvitationHandoffRef { - format_version: HANDOFF_FORMAT_V3, + format_version: HANDOFF_FORMAT_V4, public_claims: &handoff.public_claims, secret: handoff.authentication.secret_for_request().expose_secret(), claims_integrity_hmac_sha256_base64: URL_SAFE_NO_PAD @@ -360,12 +358,12 @@ fn decode_handoff(encoded: &[u8]) -> Result { } let format: InvitationHandoffFormatProbe = serde_json::from_slice(encoded).map_err(|_| BootstrapError::InvalidInvitationClaims)?; - if format.format_version != HANDOFF_FORMAT_V3 { + if format.format_version != HANDOFF_FORMAT_V4 { return Err(BootstrapError::UnsupportedInvitationFormat); } let owned: InvitationHandoffOwned = serde_json::from_slice(encoded).map_err(|_| BootstrapError::InvalidInvitationClaims)?; - if owned.format_version != HANDOFF_FORMAT_V3 { + if owned.format_version != HANDOFF_FORMAT_V4 { return Err(BootstrapError::UnsupportedInvitationFormat); } validate_public_claims(&owned.public_claims)?; @@ -1074,7 +1072,7 @@ pub(crate) fn public_claims_sha256_for_enrollment( fn encode_public_claims(claims: &InvitationPublicClaims) -> Result, BootstrapError> { validate_public_claims(claims)?; let mut encoded = Vec::with_capacity(512); - encoded.extend_from_slice(b"AGENET\0invitation-public-claims-v2\0"); + encoded.extend_from_slice(b"AGENET\0invitation-public-claims-v4\0"); append_claim_field(&mut encoded, claims.protocol_version.as_bytes())?; append_claim_field(&mut encoded, claims.domain_id.as_str().as_bytes())?; append_claim_field(&mut encoded, claims.authority_endpoint.as_str().as_bytes())?; @@ -1086,7 +1084,8 @@ fn encode_public_claims(claims: &InvitationPublicClaims) -> Result, Boot .to_be_bytes(), ); for directory in &claims.directory_seeds { - append_claim_field(&mut directories, directory.as_str().as_bytes())?; + append_claim_field(&mut directories, directory.endpoint.as_str().as_bytes())?; + append_claim_field(&mut directories, directory.node_id.as_str().as_bytes())?; } append_claim_field(&mut encoded, &directories)?; append_claim_field(&mut encoded, &[overlay_discriminant(claims.network_kind)])?; @@ -1243,7 +1242,21 @@ fn validate_specification(specification: &InvitationSpec) -> Result<(), Bootstra || specification .directory_seeds .iter() - .any(|seed| !valid_private_endpoint_shape(seed)) + .any(|seed| !valid_private_endpoint_shape(&seed.endpoint)) + || specification + .directory_seeds + .iter() + .map(|seed| &seed.endpoint) + .collect::>() + .len() + != specification.directory_seeds.len() + || specification + .directory_seeds + .iter() + .map(|seed| &seed.node_id) + .collect::>() + .len() + != specification.directory_seeds.len() || !valid_sha256(&specification.root_sha256) || !valid_sha256(&specification.tls_ca_sha256) || specification.capability_ceiling.len() > MAX_CAPABILITIES @@ -1264,7 +1277,8 @@ fn validate_specification(specification: &InvitationSpec) -> Result<(), Bootstra .validate_bind_shape() .map_err(|_| BootstrapError::InvalidInvitationClaims)?; if specification.directory_seeds.iter().any(|seed| { - seed.host_str() + seed.endpoint + .host_str() .and_then(parse_ip_host) .is_none_or(|address| !boundary.allows_peer(address)) }) { @@ -1489,31 +1503,6 @@ mod url_serde { } } -mod url_vec_serde { - use reqwest::Url; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - - pub(super) fn serialize(urls: &[Url], serializer: S) -> Result - where - S: Serializer, - { - urls.iter() - .map(Url::as_str) - .collect::>() - .serialize(serializer) - } - - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - Vec::::deserialize(deserializer)? - .into_iter() - .map(|value| Url::parse(&value).map_err(serde::de::Error::custom)) - .collect() - } -} - #[cfg(test)] mod tests { use super::*; @@ -1533,12 +1522,13 @@ mod tests { fn handoff(invitation_id: Uuid, domain: &str) -> InvitationHandoff { let public_claims = InvitationPublicClaims { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), domain_id: DomainId::new(domain).expect("test domain is valid"), authority_endpoint: Url::parse("https://100.64.0.1:7443/").expect("test URL is valid"), - directory_seeds: vec![ - Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), - ], + directory_seeds: vec![DirectorySeed { + endpoint: Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), + node_id: NodeId::new("node:directory:test").expect("node id"), + }], network_kind: OverlayKind::Tailscale, allowed_cidrs: vec!["100.64.0.0/10".parse().expect("CIDR")], root_sha256: "11".repeat(32), @@ -1601,12 +1591,13 @@ mod tests { .expect("state directory is owner-only"); let store = InvitationStore::open(temp.path()).expect("store opens"); let specification = || InvitationSpec { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), domain_id: DomainId::new("domain:secret-proof").expect("test domain is valid"), authority_endpoint: Url::parse("https://100.64.0.1:7443/").expect("test URL is valid"), - directory_seeds: vec![ - Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), - ], + directory_seeds: vec![DirectorySeed { + endpoint: Url::parse("https://100.64.0.1:7444/").expect("test URL is valid"), + node_id: NodeId::new("node:directory:test").expect("node id"), + }], network_kind: OverlayKind::Tailscale, allowed_cidrs: vec!["100.64.0.0/10".parse().expect("CIDR")], root_sha256: "11".repeat(32), @@ -1729,7 +1720,7 @@ mod tests { let encoded = encode_handoff(&handoff).expect("handoff encodes"); let format: InvitationHandoffFormatProbe = serde_json::from_slice(&encoded).expect("format probe parses"); - assert_eq!(format.format_version, HANDOFF_FORMAT_V3); + assert_eq!(format.format_version, HANDOFF_FORMAT_V4); let decoded = decode_handoff(&encoded).expect("handoff decodes"); assert_eq!(decoded.public_claims, handoff.public_claims); assert!( @@ -1769,7 +1760,7 @@ mod tests { for version in [ "agenet.invitation-handoff.v1", "agenet.invitation-handoff.v2", - "agenet.invitation-handoff.v4", + "agenet.invitation-handoff.v5", ] { let mut unsupported = original.clone(); unsupported["format_version"] = serde_json::json!(version); @@ -1812,7 +1803,7 @@ mod tests { let mutations: &[(&str, serde_json::Value)] = &[ ( "protocol_version", - serde_json::json!("agenet.enrollment.v0.4"), + serde_json::json!("agenet.enrollment.v0.3"), ), ("domain_id", serde_json::json!("domain:attacker")), ( diff --git a/src/bootstrap/journal.rs b/src/bootstrap/journal.rs index bb36ccd..97ecf6b 100644 --- a/src/bootstrap/journal.rs +++ b/src/bootstrap/journal.rs @@ -12,7 +12,7 @@ use sha2::{Digest, Sha256}; use super::BootstrapError; const HEADER_PREFIX: &[u8] = b"AGENET-INVITATION-JOURNAL\0"; -const HEADER_V3: &[u8] = b"AGENET-INVITATION-JOURNAL\0\x03"; +const HEADER_V4: &[u8] = b"AGENET-INVITATION-JOURNAL\0\x04"; const CHECKSUM_BYTES: usize = 32; const MAX_RECORD_BYTES: usize = 64 * 1024; const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024; @@ -33,7 +33,7 @@ where let metadata = file.metadata().map_err(|_| BootstrapError::StorageFailed)?; require_owner_only_regular(&metadata)?; if created { - file.write_all(HEADER_V3) + file.write_all(HEADER_V4) .map_err(|_| BootstrapError::StorageFailed)?; persist(&mut file)?; sync_parent(path)?; @@ -129,13 +129,13 @@ fn open_new(path: &Path) -> Result { } fn decode_entries(bytes: &[u8]) -> Result, BootstrapError> { - if bytes.starts_with(HEADER_PREFIX) && !bytes.starts_with(HEADER_V3) { + if bytes.starts_with(HEADER_PREFIX) && !bytes.starts_with(HEADER_V4) { return Err(BootstrapError::UnsupportedInvitationFormat); } - if !bytes.starts_with(HEADER_V3) { + if !bytes.starts_with(HEADER_V4) { return Err(BootstrapError::InvalidJournal); } - let mut cursor = HEADER_V3.len(); + let mut cursor = HEADER_V4.len(); let mut entries = Vec::new(); while cursor < bytes.len() { if entries.len() >= MAX_RECORDS || bytes.len() - cursor < 4 { diff --git a/src/bootstrap/paths.rs b/src/bootstrap/paths.rs index f443095..82fd605 100644 --- a/src/bootstrap/paths.rs +++ b/src/bootstrap/paths.rs @@ -59,6 +59,7 @@ pub struct NodePaths { pub authority_ca_private_key_file: PathBuf, pub authority_ca_certificate_file: PathBuf, pub invitation_state_dir: PathBuf, + pub enrollment_result_dir: PathBuf, pub pending_join_file: PathBuf, } @@ -136,6 +137,7 @@ impl NodePaths { authority_ca_private_key_file: state_dir.join("authority-ca-private-v1.pem"), authority_ca_certificate_file: state_dir.join("authority-ca-certificate-v1.pem"), invitation_state_dir: state_dir.join("invitations"), + enrollment_result_dir: state_dir.join("enrollment-results-v1"), pending_join_file: state_dir.join("pending-join-v1.json"), config_dir, state_dir, diff --git a/src/cli/domain.rs b/src/cli/domain.rs index 1afbfbd..261a145 100644 --- a/src/cli/domain.rs +++ b/src/cli/domain.rs @@ -21,10 +21,13 @@ use crate::{ network::{NetworkBoundary, OverlayKind}, }, protocol::{ - AuthorityClaims, AuthorityScope, BootstrapProfile, CredentialChain, DomainId, - NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, + AuthorityClaims, AuthorityScope, BootstrapProfile, CredentialChain, DirectorySeed, + DomainId, NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, + }, + runtime::{ + AuthorityRevocationStore, RevocationCache, + key_store::{atomic_write_owner_only_strict, write_signing_key_strict}, }, - runtime::key_store::{atomic_write_owner_only_strict, write_signing_key_strict}, transport::PeerTlsIdentity, }; @@ -245,11 +248,15 @@ pub(crate) fn provision( let directory_endpoint = endpoint(boundary.bind_ip, DIRECTORY_PORT)?; let config = NodeConfigV1 { format: "agenet.node-config".to_owned(), - schema_version: 1, + schema_version: 2, domain_id: domain_id.clone(), profile: BootstrapProfile::Base, network: boundary, - directory_seeds: vec![directory_endpoint.clone()], + peer_port: DIRECTORY_PORT, + directory_seeds: vec![DirectorySeed { + endpoint: directory_endpoint.clone(), + node_id: founding_node_id.clone(), + }], authority_endpoint: authority_endpoint.clone(), revocation_endpoint: endpoint(config_ip(&authority_endpoint)?, REVOCATION_PORT)?, }; @@ -266,13 +273,30 @@ pub(crate) fn provision( .map_err(map_bootstrap)?; persist_public_and_authority(&paths, &root, &authority_key, &authority, &pki)?; paths.write_config(&config).map_err(map_bootstrap)?; + let chain = CredentialChain { authority, node }; paths - .write_startup_material( - &CredentialChain { authority, node }, - &founding_key, - &identity, - ) + .write_startup_material(&chain, &founding_key, &identity) .map_err(map_bootstrap)?; + let revocation_store = AuthorityRevocationStore::open( + &paths.state_dir, + root.verifying_key(), + chain.authority.clone(), + authority_key, + ) + .map_err(|_| internal())?; + let initial_snapshot = revocation_store + .publish(now, BTreeSet::new(), BTreeSet::new()) + .map_err(|_| internal())?; + let revocation_cache = RevocationCache::open( + &paths.state_dir, + root.verifying_key(), + domain_id.clone(), + chain.authority.clone(), + ) + .map_err(|_| internal())?; + revocation_cache + .accept(initial_snapshot, now) + .map_err(|_| internal())?; let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; for (id, phase) in [ ("domain-binary-v1", BootstrapPhase::BinaryInstalled), diff --git a/src/cli/invite.rs b/src/cli/invite.rs index a4cc4c9..b5c86b8 100644 --- a/src/cli/invite.rs +++ b/src/cli/invite.rs @@ -120,7 +120,7 @@ fn create_at( let handoff = store .create_with_ttl( InvitationSpec { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), domain_id: root.domain_id.clone(), authority_endpoint: bundle.config.authority_endpoint.clone(), directory_seeds: bundle.config.directory_seeds.clone(), diff --git a/src/cli/join.rs b/src/cli/join.rs index d1e6b40..9d64cc1 100644 --- a/src/cli/join.rs +++ b/src/cli/join.rs @@ -132,10 +132,11 @@ async fn execute_at( .map_err(|_| internal())?; let config = NodeConfigV1 { format: "agenet.node-config".to_owned(), - schema_version: 1, + schema_version: 2, domain_id: bundle.domain_id.clone(), profile: pending.profile, network: policy, + peer_port: 7444, directory_seeds: bundle.directory_seeds.clone(), authority_endpoint: bundle.authority_endpoint.clone(), revocation_endpoint, @@ -485,10 +486,13 @@ mod tests { #[test] fn tailscale_auto_selects_exactly_one_policy_address_and_rejects_ambiguity() { let claims = crate::bootstrap::InvitationPublicClaims { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), domain_id: DomainId::new("domain:auto-bind").expect("domain"), authority_endpoint: url::Url::parse("https://100.64.0.1:7443/").expect("URL"), - directory_seeds: vec![url::Url::parse("https://100.64.0.1:7444/").expect("URL")], + directory_seeds: vec![crate::protocol::DirectorySeed { + endpoint: url::Url::parse("https://100.64.0.1:7444/").expect("URL"), + node_id: NodeId::new("node:directory:test").expect("node id"), + }], network_kind: crate::bootstrap::network::OverlayKind::Tailscale, allowed_cidrs: vec!["100.64.0.0/10".parse().expect("CIDR")], root_sha256: "11".repeat(32), @@ -582,10 +586,13 @@ mod tests { let handoff = invitations .create( InvitationSpec { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), domain_id: domain, authority_endpoint: endpoint.clone(), - directory_seeds: vec![endpoint.clone()], + directory_seeds: vec![crate::protocol::DirectorySeed { + endpoint: endpoint.clone(), + node_id: NodeId::new("node:directory:test").expect("node id"), + }], network_kind: crate::bootstrap::network::OverlayKind::Loopback, allowed_cidrs: vec!["127.0.0.1/32".parse().expect("CIDR")], root_sha256: Sha256::digest(root.verifying_key().as_bytes()) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 553617b..0edce11 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -65,7 +65,7 @@ pub struct Cli { enum Command { Domain(domain::DomainArgs), Invite(invite::InviteArgs), - Node(NodeArgs), + Node(Box), Demo(DemoArgs), } @@ -88,7 +88,17 @@ struct NodeArgs { #[arg(long)] directory_seed: Option, #[arg(long)] + directory_node_id: Option, + #[arg(long)] control_token_file: Option, + #[arg(long)] + tls_certificate_file: Option, + #[arg(long)] + tls_private_key_file: Option, + #[arg(long)] + authority_ca_file: Option, + #[arg(long, default_value = "127.0.0.1")] + bind_ip: std::net::IpAddr, } #[derive(Debug, Subcommand)] @@ -189,7 +199,7 @@ async fn run_cli(cli: Cli) -> i32 { OutputFormat::Human, service_node::service_run(service_args).await, ), - None => (OutputFormat::Human, run_internal_node(args).await), + None => (OutputFormat::Human, run_internal_node(*args).await), }, Command::Demo(args) => ( OutputFormat::Human, @@ -228,8 +238,20 @@ async fn run_internal_node(args: NodeArgs) -> Result<(), CliError> { root_public_key_file: args.root_public_key_file.ok_or_else(missing)?, ready_file: args.ready_file.ok_or_else(missing)?, directory_seed: args.directory_seed, + directory_node_id: args + .directory_node_id + .map(crate::protocol::NodeId::new) + .transpose() + .map_err(|_| missing())?, control_token_file: args.control_token_file, - network: NetworkBoundary::loopback_ipv4(), + tls_certificate_file: args.tls_certificate_file.ok_or_else(missing)?, + tls_private_key_file: args.tls_private_key_file.ok_or_else(missing)?, + authority_ca_file: args.authority_ca_file.ok_or_else(missing)?, + network: NetworkBoundary { + kind: crate::bootstrap::network::OverlayKind::Loopback, + bind_ip: args.bind_ip, + allowed_cidrs: vec!["127.0.0.0/8".parse().expect("static CIDR")], + }, }) .await .map(|_| ()) diff --git a/src/cli/node.rs b/src/cli/node.rs index a556bd8..2b00f94 100644 --- a/src/cli/node.rs +++ b/src/cli/node.rs @@ -186,12 +186,28 @@ fn emit_status( phase: phase_name(current_service_phase(paths)?), service_installed: status.installed, service_process: status.process, - runtime_ready: false, + runtime_ready: runtime_ready(paths, &status), persistence: "after_user_login", }; output::emit(format, human, &result) } +fn runtime_ready(paths: &NodePaths, status: &crate::service::ServiceStatus) -> bool { + if status.process != crate::service::ServiceProcessState::Running { + return false; + } + let Ok(bytes) = + crate::runtime::key_store::read_owner_only(&paths.service_metadata_file, 16 * 1024) + else { + return false; + }; + let Ok(value) = serde_json::from_slice::(&bytes) else { + return false; + }; + value.get("format") == Some(&serde_json::json!("agenet.runtime-ready.v0.2")) + && value.get("runtime_ready") == Some(&serde_json::json!(true)) +} + fn current_service_phase(paths: &NodePaths) -> Result { match std::fs::symlink_metadata(&paths.journal_file) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => { @@ -269,6 +285,11 @@ fn map_service(error: ServiceError) -> CliError { "The supervisor rejected persisted node state.", false, ), + ServiceError::RuntimeUnavailable => CliError::new( + "RuntimeUnavailable", + "The authenticated node runtime could not become ready.", + true, + ), ServiceError::RenderFailed | ServiceError::CommandUnavailable | ServiceError::CommandFailed diff --git a/src/demo.rs b/src/demo.rs index efeb8f8..55a5e0e 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -11,13 +11,17 @@ use ed25519_dalek::SigningKey; use serde::{Deserialize, Serialize}; use crate::{ + bootstrap::{AuthorityPki, NodeTlsCsr}, node::{NodeProfile, ReadyState}, protocol::{ AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityKind, CredentialChain, - DomainId, NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, + DomainId, NodeCredentialClaims, NodeId, NodeRole, RevocationClaims, RevocationSnapshot, + SignedAuthorityCredential, }, - runtime::{PursuitRequest, PursuitResult, write_signing_key}, + runtime::{PursuitRequest, PursuitResult, RevocationCache, write_signing_key}, + transport::{PeerTlsIdentity, build_peer_client}, }; +use zeroize::Zeroizing; #[derive(Debug)] pub struct DemoOptions { @@ -28,6 +32,8 @@ pub struct DemoOptions { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DemoSummary { + pub environment: String, + pub network_surface: String, pub run_id: String, pub participants: Vec, pub pursuit: PursuitResult, @@ -115,7 +121,7 @@ async fn run_inner(options: DemoOptions) -> Result { ) .map_err(sanitized)?; - let credentials = provision_nodes(&root_dir, &root_key, now)?; + let credentials = provision_nodes(&root_dir, &root_key, now, select_bind_ips())?; let control_token = uuid::Uuid::new_v4().to_string(); let control_token_file = root_dir.join("requester-control.token"); write_secret(&control_token_file, control_token.as_bytes())?; @@ -129,10 +135,15 @@ async fn run_inner(options: DemoOptions) -> Result { None, None, None, + None, )?; children.push(directory); let directory_ready = wait_ready(&credentials[&NodeProfile::Directory].ready_file).await?; - wait_health(&directory_ready.address).await?; + wait_health( + &directory_ready.address, + &credentials[&NodeProfile::Directory], + ) + .await?; participants.push(directory_ready.clone()); for profile in [NodeProfile::Executor, NodeProfile::Verifier] { @@ -141,12 +152,13 @@ async fn run_inner(options: DemoOptions) -> Result { &root_public_key_file, &credentials[&profile], Some(&directory_ready.address), + Some(&credentials[&NodeProfile::Directory].node_id), None, None, )?; children.push(child); let ready = wait_ready(&credentials[&profile].ready_file).await?; - wait_health(&ready.address).await?; + wait_health(&ready.address, &credentials[&profile]).await?; participants.push(ready); } @@ -155,20 +167,27 @@ async fn run_inner(options: DemoOptions) -> Result { &root_public_key_file, &credentials[&NodeProfile::Requester], Some(&directory_ready.address), + Some(&credentials[&NodeProfile::Directory].node_id), Some(&control_token_file), Some(&env), )?; children.push(requester); let requester_ready = wait_ready(&credentials[&NodeProfile::Requester].ready_file).await?; - wait_health(&requester_ready.address).await?; + wait_health( + &requester_ready.address, + &credentials[&NodeProfile::Requester], + ) + .await?; participants.push(requester_ready.clone()); validate_participants(&participants)?; - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(2)) - .timeout(Duration::from_secs(90)) - .build() - .map_err(sanitized)?; + let requester_node = &credentials[&NodeProfile::Requester]; + let client = build_peer_client( + &requester_node.tls_identity()?, + &requester_node.network(), + &requester_node.node_id, + ) + .map_err(sanitized)?; let response = client .post(format!("{}/local/v0/pursuits", requester_ready.address)) .bearer_auth(&control_token) @@ -186,6 +205,8 @@ async fn run_inner(options: DemoOptions) -> Result { } let pursuit: PursuitResult = response.json().await.map_err(sanitized)?; let summary = DemoSummary { + environment: "loopback_harness".to_owned(), + network_surface: simulated_network_surface(&participants), run_id, participants, pursuit, @@ -199,6 +220,22 @@ async fn run_inner(options: DemoOptions) -> Result { Ok(summary) } +fn simulated_network_surface(participants: &[ReadyState]) -> String { + let hosts: HashSet<_> = participants + .iter() + .filter_map(|participant| { + url::Url::parse(&participant.address) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + }) + .collect(); + if hosts.len() == participants.len() { + "simulated_loopback_aliases_mtls".to_owned() + } else { + "loopback_ports_mtls".to_owned() + } +} + #[derive(Debug)] struct ProvisionedNode { profile: NodeProfile, @@ -206,16 +243,50 @@ struct ProvisionedNode { key_file: PathBuf, credential_file: PathBuf, ready_file: PathBuf, + node_id: NodeId, + bind_ip: std::net::Ipv4Addr, + tls_certificate_file: PathBuf, + tls_private_key_file: PathBuf, + authority_ca_file: PathBuf, +} + +impl ProvisionedNode { + fn network(&self) -> crate::bootstrap::network::NetworkBoundary { + crate::bootstrap::network::NetworkBoundary { + kind: crate::bootstrap::network::OverlayKind::Loopback, + bind_ip: self.bind_ip.into(), + allowed_cidrs: vec!["127.0.0.0/8".parse().expect("static CIDR")], + } + } + + fn tls_identity(&self) -> Result { + Ok(PeerTlsIdentity { + node_id: self.node_id.clone(), + certificate_chain_pem: Zeroizing::new( + fs::read_to_string(&self.tls_certificate_file).map_err(sanitized)?, + ), + private_key_pem: Zeroizing::new( + fs::read_to_string(&self.tls_private_key_file).map_err(sanitized)?, + ), + authority_ca_pem: fs::read_to_string(&self.authority_ca_file).map_err(sanitized)?, + }) + } } fn provision_nodes( root_dir: &Path, root_key: &SigningKey, now: u64, + bind_ips: [std::net::Ipv4Addr; 4], ) -> Result, String> { let mut nodes = HashMap::new(); let now_ms = i64::try_from(now).map_err(sanitized)?; let authority_key = random_signing_key()?; + let pki = AuthorityPki::generate( + now_ms.saturating_sub(60_000), + now_ms.saturating_add(1_200_000), + ) + .map_err(sanitized)?; let domain_id = DomainId::new(format!("domain:{}", uuid::Uuid::new_v4())).map_err(sanitized)?; let authority_credential = SignedAuthorityCredential::issue( root_key, @@ -224,10 +295,11 @@ fn provision_nodes( authority_id: NodeId::new(format!("authority:{}", uuid::Uuid::new_v4())) .map_err(sanitized)?, signing_public_key_base64: STANDARD.encode(authority_key.verifying_key().to_bytes()), - tls_ca_sha256: "00".repeat(32), + tls_ca_sha256: pki.fingerprint_sha256.clone(), scopes: BTreeSet::from([ AuthorityScope::IssueNodeCredential, AuthorityScope::IssueFoundingDirectoryCredential, + AuthorityScope::PublishRevocationSnapshot, ]), allowed_profiles: BTreeSet::from([ BootstrapProfile::Base, @@ -240,14 +312,33 @@ fn provision_nodes( }, ) .map_err(sanitized)?; - for profile in [ - NodeProfile::Directory, - NodeProfile::Requester, - NodeProfile::Executor, - NodeProfile::Verifier, + let snapshot = RevocationSnapshot::sign( + authority_credential.clone(), + &authority_key, + RevocationClaims { + format_version: "agenet.revocation-snapshot.v0.2".to_owned(), + domain_id: domain_id.clone(), + issuer_id: authority_credential.claims.authority_id.clone(), + epoch: 1, + generated_at_ms: now_ms.saturating_sub(1_000), + next_update_ms: now_ms.saturating_add(299_000), + revoked_authorities: BTreeSet::new(), + revoked_nodes: BTreeSet::new(), + }, + &root_key.verifying_key(), + now_ms.saturating_sub(1_000), + ) + .map_err(sanitized)?; + for (profile, bind_ip) in [ + (NodeProfile::Directory, bind_ips[0]), + (NodeProfile::Executor, bind_ips[1]), + (NodeProfile::Verifier, bind_ips[2]), + (NodeProfile::Requester, bind_ips[3]), ] { let state_dir = root_dir.join(format!("{profile:?}").to_lowercase()); fs::create_dir_all(&state_dir).map_err(sanitized)?; + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&state_dir, fs::Permissions::from_mode(0o700)).map_err(sanitized)?; let signing_key = random_signing_key()?; let node_id = NodeId::new(format!( "node:{}:{}", @@ -270,7 +361,7 @@ fn provision_nodes( format_version: "agenet.node-credential.v0.3".to_owned(), domain_id: domain_id.clone(), authority_id: authority_credential.claims.authority_id.clone(), - node_id, + node_id: node_id.clone(), signing_public_key_base64: STANDARD.encode(signing_key.verifying_key().to_bytes()), bootstrap_profile, allowed_roles, @@ -312,12 +403,43 @@ fn provision_nodes( let key_file = state_dir.join("identity.key"); let credential_file = state_dir.join("credential.json"); let ready_file = state_dir.join("ready.json"); + let tls = NodeTlsCsr::generate().map_err(sanitized)?; + let certificate = pki + .issue_peer( + &tls.csr_pem, + &credential + .node + .verify(&authority_key.verifying_key(), now_ms) + .map_err(sanitized)? + .node_id, + bind_ip.into(), + now_ms.saturating_sub(1_000), + now_ms.saturating_add(600_000), + ) + .map_err(sanitized)?; write_signing_key(&key_file, &signing_key).map_err(sanitized)?; fs::write( &credential_file, serde_json::to_vec_pretty(&credential).map_err(sanitized)?, ) .map_err(sanitized)?; + let tls_private_key_file = state_dir.join("peer-private-key-v1.pem"); + let tls_certificate_file = state_dir.join("peer-certificate-v1.pem"); + let authority_ca_file = state_dir.join("authority-ca-v1.pem"); + write_secret(&tls_private_key_file, tls.private_key_pem.as_bytes())?; + write_secret( + &tls_certificate_file, + format!("{}{}", certificate.cert_pem, pki.ca_cert_pem.as_str()).as_bytes(), + )?; + write_secret(&authority_ca_file, pki.ca_cert_pem.as_bytes())?; + let cache = RevocationCache::open( + &state_dir, + root_key.verifying_key(), + domain_id.clone(), + authority_credential.clone(), + ) + .map_err(sanitized)?; + cache.accept(snapshot.clone(), now_ms).map_err(sanitized)?; nodes.insert( profile, ProvisionedNode { @@ -326,17 +448,40 @@ fn provision_nodes( key_file, credential_file, ready_file, + node_id, + bind_ip, + tls_certificate_file, + tls_private_key_file, + authority_ca_file, }, ); } Ok(nodes) } +fn select_bind_ips() -> [std::net::Ipv4Addr; 4] { + let aliases = [ + std::net::Ipv4Addr::new(127, 0, 0, 2), + std::net::Ipv4Addr::new(127, 0, 0, 3), + std::net::Ipv4Addr::new(127, 0, 0, 4), + std::net::Ipv4Addr::new(127, 0, 0, 5), + ]; + if aliases + .iter() + .all(|ip| std::net::TcpListener::bind((*ip, 0)).is_ok()) + { + aliases + } else { + [std::net::Ipv4Addr::LOCALHOST; 4] + } +} + fn spawn_node( executable: &Path, root_public_key_file: &Path, node: &ProvisionedNode, directory_seed: Option<&str>, + directory_node_id: Option<&NodeId>, control_token_file: Option<&Path>, llm_env: Option<&HashMap>, ) -> Result { @@ -358,11 +503,24 @@ fn spawn_node( .arg(root_public_key_file) .arg("--ready-file") .arg(&node.ready_file) + .arg("--tls-certificate-file") + .arg(&node.tls_certificate_file) + .arg("--tls-private-key-file") + .arg(&node.tls_private_key_file) + .arg("--authority-ca-file") + .arg(&node.authority_ca_file) + .arg("--bind-ip") + .arg(node.bind_ip.to_string()) .stdout(Stdio::from(stdout)) .stderr(Stdio::from(stderr)); if let Some(directory_seed) = directory_seed { command.arg("--directory-seed").arg(directory_seed); } + if let Some(directory_node_id) = directory_node_id { + command + .arg("--directory-node-id") + .arg(directory_node_id.as_str()); + } if let Some(control_token_file) = control_token_file { command.arg("--control-token-file").arg(control_token_file); } @@ -389,9 +547,10 @@ async fn wait_ready(path: &Path) -> Result { } } -async fn wait_health(endpoint: &str) -> Result<(), String> { +async fn wait_health(endpoint: &str, node: &ProvisionedNode) -> Result<(), String> { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); - let client = reqwest::Client::new(); + let client = build_peer_client(&node.tls_identity()?, &node.network(), &node.node_id) + .map_err(sanitized)?; loop { if let Ok(response) = client.get(format!("{endpoint}/healthz")).send().await && response.status().is_success() diff --git a/src/node.rs b/src/node.rs index a0baf39..a1bd2a1 100644 --- a/src/node.rs +++ b/src/node.rs @@ -3,27 +3,32 @@ use std::{ net::{IpAddr, SocketAddr}, path::PathBuf, sync::Arc, + time::Duration, }; use base64::{Engine, engine::general_purpose::STANDARD}; use clap::ValueEnum; use ed25519_dalek::VerifyingKey; use serde::{Deserialize, Serialize}; -use tokio::net::TcpListener; use crate::{ adapters::LlmDecisionAdapter, bootstrap::network::NetworkBoundary, protocol::{ - CapabilityId, CapabilityManifest, CredentialChain, NodeRole, ProtocolError, - SideEffectProfile, + CapabilityId, CapabilityManifest, CredentialChain, DirectorySeed, NodeId, NodeRole, + ProtocolError, SideEffectProfile, }, runtime::{ ArtifactAccessService, ArtifactStore, ContractRecorder, DirectoryRegistry, NodeIdentity, - ProviderService, RequesterService, RuntimeError, read_signing_key, + ProviderService, RequesterService, RevocationCache, RevocationGuard, RuntimeError, + SystemClock, read_signing_key, serve_peer_tls, + }, + transport::{ + PeerClient, PeerTlsIdentity, build_peer_client, directory_router_with_revocation, + provider_router_with_revocation, requester_router_with_revocation, }, - transport::{PeerClient, directory_router, provider_router, requester_router}, }; +use zeroize::Zeroizing; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] pub enum NodeProfile { @@ -53,7 +58,11 @@ pub struct NodeOptions { pub root_public_key_file: PathBuf, pub ready_file: PathBuf, pub directory_seed: Option, + pub directory_node_id: Option, pub control_token_file: Option, + pub tls_certificate_file: PathBuf, + pub tls_private_key_file: PathBuf, + pub authority_ca_file: PathBuf, pub network: NetworkBoundary, } @@ -71,22 +80,27 @@ pub async fn run(options: NodeOptions) -> Result { ensure_transport_available(&options.network)?; validate_network_boundary(options.network.clone()).await?; fs::create_dir_all(&options.state_dir).map_err(sanitized)?; - let now = unix_ms(); - let identity = load_identity(&options, now)?; + let identity = load_identity(&options)?; if identity.role() != options.profile.role() { return Err("CredentialRoleMismatch".to_owned()); } - let listener = TcpListener::bind(SocketAddr::new(options.network.bind_ip, 0)) - .await + let listener = std::net::TcpListener::bind(SocketAddr::new(options.network.bind_ip, 0)) .map_err(sanitized)?; + listener.set_nonblocking(true).map_err(sanitized)?; let address = listener.local_addr().map_err(sanitized)?; ensure_exact_bind(address, options.network.bind_ip)?; - let scheme = if address.ip().is_loopback() { - "http" - } else { - "https" - }; - let endpoint = format!("{scheme}://{address}"); + let endpoint = format!("https://{address}"); + let tls_identity = load_tls_identity(&options, identity.node_id().clone())?; + let revocations = Arc::new( + RevocationCache::open( + &options.state_dir, + *identity.root(), + identity.domain_id().clone(), + identity.authority_credential().clone(), + ) + .map_err(sanitized)?, + ); + let now = u64::try_from(identity.now_ms()).unwrap_or(u64::MAX); let recorder = Arc::new( ContractRecorder::open( &options.state_dir, @@ -97,18 +111,24 @@ pub async fn run(options: NodeOptions) -> Result { .await .map_err(sanitized)?, ); - let app = match options.profile { - NodeProfile::Directory => directory_router(DirectoryRegistry::new(), identity.clone(), now), + let (app, registration) = match options.profile { + NodeProfile::Directory => ( + directory_router_with_revocation( + DirectoryRegistry::new(), + identity.clone(), + now, + RevocationGuard::new(revocations.as_ref().clone()), + ), + None, + ), NodeProfile::Executor | NodeProfile::Verifier => { - let directory = options - .directory_seed - .as_deref() - .ok_or_else(|| "DirectorySeedRequired".to_owned())?; - let client = PeerClient::new_with_boundary( + let directory = directory_seed(&options)?; + let client = PeerClient::new_mtls_dynamic( *identity.root(), identity.domain_id().clone(), - now, + Arc::new(SystemClock), options.network.clone(), + tls_identity.clone(), ) .map_err(sanitized)?; let service = ProviderService::new( @@ -119,14 +139,16 @@ pub async fn run(options: NodeOptions) -> Result { now, ) .map_err(sanitized)?; - register_capability(directory, &endpoint, &identity, &client, options.profile).await?; - provider_router(service) + ( + provider_router_with_revocation( + service, + RevocationGuard::new(revocations.as_ref().clone()), + ), + Some((directory, client, options.profile)), + ) } NodeProfile::Requester => { - let directory = options - .directory_seed - .clone() - .ok_or_else(|| "DirectorySeedRequired".to_owned())?; + let directory = directory_seed(&options)?; let control_token_file = options .control_token_file .as_ref() @@ -143,11 +165,12 @@ pub async fn run(options: NodeOptions) -> Result { identity.domain_id().clone(), now, ); - let client = PeerClient::new_with_boundary( + let client = PeerClient::new_mtls_dynamic( *identity.root(), identity.domain_id().clone(), - now, + Arc::new(SystemClock), options.network.clone(), + tls_identity.clone(), ) .map_err(sanitized)?; let decision = LlmDecisionAdapter::new_modelhub( @@ -156,7 +179,7 @@ pub async fn run(options: NodeOptions) -> Result { &required_env("VLM_MODEL")?, ) .map_err(sanitized)?; - let service = RequesterService::new( + let service = RequesterService::new_with_directory_seed( identity.clone(), store, access, @@ -166,25 +189,75 @@ pub async fn run(options: NodeOptions) -> Result { endpoint.clone(), ) .map_err(sanitized)?; - requester_router(service, control_token) + ( + requester_router_with_revocation( + service, + control_token, + RevocationGuard::new(revocations.as_ref().clone()), + ), + None, + ) } }; let ready = ReadyState { profile: format!("{:?}", options.profile).to_lowercase(), pid: std::process::id(), - address: endpoint, + address: endpoint.clone(), node_id: identity.node_id().as_str().to_owned(), state_dir: options.state_dir, directory_seed: options.directory_seed, }; - write_ready(&options.ready_file, &ready)?; - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(shutdown_signal()) - .await - .map_err(sanitized)?; + let handle = axum_server::Handle::new(); + let server_handle = handle.clone(); + let boundary = options.network.clone(); + let server_tls = tls_identity.clone(); + let server_revocations = Arc::clone(&revocations); + let mut server = tokio::spawn(async move { + serve_peer_tls( + listener, + app, + &server_tls, + server_revocations, + &boundary, + server_handle, + ) + .await + }); + if let Err(error) = wait_tls_health(&endpoint, &tls_identity, &options.network).await { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server.await; + return Err(error); + } + if let Some((directory, client, profile)) = registration + && let Err(error) = + register_capability(&directory, &endpoint, &identity, &client, profile).await + { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server.await; + return Err(error); + } + if revocations.decision( + identity.now_ms(), + &identity.claims().authority_id, + identity.node_id(), + ) != crate::protocol::RevocationDecision::CurrentAndAllowed + { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server.await; + return Err("RevocationStateStale".to_owned()); + } + if let Err(error) = write_ready(&options.ready_file, &ready) { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server.await; + return Err(error); + } + tokio::select! { + result = &mut server => result.map_err(sanitized)?.map_err(sanitized)?, + () = shutdown_signal() => { + handle.graceful_shutdown(Some(Duration::from_secs(3))); + server.await.map_err(sanitized)?.map_err(sanitized)?; + } + } Ok(ready) } @@ -206,7 +279,7 @@ fn ensure_transport_available(boundary: &NetworkBoundary) -> Result<(), String> Ok(()) } -fn load_identity(options: &NodeOptions, now: u64) -> Result { +fn load_identity(options: &NodeOptions) -> Result { let signing_key = read_signing_key(&options.key_file).map_err(sanitized)?; let credential: CredentialChain = serde_json::from_slice(&fs::read(&options.credential_file).map_err(sanitized)?) @@ -222,11 +295,47 @@ fn load_identity(options: &NodeOptions, now: u64) -> Result Result { + Ok(PeerTlsIdentity { + node_id, + certificate_chain_pem: Zeroizing::new( + fs::read_to_string(&options.tls_certificate_file).map_err(sanitized)?, + ), + private_key_pem: Zeroizing::new( + fs::read_to_string(&options.tls_private_key_file).map_err(sanitized)?, + ), + authority_ca_pem: fs::read_to_string(&options.authority_ca_file).map_err(sanitized)?, + }) +} + +fn directory_seed(options: &NodeOptions) -> Result { + Ok(DirectorySeed { + endpoint: url::Url::parse( + options + .directory_seed + .as_deref() + .ok_or_else(|| "DirectorySeedRequired".to_owned())?, + ) + .map_err(sanitized)?, + node_id: options + .directory_node_id + .clone() + .ok_or_else(|| "DirectoryNodeIdRequired".to_owned())?, + }) } async fn register_capability( - directory: &str, + directory: &DirectorySeed, endpoint: &str, identity: &NodeIdentity, client: &PeerClient, @@ -262,8 +371,9 @@ async fn register_capability( .seal("capability.manifest.v1", &manifest) .map_err(sanitized)?; let _: serde_json::Value = client - .post_signed( - directory, + .post_signed_to_peer( + directory.endpoint.as_str(), + &directory.node_id, "/v0/capabilities/register", &envelope, "capability.registration.v1", @@ -274,6 +384,26 @@ async fn register_capability( Ok(()) } +async fn wait_tls_health( + endpoint: &str, + identity: &PeerTlsIdentity, + boundary: &NetworkBoundary, +) -> Result<(), String> { + let client = build_peer_client(identity, boundary, &identity.node_id).map_err(sanitized)?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if let Ok(response) = client.get(format!("{endpoint}/healthz")).send().await + && response.status().is_success() + { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err("NodeHealthTimeout".to_owned()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + fn write_ready(path: &PathBuf, state: &ReadyState) -> Result<(), String> { let bytes = serde_json::to_vec_pretty(state).map_err(sanitized)?; fs::write(path, bytes).map_err(sanitized) diff --git a/src/protocol/enrollment.rs b/src/protocol/enrollment.rs index 1af7010..6984684 100644 --- a/src/protocol/enrollment.rs +++ b/src/protocol/enrollment.rs @@ -5,9 +5,9 @@ use serde::{Deserialize, Serialize}; use std::net::IpAddr; use uuid::Uuid; -use super::{BootstrapProfile, CredentialChain, DomainId, NodeId, ProtocolError}; +use super::{BootstrapProfile, CredentialChain, DirectorySeed, DomainId, NodeId, ProtocolError}; -const ENROLLMENT_REQUEST_DOMAIN: &[u8] = b"AGENET\0enrollment-request-v0.3\0"; +const ENROLLMENT_REQUEST_DOMAIN: &[u8] = b"AGENET\0enrollment-request-v0.4\0"; pub const MAX_ENROLLMENT_CSR_BYTES: usize = 16 * 1024; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -19,7 +19,7 @@ pub struct EnrollmentBundle { pub tls_client_certificate_pem: String, pub tls_ca_certificate_pem: String, pub authority_endpoint: Url, - pub directory_seeds: Vec, + pub directory_seeds: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -78,7 +78,7 @@ pub(crate) fn verify_enrollment_claims( } fn validate_enrollment_claims(claims: &EnrollmentRequestClaims) -> Result<(), ProtocolError> { - if claims.protocol_version != "agenet.enrollment.v0.3" { + if claims.protocol_version != "agenet.enrollment.v0.4" { return Err(ProtocolError::UnsupportedEnrollmentVersion); } if claims.operation_id.is_nil() || claims.invitation_claims_sha256 == [0_u8; 32] { @@ -117,7 +117,7 @@ mod tests { fn exact_signed_claim_bytes_reject_each_field_mutation() { let key = SigningKey::from_bytes(&[31_u8; 32]); let claims = EnrollmentRequestClaims { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), operation_id: Uuid::from_u128(9), invitation_claims_sha256: [7_u8; 32], node_id: NodeId::new("node-exact-claims").expect("node"), diff --git a/src/protocol/envelope.rs b/src/protocol/envelope.rs index aaca604..c9b88f7 100644 --- a/src/protocol/envelope.rs +++ b/src/protocol/envelope.rs @@ -141,6 +141,10 @@ pub(crate) struct OpenedEnvelope { } impl OpenedEnvelope { + pub(crate) fn payload(&self) -> &T { + &self.payload + } + pub(crate) fn claims(&self) -> &VerifiedNodeClaims { &self.claims } diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 9c5bb16..f3212cc 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -31,9 +31,9 @@ pub use sealed_contract::{ContractOffer, SealedContract}; pub use types::{ AcceptanceProfile, ArtifactId, ArtifactPayload, ArtifactReadRequest, ArtifactRef, CandidateSet, CapabilityId, CapabilityKind, CapabilityManifest, ContractDraft, ContractEvent, ContractId, - ContractProposeRequest, ContractProposeResponse, ContractQuery, ContractState, DomainId, - ErrorEnvelope, EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, NodeRole, - RouteQuery, SideEffectProfile, SourceMetrics, + ContractProposeRequest, ContractProposeResponse, ContractQuery, ContractState, DirectorySeed, + DomainId, ErrorEnvelope, EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, + NodeRole, RouteQuery, SideEffectProfile, SourceMetrics, }; pub const KERNEL_VERSION: &str = crate::KERNEL_VERSION_V2; diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 9821c81..3159a05 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -31,6 +31,13 @@ identifier!(ArtifactId); identifier!(IntentId); identifier!(ContractId); +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DirectorySeed { + pub endpoint: url::Url, + pub node_id: NodeId, +} + /// Versioned capability name used at authorization boundaries. /// /// Existing v0.1 manifests intentionally retain their string wire fields. This diff --git a/src/runtime/artifact_access.rs b/src/runtime/artifact_access.rs index f858f1d..dab3523 100644 --- a/src/runtime/artifact_access.rs +++ b/src/runtime/artifact_access.rs @@ -14,7 +14,7 @@ pub struct ArtifactAccessService { store: ArtifactStore, root: VerifyingKey, domain_id: DomainId, - validation_time_unix_ms: u64, + clock: Arc, contracts: Arc>>, } @@ -29,13 +29,17 @@ impl ArtifactAccessService { store, root, domain_id, - validation_time_unix_ms, + clock: Arc::new(super::FixedClock::new( + i64::try_from(validation_time_unix_ms).unwrap_or(i64::MAX), + )), contracts: Arc::new(RwLock::new(HashMap::new())), } } pub async fn authorize(&self, contract: SealedContract) -> Result<(), RuntimeError> { - let draft = contract.verify(&self.root, &self.domain_id, self.validation_time_unix_ms)?; + let now = u64::try_from(self.clock.now_ms()) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; + let draft = contract.verify(&self.root, &self.domain_id, now)?; self.contracts .write() .await @@ -52,13 +56,12 @@ impl ArtifactAccessService { let contract = contracts .get(&request.contract_id) .ok_or(RuntimeError::ArtifactAccessDenied)?; - let draft = contract.verify(&self.root, &self.domain_id, self.validation_time_unix_ms)?; - draft.grant.allows( - caller, - &draft.capability_id, - &request.artifact_id, - self.validation_time_unix_ms, - )?; + let now = u64::try_from(self.clock.now_ms()) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; + let draft = contract.verify(&self.root, &self.domain_id, now)?; + draft + .grant + .allows(caller, &draft.capability_id, &request.artifact_id, now)?; if request.artifact_id != draft.artifact.artifact_id { return Err(RuntimeError::ArtifactAccessDenied); } diff --git a/src/runtime/clock.rs b/src/runtime/clock.rs new file mode 100644 index 0000000..95b5d03 --- /dev/null +++ b/src/runtime/clock.rs @@ -0,0 +1,34 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Request-time clock used by credential and revocation policy checks. +pub trait Clock: Send + Sync + std::fmt::Debug { + fn now_ms(&self) -> i64; +} + +#[derive(Debug, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + fn now_ms(&self) -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|value| i64::try_from(value.as_millis()).ok()) + .unwrap_or(i64::MAX) + } +} + +#[derive(Debug)] +pub struct FixedClock(i64); + +impl FixedClock { + pub fn new(now_ms: i64) -> Self { + Self(now_ms) + } +} + +impl Clock for FixedClock { + fn now_ms(&self) -> i64 { + self.0 + } +} diff --git a/src/runtime/directory.rs b/src/runtime/directory.rs index a6f6437..0b84361 100644 --- a/src/runtime/directory.rs +++ b/src/runtime/directory.rs @@ -34,6 +34,14 @@ impl DirectoryRegistry { validate_manifest(&claims.node_id, &manifest, now_unix_ms)?; let kind = CapabilityKind::new(manifest.capability_kind_version()) .map_err(|_| RuntimeError::CapabilityNotAuthorized)?; + let required_role = match manifest.kind.as_str() { + "source.metrics" => crate::protocol::NodeRole::Executor, + "source.metrics.verify" => crate::protocol::NodeRole::Verifier, + _ => return Err(RuntimeError::CapabilityNotAuthorized), + }; + if !claims.allowed_roles.contains(&required_role) { + return Err(RuntimeError::CapabilityNotAuthorized); + } if !claims.capability_ceiling.contains(&kind) { return Err(RuntimeError::CapabilityNotAuthorized); } diff --git a/src/runtime/error.rs b/src/runtime/error.rs index 48f514a..c5257b3 100644 --- a/src/runtime/error.rs +++ b/src/runtime/error.rs @@ -39,6 +39,9 @@ pub enum RuntimeError { RevocationPersistenceUnavailable, CorruptRevocationState, UnsafeRevocationState, + InvalidStartupBundle, + FoundingAuthorityMaterialInvalid, + LocalControlUnavailable, Protocol(ProtocolError), } diff --git a/src/runtime/host.rs b/src/runtime/host.rs new file mode 100644 index 0000000..7318e3c --- /dev/null +++ b/src/runtime/host.rs @@ -0,0 +1,682 @@ +use std::{ + collections::BTreeSet, + net::{SocketAddr, TcpListener}, + sync::Arc, + time::Duration, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::VerifyingKey; +use url::Url; + +use crate::{ + bootstrap::{ + AuthorityPki, BootstrapError, EnrollmentAuthority, InvitationStore, NodePaths, + PersistedStartupBundle, load_startup_bundle, + }, + protocol::{ + CapabilityId, CapabilityKind, CapabilityManifest, CredentialChain, NodeRole, + RevocationDecision, SideEffectProfile, + }, + transport::{ + PeerClient, RevocationClient, base_router_with_revocation, + directory_router_with_revocation, provider_router_with_revocation, + }, +}; + +use super::{ + AuthorityRevocationStore, Clock, ContractRecorder, DirectoryRegistry, NodeIdentity, + ProviderService, RevocationCache, RevocationGuard, RuntimeError, SystemClock, serve_peer_tls, +}; + +pub struct FoundingAuthorityRuntime { + pub enrollment: Arc, + pub revocations: Arc, + pub server_identity: crate::bootstrap::IssuedServerIdentity, + pub ca_certificate_pem: String, +} + +impl FoundingAuthorityRuntime { + pub fn load( + paths: &NodePaths, + bundle: &PersistedStartupBundle, + root: VerifyingKey, + now: i64, + ) -> Result { + let credential: crate::protocol::SignedAuthorityCredential = serde_json::from_slice( + &paths + .read_material(&paths.authority_credential_file, 64 * 1024) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?, + ) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + crate::protocol::verify_authority_credential(&root, &credential, now) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + if credential.claims.domain_id != bundle.config.domain_id + || credential.claims.authority_id != bundle.credential.authority.claims.authority_id + || credential != bundle.credential.authority + { + return Err(RuntimeError::FoundingAuthorityMaterialInvalid); + } + let signing = super::read_signing_key(&paths.authority_signing_key_file) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + if STANDARD.encode(signing.verifying_key().to_bytes()) + != credential.claims.signing_public_key_base64 + { + return Err(RuntimeError::FoundingAuthorityMaterialInvalid); + } + let ca = std::str::from_utf8( + &paths + .read_material(&paths.authority_ca_certificate_file, 64 * 1024) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?, + ) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)? + .to_owned(); + let ca_key = std::str::from_utf8( + &paths + .read_material(&paths.authority_ca_private_key_file, 32 * 1024) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?, + ) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)? + .to_owned(); + let pki = AuthorityPki::load(&ca, &ca_key) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + if pki.fingerprint_sha256 != credential.claims.tls_ca_sha256 { + return Err(RuntimeError::FoundingAuthorityMaterialInvalid); + } + bundle + .config + .network + .validate_peer_endpoint(bundle.config.authority_endpoint.as_str()) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + bundle + .config + .network + .validate_peer_endpoint(bundle.config.revocation_endpoint.as_str()) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + let invitations = Arc::new( + InvitationStore::open(&paths.invitation_state_dir) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?, + ); + let server_identity = pki + .issue_server( + bundle.config.network.bind_ip, + now.saturating_sub(1_000), + now.saturating_add(300_000), + ) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + let ca_certificate_pem = pki.ca_cert_pem.to_string(); + let enrollment = Arc::new( + EnrollmentAuthority::open( + &paths.enrollment_result_dir, + invitations, + root, + credential.clone(), + signing.clone(), + pki, + bundle.config.authority_endpoint.clone(), + ) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?, + ); + let revocations = Arc::new( + AuthorityRevocationStore::open(&paths.state_dir, root, credential, signing) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?, + ); + Ok(Self { + enrollment, + revocations, + server_identity, + ca_certificate_pem, + }) + } +} + +pub struct HostRuntime { + bundle: PersistedStartupBundle, + root: VerifyingKey, + roles: BTreeSet, + clock: Arc, + revocations: Arc, + founding: Option, +} + +impl HostRuntime { + pub async fn load(paths: &NodePaths) -> Result { + let root = read_root(paths)?; + let credential = read_credential(paths)?; + let clock: Arc = Arc::new(SystemClock); + let now = clock.now_ms(); + let role = first_signed_role(&root, &credential, now)?; + let bundle = load_startup_bundle(paths, &root, role, now) + .map_err(|_| RuntimeError::InvalidStartupBundle)?; + let verified = crate::protocol::verify_credential_chain( + &root, + &bundle.credential, + &bundle.config.domain_id, + role, + now, + )?; + let revocations = Arc::new(RevocationCache::open( + &paths.state_dir, + root, + bundle.config.domain_id.clone(), + bundle.credential.authority.clone(), + )?); + refresh_if_needed(&bundle, revocations.as_ref(), now).await?; + if revocations.decision(now, &verified.authority_id, &verified.node_id) + != RevocationDecision::CurrentAndAllowed + { + return Err(RuntimeError::RevocationStateStale); + } + let founding = if verified.allowed_roles.contains(&NodeRole::Directory) { + Some(FoundingAuthorityRuntime::load(paths, &bundle, root, now)?) + } else { + None + }; + Ok(Self { + bundle, + root, + roles: verified.allowed_roles, + clock, + revocations, + founding, + }) + } + + pub async fn run_until(self, paths: &NodePaths, shutdown: F) -> Result<(), RuntimeError> + where + F: std::future::Future, + { + let mut authority_servers = match self.founding.as_ref() { + Some(founding) => Some(AuthorityServers::start(founding, &self.bundle).await?), + None => None, + }; + remove_ready(paths)?; + let address = SocketAddr::new( + self.bundle.config.network.bind_ip, + self.bundle.config.peer_port, + ); + let listener = TcpListener::bind(address)?; + listener.set_nonblocking(true)?; + let actual = listener.local_addr()?; + if actual.ip() != self.bundle.config.network.bind_ip + || (self.bundle.config.peer_port != 0 && actual.port() != self.bundle.config.peer_port) + { + return Err(RuntimeError::UnsupportedNonLoopbackTransport); + } + let endpoint = Url::parse(&format!("https://{actual}/")) + .map_err(|_| RuntimeError::UnsupportedNonLoopbackTransport)?; + let handle = axum_server::Handle::new(); + let (app, registration) = self.router(paths).await?; + let tls_identity = self.bundle.tls_identity.clone(); + let revocations = Arc::clone(&self.revocations); + let boundary = self.bundle.config.network.clone(); + let server_handle = handle.clone(); + let server = tokio::spawn(async move { + serve_peer_tls( + listener, + app, + &tls_identity, + revocations, + &boundary, + server_handle, + ) + .await + }); + if let Err(error) = self.probe_ready(&endpoint).await { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server.await; + return Err(error); + } + if self.revocations.decision( + self.clock.now_ms(), + &self.bundle.credential.authority.claims.authority_id, + &self.bundle.tls_identity.node_id, + ) != RevocationDecision::CurrentAndAllowed + { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server.await; + return Err(RuntimeError::RevocationStateStale); + } + if let Some((identity, client, roles)) = registration + && let Err(error) = self + .register_manifests(&identity, &client, &endpoint, &roles) + .await + { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server.await; + remove_ready(paths)?; + return Err(error); + } + if let Err(error) = write_ready(paths, &endpoint, &self.bundle.tls_identity.node_id) { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server.await; + remove_ready(paths)?; + return Err(error); + } + let mut server = server; + let result = tokio::select! { + result = &mut server => result.map_err(|_| RuntimeError::TransportFailed)?.map_err(|_| RuntimeError::TransportFailed), + () = shutdown => { + handle.graceful_shutdown(Some(Duration::from_secs(3))); + server.await.map_err(|_| RuntimeError::TransportFailed)?.map_err(|_| RuntimeError::TransportFailed) + } + }; + remove_ready(paths)?; + if let Some(servers) = authority_servers.take() { + servers.shutdown().await; + } + result + } + + async fn router( + &self, + paths: &NodePaths, + ) -> Result< + ( + axum::Router, + Option<(NodeIdentity, PeerClient, BTreeSet)>, + ), + RuntimeError, + > { + if self.roles.contains(&NodeRole::Directory) { + let identity = self.identity(NodeRole::Directory)?; + return Ok(( + directory_router_with_revocation( + DirectoryRegistry::new(), + identity, + u64::try_from(self.clock.now_ms()).unwrap_or(u64::MAX), + RevocationGuard::new(self.revocations.as_ref().clone()), + ), + None, + )); + } + let provider_roles: BTreeSet<_> = self + .roles + .iter() + .copied() + .filter(|role| matches!(role, NodeRole::Executor | NodeRole::Verifier)) + .collect(); + if provider_roles.is_empty() { + if !self.roles.contains(&NodeRole::Requester) { + return Err(RuntimeError::CredentialRoleMismatch); + } + let identity = self.identity(NodeRole::Requester)?; + return Ok(( + base_router_with_revocation( + identity, + RevocationGuard::new(self.revocations.as_ref().clone()), + ), + None, + )); + } + let identity = self.identity(*provider_roles.iter().next().expect("non-empty"))?; + let client = PeerClient::new_mtls_dynamic( + self.root, + self.bundle.config.domain_id.clone(), + Arc::clone(&self.clock), + self.bundle.config.network.clone(), + self.bundle.tls_identity.clone(), + )?; + let recorder = Arc::new( + ContractRecorder::open( + &paths.state_dir, + self.root, + self.bundle.config.domain_id.clone(), + u64::try_from(self.clock.now_ms()).unwrap_or(u64::MAX), + ) + .await?, + ); + let service = ProviderService::new_for_roles( + identity.clone(), + recorder, + client.clone(), + provider_roles.clone(), + u64::try_from(self.clock.now_ms()).unwrap_or(u64::MAX), + )?; + Ok(( + provider_router_with_revocation( + service, + RevocationGuard::new(self.revocations.as_ref().clone()), + ), + Some((identity, client, provider_roles)), + )) + } + + async fn probe_ready(&self, endpoint: &Url) -> Result<(), RuntimeError> { + let client = crate::transport::build_peer_client( + &self.bundle.tls_identity, + &self.bundle.config.network, + &self.bundle.tls_identity.node_id, + )?; + let url = endpoint + .join("healthz") + .map_err(|_| RuntimeError::TransportFailed)?; + for _ in 0..40 { + if let Ok(response) = client.get(url.clone()).send().await + && response.status().is_success() + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err(RuntimeError::TransportFailed) + } + + fn identity(&self, role: NodeRole) -> Result { + NodeIdentity::new_with_clock( + self.bundle.signing_key.clone(), + self.bundle.credential.clone(), + role, + self.root, + Arc::clone(&self.clock), + ) + } + + async fn register_manifests( + &self, + identity: &NodeIdentity, + client: &PeerClient, + endpoint: &Url, + roles: &BTreeSet, + ) -> Result<(), RuntimeError> { + let seed = self + .bundle + .config + .directory_seeds + .first() + .ok_or(RuntimeError::CapabilityUnavailable)?; + for role in roles { + let manifest = manifest(identity, endpoint, *role, self.clock.now_ms())?; + let envelope = identity.seal("capability.manifest.v1", &manifest)?; + let _: serde_json::Value = client + .post_signed_to_peer( + seed.endpoint.as_str(), + &seed.node_id, + "/v0/capabilities/register", + &envelope, + "capability.registration.v1", + NodeRole::Directory, + ) + .await + .map_err(|_| RuntimeError::TransportFailed)?; + } + Ok(()) + } +} + +struct AuthorityServers { + handles: Vec>, + tasks: Vec>, +} + +impl AuthorityServers { + async fn start( + founding: &FoundingAuthorityRuntime, + bundle: &PersistedStartupBundle, + ) -> Result { + let tls = crate::transport::enrollment_tls_config( + &founding.server_identity, + &founding.ca_certificate_pem, + ) + .await + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + let endpoints = [ + ( + bundle.config.authority_endpoint.clone(), + crate::transport::enrollment_router(Arc::clone(&founding.enrollment)), + ), + ( + bundle.config.revocation_endpoint.clone(), + crate::transport::authority_revocation_router(Arc::clone(&founding.revocations)), + ), + ]; + let mut servers = Self { + handles: vec![], + tasks: vec![], + }; + for (endpoint, app) in endpoints { + let ip: std::net::IpAddr = endpoint + .host_str() + .ok_or(RuntimeError::FoundingAuthorityMaterialInvalid)? + .parse() + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + if ip != bundle.config.network.bind_ip { + return Err(RuntimeError::FoundingAuthorityMaterialInvalid); + } + let port = endpoint + .port() + .ok_or(RuntimeError::FoundingAuthorityMaterialInvalid)?; + let listener = TcpListener::bind(SocketAddr::new(ip, port))?; + listener.set_nonblocking(true)?; + let handle = axum_server::Handle::new(); + let task_handle = handle.clone(); + let tls = tls.clone(); + servers.handles.push(handle); + servers.tasks.push(tokio::spawn(async move { + if let Ok(server) = axum_server::from_tcp_rustls(listener, tls) { + let _ = server + .handle(task_handle) + .serve(app.into_make_service()) + .await; + } + })); + } + let authority = reqwest::Certificate::from_pem(founding.ca_certificate_pem.as_bytes()) + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .https_only(true) + .tls_certs_only([authority]) + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(5)) + .build() + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + for endpoint in [ + &bundle.config.authority_endpoint, + &bundle.config.revocation_endpoint, + ] { + let health = endpoint + .join("healthz") + .map_err(|_| RuntimeError::FoundingAuthorityMaterialInvalid)?; + let mut healthy = false; + for _ in 0..40 { + if client + .get(health.clone()) + .send() + .await + .is_ok_and(|response| response.status().is_success()) + { + healthy = true; + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + if !healthy { + return Err(RuntimeError::FoundingAuthorityMaterialInvalid); + } + } + let publisher = Arc::clone(&founding.revocations); + servers.tasks.push(tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + let Ok(Some(latest)) = publisher.latest() else { + continue; + }; + let now = SystemClock.now_ms(); + if latest.claims.next_update_ms.saturating_sub(now) > 120_000 { + continue; + } + let _ = publisher.publish( + now, + latest.claims.revoked_authorities, + latest.claims.revoked_nodes, + ); + } + })); + Ok(servers) + } + + async fn shutdown(mut self) { + for handle in &self.handles { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + } + for task in self.tasks.drain(..) { + task.abort(); + let _ = task.await; + } + } +} + +impl Drop for AuthorityServers { + fn drop(&mut self) { + for task in &self.tasks { + task.abort(); + } + } +} + +fn manifest( + identity: &NodeIdentity, + endpoint: &Url, + role: NodeRole, + now: i64, +) -> Result { + let (id, kind, description) = match role { + NodeRole::Executor => ( + "capability:source-metrics-executor", + "source.metrics", + "Compute source metrics from an authorized Artifact", + ), + NodeRole::Verifier => ( + "capability:source-metrics-verifier", + "source.metrics.verify", + "Independently recompute and verify source metrics", + ), + _ => return Err(RuntimeError::CredentialRoleMismatch), + }; + let authorized = CapabilityKind::new(format!("{kind}.v1"))?; + if !identity.claims().capability_ceiling.contains(&authorized) { + return Err(RuntimeError::CapabilityNotAuthorized); + } + Ok(CapabilityManifest { + capability_id: CapabilityId::new(id)?, + provider: identity.node_id().clone(), + kind: kind.to_owned(), + version: "v1".to_owned(), + description: description.to_owned(), + input_profile: "artifact.source.utf8.v1".to_owned(), + output_profile: "source.metrics.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: endpoint.as_str().trim_end_matches('/').to_owned(), + evidence_types: vec!["source.metrics.evidence.v1".to_owned()], + expires_at_unix_ms: u64::try_from(now.saturating_add(300_000)).unwrap_or(u64::MAX), + }) +} + +async fn refresh_if_needed( + bundle: &PersistedStartupBundle, + cache: &RevocationCache, + now: i64, +) -> Result<(), RuntimeError> { + let claims = &bundle.credential.authority.claims; + if cache.decision(now, &claims.authority_id, &bundle.tls_identity.node_id) + == RevocationDecision::CurrentAndAllowed + { + return Ok(()); + } + let client = RevocationClient::new_with_authority_ca( + &bundle.config.network, + bundle.config.revocation_endpoint.clone(), + &bundle.tls_identity.authority_ca_pem, + Duration::from_secs(2), + Duration::from_secs(5), + ) + .map_err(|_| RuntimeError::RevocationStateStale)?; + client + .refresh(cache, now) + .await + .map_err(|_| RuntimeError::RevocationStateStale)?; + Ok(()) +} + +fn first_signed_role( + root: &VerifyingKey, + credential: &CredentialChain, + now: i64, +) -> Result { + for role in [ + NodeRole::Directory, + NodeRole::Requester, + NodeRole::Executor, + NodeRole::Verifier, + ] { + if crate::protocol::verify_credential_chain( + root, + credential, + &credential.authority.claims.domain_id, + role, + now, + ) + .is_ok() + { + return Ok(role); + } + } + Err(RuntimeError::CredentialRoleMismatch) +} + +fn read_root(paths: &NodePaths) -> Result { + let encoded = paths + .read_material(&paths.root_public_key_file, 256) + .map_err(|_| RuntimeError::InvalidStartupBundle)?; + let bytes = STANDARD + .decode( + std::str::from_utf8(&encoded) + .map_err(|_| RuntimeError::InvalidStartupBundle)? + .trim(), + ) + .map_err(|_| RuntimeError::InvalidStartupBundle)?; + let raw: [u8; 32] = bytes + .try_into() + .map_err(|_| RuntimeError::InvalidStartupBundle)?; + VerifyingKey::from_bytes(&raw).map_err(|_| RuntimeError::InvalidStartupBundle) +} + +fn read_credential(paths: &NodePaths) -> Result { + let bytes = paths + .read_material(&paths.credential_file, 64 * 1024) + .map_err(|_| RuntimeError::InvalidStartupBundle)?; + serde_json::from_slice(&bytes).map_err(|_| RuntimeError::InvalidStartupBundle) +} + +fn write_ready( + paths: &NodePaths, + endpoint: &Url, + node_id: &crate::protocol::NodeId, +) -> Result<(), RuntimeError> { + let bytes = serde_json::to_vec(&serde_json::json!({ + "format": "agenet.runtime-ready.v0.2", + "node_id": node_id, + "endpoint": endpoint, + "runtime_ready": true, + }))?; + super::key_store::atomic_write_owner_only_strict(&paths.service_metadata_file, &bytes, true) + .map_err(|_| RuntimeError::Io) +} + +fn remove_ready(paths: &NodePaths) -> Result<(), RuntimeError> { + super::key_store::remove_owner_only_user_service_file(&paths.service_metadata_file) +} + +impl From for RuntimeError { + fn from(_: crate::transport::TransportError) -> Self { + Self::TransportFailed + } +} + +impl From for RuntimeError { + fn from(_: BootstrapError) -> Self { + Self::InvalidStartupBundle + } +} diff --git a/src/runtime/identity.rs b/src/runtime/identity.rs index 23a2767..5c3f608 100644 --- a/src/runtime/identity.rs +++ b/src/runtime/identity.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use ed25519_dalek::{SigningKey, VerifyingKey}; use serde::Serialize; @@ -6,7 +8,7 @@ use crate::protocol::{ SealedContract, VerifiedNodeClaims, WireEnvelope, verify_credential_chain, }; -use super::RuntimeError; +use super::{Clock, FixedClock, RuntimeError}; #[derive(Clone)] pub struct NodeIdentity { @@ -15,7 +17,7 @@ pub struct NodeIdentity { claims: VerifiedNodeClaims, role: NodeRole, root: VerifyingKey, - validation_time_unix_ms: u64, + clock: Arc, } impl NodeIdentity { @@ -27,6 +29,23 @@ impl NodeIdentity { now_unix_ms: u64, ) -> Result { let now_ms = i64::try_from(now_unix_ms).map_err(|_| ProtocolError::CredentialExpired)?; + Self::new_with_clock( + signing_key, + credential_chain, + role, + root, + Arc::new(FixedClock::new(now_ms)), + ) + } + + pub fn new_with_clock( + signing_key: SigningKey, + credential_chain: CredentialChain, + role: NodeRole, + root: VerifyingKey, + clock: Arc, + ) -> Result { + let now_ms = clock.now_ms(); let domain_id = credential_chain.authority.claims.domain_id.clone(); let claims = verify_credential_chain(&root, &credential_chain, &domain_id, role, now_ms)?; if claims.signing_public_key != signing_key.verifying_key() { @@ -38,7 +57,7 @@ impl NodeIdentity { claims, role, root, - validation_time_unix_ms: now_unix_ms, + clock, }) } @@ -54,6 +73,10 @@ impl NodeIdentity { &self.claims } + pub fn authority_credential(&self) -> &crate::protocol::SignedAuthorityCredential { + &self.credential_chain.authority + } + pub fn role(&self) -> NodeRole { self.role } @@ -62,8 +85,19 @@ impl NodeIdentity { &self.root } - pub fn validation_time_unix_ms(&self) -> u64 { - self.validation_time_unix_ms + pub fn now_ms(&self) -> i64 { + self.clock.now_ms() + } + + pub fn revalidate(&self, required_role: NodeRole) -> Result { + verify_credential_chain( + &self.root, + &self.credential_chain, + &self.claims.domain_id, + required_role, + self.clock.now_ms(), + ) + .map_err(RuntimeError::from) } pub fn seal( diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 6f07593..5dbb323 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -1,7 +1,9 @@ mod artifact; mod artifact_access; +mod clock; mod directory; mod error; +mod host; mod identity; pub(crate) mod key_store; mod node; @@ -12,8 +14,10 @@ mod revocation; pub use artifact::ArtifactStore; pub use artifact_access::ArtifactAccessService; +pub use clock::{Clock, FixedClock, SystemClock}; pub use directory::DirectoryRegistry; pub use error::RuntimeError; +pub use host::HostRuntime; pub use identity::NodeIdentity; pub use key_store::{read_signing_key, write_signing_key}; pub use node::serve_peer_tls; diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs index 478d57e..bdf9fb1 100644 --- a/src/runtime/provider.rs +++ b/src/runtime/provider.rs @@ -1,13 +1,13 @@ -use std::{sync::Arc, time::Duration}; +use std::{collections::BTreeSet, sync::Arc, time::Duration}; use base64::{Engine, engine::general_purpose::STANDARD}; use crate::{ adapters::{executor_metrics, verifier_metrics}, protocol::{ - ArtifactPayload, ArtifactReadRequest, ContractDraft, ContractEvent, ContractId, - ContractProjection, ContractProposeRequest, ContractProposeResponse, ContractQuery, - ContractState, EventKind, EvidenceClaim, NodeRole, WireEnvelope, event_hash, + ArtifactPayload, ArtifactReadRequest, CapabilityKind, ContractDraft, ContractEvent, + ContractId, ContractProjection, ContractProposeRequest, ContractProposeResponse, + ContractQuery, ContractState, EventKind, EvidenceClaim, NodeRole, WireEnvelope, event_hash, }, transport::{PeerClient, TransportError}, }; @@ -19,8 +19,7 @@ pub struct ProviderService { identity: NodeIdentity, recorder: Arc, client: PeerClient, - role: NodeRole, - now_unix_ms: u64, + roles: BTreeSet, } impl ProviderService { @@ -29,17 +28,40 @@ impl ProviderService { recorder: Arc, client: PeerClient, role: NodeRole, - now_unix_ms: u64, + _now_unix_ms: u64, ) -> Result { if !matches!(role, NodeRole::Executor | NodeRole::Verifier) || identity.role() != role { return Err(RuntimeError::CredentialRoleMismatch); } + Self::new_for_roles( + identity, + recorder, + client, + BTreeSet::from([role]), + _now_unix_ms, + ) + } + + pub fn new_for_roles( + identity: NodeIdentity, + recorder: Arc, + client: PeerClient, + roles: BTreeSet, + _now_unix_ms: u64, + ) -> Result { + if roles.is_empty() + || !roles + .iter() + .all(|role| matches!(role, NodeRole::Executor | NodeRole::Verifier)) + || !roles.is_subset(&identity.claims().allowed_roles) + { + return Err(RuntimeError::CredentialRoleMismatch); + } Ok(Self { identity, recorder, client, - role, - now_unix_ms, + roles, }) } @@ -47,10 +69,6 @@ impl ProviderService { &self.identity } - pub fn validation_time_unix_ms(&self) -> u64 { - self.now_unix_ms - } - pub async fn propose( &self, issuer: &crate::protocol::NodeId, @@ -59,11 +77,14 @@ impl ProviderService { let draft = request.offer.verify( self.identity.root(), self.identity.domain_id(), - self.now_unix_ms, + u64::try_from(self.identity.now_ms()) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?, )?; if issuer != &draft.requester || &draft.provider != self.identity.node_id() { return Err(RuntimeError::ArtifactAccessDenied); } + let authorized_role = self.authorized_contract_role(&draft)?; + self.identity.revalidate(authorized_role)?; let sealed = self.identity.countersign_contract(request.offer.clone())?; self.recorder.register_contract(sealed.clone()).await?; let service = self.clone(); @@ -79,6 +100,14 @@ impl ProviderService { self.recorder.append_event(envelope).await } + pub async fn append_verified( + &self, + envelope: WireEnvelope, + event: ContractEvent, + ) -> Result { + self.recorder.append_verified_event(envelope, event).await + } + pub async fn query(&self, query: &ContractQuery) -> Result { self.recorder.projection(&query.contract_id).await } @@ -127,8 +156,9 @@ impl ProviderService { let read_envelope = self.identity.seal("artifact.read.v1", &read_request)?; let artifact: ArtifactPayload = self .client - .post_signed_read( + .post_signed_peer_or_loopback( &request.artifact_endpoint, + &draft.requester, "/v0/artifacts/read", &read_envelope, "artifact.payload.v1", @@ -142,7 +172,8 @@ impl ProviderService { if artifact.artifact != draft.artifact { return Err(RuntimeError::ArtifactIntegrityMismatch); } - let metrics = match self.role { + let role = self.authorized_contract_role(draft)?; + let metrics = match role { NodeRole::Executor => executor_metrics::compute(&bytes)?, NodeRole::Verifier => { let expected = request @@ -167,6 +198,27 @@ impl ProviderService { }) } + fn authorized_contract_role(&self, draft: &ContractDraft) -> Result { + let (role, kind) = match draft.capability_id.as_str() { + "capability:source-metrics-executor" => ( + NodeRole::Executor, + CapabilityKind::new("source.metrics.v1")?, + ), + "capability:source-metrics-verifier" => ( + NodeRole::Verifier, + CapabilityKind::new("source.metrics.verify.v1")?, + ), + _ => return Err(RuntimeError::CapabilityNotAuthorized), + }; + if !self.roles.contains(&role) + || !self.identity.claims().allowed_roles.contains(&role) + || !self.identity.claims().capability_ceiling.contains(&kind) + { + return Err(RuntimeError::CapabilityNotAuthorized); + } + Ok(role) + } + async fn fail( &self, contract_id: &ContractId, diff --git a/src/runtime/recorder.rs b/src/runtime/recorder.rs index 503edb9..ac88142 100644 --- a/src/runtime/recorder.rs +++ b/src/runtime/recorder.rs @@ -3,6 +3,7 @@ use std::{ fs::{self, File, OpenOptions}, io::Write, path::Path, + sync::Arc, }; use ed25519_dalek::VerifyingKey; @@ -14,7 +15,7 @@ use crate::protocol::{ WireEnvelope, apply_event, }; -use super::RuntimeError; +use super::{Clock, FixedClock, RuntimeError}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "entry_type", rename_all = "snake_case")] @@ -31,7 +32,7 @@ struct RecorderState { pub struct ContractRecorder { root: VerifyingKey, domain_id: DomainId, - validation_time_unix_ms: u64, + clock: Arc, state: Mutex, } @@ -49,6 +50,9 @@ impl ContractRecorder { } else { String::new() }; + let now = i64::try_from(validation_time_unix_ms) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; + let clock: Arc = Arc::new(FixedClock::new(now)); let mut projections = HashMap::new(); for line in contents.lines() { let entry: JournalEntry = serde_json::from_str(line)?; @@ -68,7 +72,7 @@ impl ContractRecorder { Ok(Self { root, domain_id, - validation_time_unix_ms, + clock, state: Mutex::new(RecorderState { journal, projections, @@ -80,7 +84,9 @@ impl ContractRecorder { &self, contract: SealedContract, ) -> Result { - let draft = contract.verify(&self.root, &self.domain_id, self.validation_time_unix_ms)?; + let now = u64::try_from(self.clock.now_ms()) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; + let draft = contract.verify(&self.root, &self.domain_id, now)?; let mut state = self.state.lock().await; if let Some(existing) = state.projections.get(&draft.contract_id) { if existing.draft == draft { @@ -104,8 +110,7 @@ impl ContractRecorder { &self.root, &self.domain_id, crate::protocol::NodeRole::Requester, - i64::try_from(self.validation_time_unix_ms) - .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?, + self.clock.now_ms(), )?; let mut state = self.state.lock().await; let projection = state @@ -128,6 +133,32 @@ impl ContractRecorder { Ok(next_state) } + pub async fn append_verified_event( + &self, + envelope: WireEnvelope, + event: ContractEvent, + ) -> Result { + let mut state = self.state.lock().await; + let projection = state + .projections + .get(&event.contract_id) + .ok_or(RuntimeError::UnknownContract)?; + if projection + .events + .iter() + .any(|existing| existing.operation_id == event.operation_id) + { + return Ok(projection.state); + } + let mut next_projection = projection.clone(); + let next_state = apply_event(&mut next_projection, &event)?; + append_entry(&mut state.journal, &JournalEntry::Event { envelope })?; + state + .projections + .insert(event.contract_id.clone(), next_projection); + Ok(next_state) + } + pub async fn projection( &self, contract_id: &ContractId, diff --git a/src/runtime/requester.rs b/src/runtime/requester.rs index ef49178..cdef66f 100644 --- a/src/runtime/requester.rs +++ b/src/runtime/requester.rs @@ -13,8 +13,8 @@ use crate::{ protocol::{ AcceptanceProfile, CandidateSet, CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractProjection, ContractProposeRequest, ContractProposeResponse, - ContractQuery, ContractState, EventKind, EvidenceClaim, Grant, IntentId, NodeRole, - RouteQuery, SourceMetrics, event_hash, + ContractQuery, ContractState, DirectorySeed, EventKind, EvidenceClaim, Grant, IntentId, + NodeId, NodeRole, RouteQuery, SourceMetrics, event_hash, }, transport::{HttpStats, PeerClient}, }; @@ -54,7 +54,7 @@ pub struct RequesterService { access: ArtifactAccessService, client: PeerClient, decision: Arc, - directory_endpoint: String, + directory_seed: DirectorySeed, artifact_endpoint: String, pursuits: Arc>>, } @@ -78,7 +78,36 @@ impl RequesterService { access, client, decision: Arc::new(decision), - directory_endpoint, + directory_seed: DirectorySeed { + endpoint: url::Url::parse(&directory_endpoint) + .map_err(|_| RuntimeError::TransportFailed)?, + node_id: NodeId::new("node:directory:loopback")?, + }, + artifact_endpoint, + pursuits: Arc::new(RwLock::new(HashMap::new())), + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_with_directory_seed( + identity: NodeIdentity, + store: ArtifactStore, + access: ArtifactAccessService, + client: PeerClient, + decision: LlmDecisionAdapter, + directory_seed: DirectorySeed, + artifact_endpoint: String, + ) -> Result { + if identity.role() != NodeRole::Requester { + return Err(RuntimeError::CredentialRoleMismatch); + } + Ok(Self { + identity, + store, + access, + client, + decision: Arc::new(decision), + directory_seed, artifact_endpoint, pursuits: Arc::new(RwLock::new(HashMap::new())), }) @@ -200,8 +229,9 @@ impl RequesterService { )?; let candidates: CandidateSet = self .client - .post_signed_read( - &self.directory_endpoint, + .post_signed_peer_or_loopback( + self.directory_seed.endpoint.as_str(), + &self.directory_seed.node_id, "/v0/routes/query", &envelope, "route.candidates.v1", @@ -224,7 +254,9 @@ impl RequesterService { artifact: crate::protocol::ArtifactRef, prefix: &str, ) -> Result { - let expires_at_unix_ms = unix_ms() + 60_000; + let expires_at_unix_ms = u64::try_from(self.identity.now_ms()) + .unwrap_or(u64::MAX) + .saturating_add(60_000); Ok(ContractDraft { contract_id: ContractId::new(format!("{prefix}:{}", uuid::Uuid::new_v4()))?, parent_contract_id, @@ -261,21 +293,29 @@ impl RequesterService { let envelope = self.identity.seal("contract.propose.v1", &request)?; let response: ContractProposeResponse = self .client - .post_signed( + .post_signed_peer_or_loopback( &manifest.endpoint, + &manifest.provider, "/v0/contracts/propose", &envelope, "contract.sealed.v1", - NodeRole::Executor, + provider_role(manifest)?, ) .await .map_err(|_| RuntimeError::TransportFailed)?; - response - .contract - .verify(self.identity.root(), self.identity.domain_id(), unix_ms())?; + response.contract.verify( + self.identity.root(), + self.identity.domain_id(), + u64::try_from(self.identity.now_ms()).unwrap_or(u64::MAX), + )?; self.access.authorize(response.contract).await?; let projection = self - .poll_delivered(&manifest.endpoint, &contract_id) + .poll_delivered( + &manifest.endpoint, + &manifest.provider, + provider_role(manifest)?, + &contract_id, + ) .await?; Ok((contract_id, projection)) } @@ -283,6 +323,8 @@ impl RequesterService { async fn poll_delivered( &self, endpoint: &str, + provider: &NodeId, + provider_role: NodeRole, contract_id: &ContractId, ) -> Result { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); @@ -295,12 +337,13 @@ impl RequesterService { )?; let projection: ContractProjection = self .client - .post_signed_read( + .post_signed_peer_or_loopback( endpoint, + provider, "/v0/contracts/events/query", &envelope, "contract.projection.v1", - NodeRole::Executor, + provider_role, ) .await .map_err(|_| RuntimeError::TransportFailed)?; @@ -334,13 +377,14 @@ impl RequesterService { "verification_contract_id": verification_contract_id, "evidence_hash": evidence_hash(evidence)?, }), - occurred_at_unix_ms: unix_ms(), + occurred_at_unix_ms: u64::try_from(self.identity.now_ms()).unwrap_or(u64::MAX), }; let envelope = self.identity.seal("contract.event.v1", &event)?; let response: serde_json::Value = self .client - .post_signed( + .post_signed_peer_or_loopback( &executor.endpoint, + &executor.provider, "/v0/contracts/events/append", &envelope, "contract.event.appended.v1", @@ -355,6 +399,14 @@ impl RequesterService { } } +fn provider_role(manifest: &CapabilityManifest) -> Result { + match manifest.kind.as_str() { + "source.metrics" => Ok(NodeRole::Executor), + "source.metrics.verify" => Ok(NodeRole::Verifier), + _ => Err(RuntimeError::CapabilityUnavailable), + } +} + fn delivered_evidence(projection: &ContractProjection) -> Result { let event = projection .events @@ -381,13 +433,6 @@ fn elapsed_ms(start: Instant) -> u64 { start.elapsed().as_millis() as u64 } -fn unix_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - fn map_decision_error(error: DecisionError) -> RuntimeError { match error { DecisionError::InvalidConfiguration => RuntimeError::DecisionFailed, diff --git a/src/service/mod.rs b/src/service/mod.rs index c12585a..cbcfbb2 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -118,6 +118,7 @@ pub enum ServiceError { CommandTimedOut, UserSessionUnavailable, SupervisorInvalid, + RuntimeUnavailable, PersistenceUnavailable, } diff --git a/src/service/supervisor.rs b/src/service/supervisor.rs index 96f15e2..6463ab1 100644 --- a/src/service/supervisor.rs +++ b/src/service/supervisor.rs @@ -4,10 +4,8 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use ed25519_dalek::VerifyingKey; use crate::{ - bootstrap::{ - BootstrapPhase, BootstrapStateStore, NodeConfigV1, NodePaths, load_startup_bundle, - }, - protocol::{BootstrapProfile, NodeRole}, + bootstrap::{BootstrapPhase, BootstrapStateStore, NodePaths, load_startup_bundle}, + protocol::NodeRole, runtime::key_store::read_owner_only, }; @@ -22,8 +20,13 @@ where F: Future, { let _state = validate_and_lock(paths, config)?; - shutdown.await; - Ok(()) + let runtime = crate::runtime::HostRuntime::load(paths) + .await + .map_err(|_| ServiceError::RuntimeUnavailable)?; + runtime + .run_until(paths, shutdown) + .await + .map_err(|_| ServiceError::RuntimeUnavailable) } fn validate_and_lock( @@ -41,28 +44,28 @@ fn validate_and_lock( ) { return Err(ServiceError::SupervisorInvalid); } - let config = paths + let _config = paths .read_config() .map_err(|_| ServiceError::SupervisorInvalid)?; let root = read_root(paths)?; - validate_bundle_for_profile(paths, &root, &config)?; + validate_bundle_for_signed_role(paths, &root)?; Ok(state) } -fn validate_bundle_for_profile( +fn validate_bundle_for_signed_role( paths: &NodePaths, root: &VerifyingKey, - config: &NodeConfigV1, ) -> Result<(), ServiceError> { - let roles: &[NodeRole] = match config.profile { - BootstrapProfile::Base => &[NodeRole::Directory, NodeRole::Requester], - BootstrapProfile::Provider => &[NodeRole::Executor, NodeRole::Verifier], - BootstrapProfile::AgentCandidate => &[NodeRole::Requester], - }; + let roles = [ + NodeRole::Directory, + NodeRole::Requester, + NodeRole::Executor, + NodeRole::Verifier, + ]; let now = unix_ms()?; if roles - .iter() - .any(|role| load_startup_bundle(paths, root, *role, now).is_ok()) + .into_iter() + .any(|role| load_startup_bundle(paths, root, role, now).is_ok()) { Ok(()) } else { @@ -151,6 +154,18 @@ mod tests { assert_eq!(result, Err(ServiceError::SupervisorInvalid)); } + #[tokio::test] + async fn founding_runtime_rejects_partial_authority_material_before_bind() { + let (_temp, paths) = provisioned_paths(); + std::fs::remove_file(&paths.authority_signing_key_file).unwrap(); + + assert!(matches!( + crate::runtime::HostRuntime::load(&paths).await, + Err(crate::runtime::RuntimeError::FoundingAuthorityMaterialInvalid) + )); + assert!(!paths.service_metadata_file.exists()); + } + #[tokio::test] async fn supervisor_validates_bundle_holds_single_instance_lock_and_exits_cleanly() { let (_temp, paths) = provisioned_paths(); @@ -163,6 +178,11 @@ mod tests { .unwrap(); drop(state); + let runtime = crate::runtime::HostRuntime::load(&paths) + .await + .expect("host runtime loads"); + drop(runtime); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); let (stop_tx, stop_rx) = tokio::sync::oneshot::channel(); let config = paths.config_file.clone(); @@ -174,7 +194,9 @@ mod tests { }) .await }); - ready_rx.await.unwrap(); + if ready_rx.await.is_err() { + panic!("supervisor exited before shutdown: {:?}", task.await); + } assert!(matches!( BootstrapStateStore::open(&paths.journal_file), Err(crate::bootstrap::BootstrapError::StateLocked) diff --git a/src/transport/client.rs b/src/transport/client.rs index 5fc84e5..fc0b766 100644 --- a/src/transport/client.rs +++ b/src/transport/client.rs @@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize}; use crate::{ bootstrap::network::NetworkBoundary, protocol::{DomainId, NodeId, NodeRole, WireEnvelope}, + runtime::{Clock, FixedClock}, }; use super::MAX_JSON_BODY_BYTES; @@ -50,10 +51,11 @@ pub struct PeerClient { client: reqwest::Client, root: VerifyingKey, domain_id: DomainId, - validation_time_unix_ms: u64, + clock: Arc, counters: Arc, boundary: NetworkBoundary, expected_tls_peer: Option, + dynamic_identity: Option>, } impl Debug for PeerClient { @@ -128,10 +130,13 @@ impl PeerClient { client, root, domain_id, - validation_time_unix_ms, + clock: Arc::new(FixedClock::new( + i64::try_from(validation_time_unix_ms).unwrap_or(i64::MAX), + )), counters: Arc::new(Counters::default()), boundary, expected_tls_peer: None, + dynamic_identity: None, }) } @@ -142,19 +147,130 @@ impl PeerClient { boundary: NetworkBoundary, identity: &super::tls::PeerTlsIdentity, expected_peer: NodeId, + ) -> Result { + Self::new_mtls_with_clock( + root, + domain_id, + Arc::new(FixedClock::new( + i64::try_from(validation_time_unix_ms).unwrap_or(i64::MAX), + )), + boundary, + identity, + expected_peer, + ) + } + + pub fn new_mtls_with_clock( + root: VerifyingKey, + domain_id: DomainId, + clock: Arc, + boundary: NetworkBoundary, + identity: &super::tls::PeerTlsIdentity, + expected_peer: NodeId, ) -> Result { let client = super::tls::build_peer_client(identity, &boundary, &expected_peer)?; Ok(Self { client, root, domain_id, - validation_time_unix_ms, + clock, counters: Arc::new(Counters::default()), boundary, expected_tls_peer: Some(expected_peer), + dynamic_identity: None, + }) + } + + pub fn new_mtls_dynamic( + root: VerifyingKey, + domain_id: DomainId, + clock: Arc, + boundary: NetworkBoundary, + identity: super::tls::PeerTlsIdentity, + ) -> Result { + boundary + .validate_bind_shape() + .map_err(|_| TransportError::InvalidEndpoint)?; + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| TransportError::RequestFailed)?; + Ok(Self { + client, + root, + domain_id, + clock, + counters: Arc::new(Counters::default()), + boundary, + expected_tls_peer: None, + dynamic_identity: Some(Arc::new(identity)), }) } + pub async fn post_signed_to_peer( + &self, + endpoint: &str, + expected_peer: &NodeId, + path: &str, + envelope: &WireEnvelope, + expected_object_type: &str, + expected_response_role: NodeRole, + ) -> Result { + let identity = self + .dynamic_identity + .as_ref() + .ok_or(TransportError::TlsIdentityMismatch)?; + let bound = Self::new_mtls_with_clock( + self.root, + self.domain_id.clone(), + Arc::clone(&self.clock), + self.boundary.clone(), + identity, + expected_peer.clone(), + )?; + bound + .post_signed( + endpoint, + path, + envelope, + expected_object_type, + expected_response_role, + ) + .await + } + + pub async fn post_signed_peer_or_loopback( + &self, + endpoint: &str, + expected_peer: &NodeId, + path: &str, + envelope: &WireEnvelope, + expected_object_type: &str, + expected_response_role: NodeRole, + ) -> Result { + if self.dynamic_identity.is_some() { + return self + .post_signed_to_peer( + endpoint, + expected_peer, + path, + envelope, + expected_object_type, + expected_response_role, + ) + .await; + } + self.post_signed_read( + endpoint, + path, + envelope, + expected_object_type, + expected_response_role, + ) + .await + } + pub async fn post_signed( &self, endpoint: &str, @@ -208,8 +324,7 @@ impl PeerClient { &self.root, &self.domain_id, expected_response_role, - i64::try_from(self.validation_time_unix_ms) - .map_err(|_| TransportError::InvalidSignedResponse)?, + self.clock.now_ms(), ) .map_err(|_| TransportError::InvalidSignedResponse)?; if self diff --git a/src/transport/directory.rs b/src/transport/directory.rs index 3584173..97013a6 100644 --- a/src/transport/directory.rs +++ b/src/transport/directory.rs @@ -2,11 +2,12 @@ use std::sync::Arc; use axum::{ Json, Router, - extract::{DefaultBodyLimit, State, rejection::JsonRejection}, + extract::{DefaultBodyLimit, Extension, State, rejection::JsonRejection}, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, }; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde_json::json; use crate::{ @@ -19,7 +20,6 @@ use super::MAX_JSON_BODY_BYTES; struct DirectoryHttpState { registry: DirectoryRegistry, identity: NodeIdentity, - now_unix_ms: u64, revocations: Option, } @@ -43,13 +43,12 @@ pub fn directory_router_with_revocation( fn directory_router_inner( registry: DirectoryRegistry, identity: NodeIdentity, - now_unix_ms: u64, + _now_unix_ms: u64, revocations: Option, ) -> Router { let state = Arc::new(DirectoryHttpState { registry, identity, - now_unix_ms, revocations, }); Router::new() @@ -67,7 +66,7 @@ async fn health(State(state): State>) -> Json>) -> Json>, + tls_identity: Option>, payload: Result, JsonRejection>, ) -> Response { let envelope = match envelope_or_error(payload) { Ok(envelope) => envelope, Err(response) => return *response, }; - let now_ms = match i64::try_from(state.now_unix_ms) { - Ok(value) => value, - Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + let now_ms = state.identity.now_ms(); + let expected_role = match advertised_registration_role(&envelope) { + Ok(role) => role, + Err(()) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; let opened = match envelope.open_with_verified_claims::( "capability.manifest.v1", state.identity.root(), state.identity.domain_id(), - crate::protocol::NodeRole::Executor, + expected_role, now_ms, ) { Ok(opened) => opened, @@ -121,9 +122,22 @@ async fn register( _ => error_response(StatusCode::FORBIDDEN, "InvalidSignedEnvelope"), }; } + if let Some(Extension(tls_identity)) = tls_identity { + let endpoint_ip = opened + .payload() + .endpoint + .parse::() + .ok() + .and_then(|url| url.host_str()?.trim_matches(['[', ']']).parse().ok()); + if endpoint_ip != Some(tls_identity.peer_ip) + || opened.claims().node_id != tls_identity.node_id + { + return error_response(StatusCode::FORBIDDEN, "InvalidCapabilityManifest"); + } + } if state .registry - .register_opened(opened, state.now_unix_ms) + .register_opened(opened, u64::try_from(now_ms).unwrap_or(u64::MAX)) .await .is_err() { @@ -138,6 +152,27 @@ async fn register( } } +fn advertised_registration_role(envelope: &WireEnvelope) -> Result { + let bytes = STANDARD + .decode(&envelope.credential_chain.node.claims_base64) + .map_err(|_| ())?; + let claims: crate::protocol::NodeCredentialClaims = + serde_json::from_slice(&bytes).map_err(|_| ())?; + if claims + .allowed_roles + .contains(&crate::protocol::NodeRole::Executor) + { + return Ok(crate::protocol::NodeRole::Executor); + } + if claims + .allowed_roles + .contains(&crate::protocol::NodeRole::Verifier) + { + return Ok(crate::protocol::NodeRole::Verifier); + } + Err(()) +} + async fn query( State(state): State>, payload: Result, JsonRejection>, @@ -146,28 +181,46 @@ async fn query( Ok(envelope) => envelope, Err(response) => return *response, }; - let now_ms = match i64::try_from(state.now_unix_ms) { - Ok(value) => value, - Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), - }; - let query: RouteQuery = match envelope.open( + let now_ms = state.identity.now_ms(); + let opened = match envelope.open_with_verified_claims::( "route.query.v1", state.identity.root(), state.identity.domain_id(), crate::protocol::NodeRole::Requester, now_ms, ) { - Ok(query) => query, + Ok(opened) => opened, Err(_) => return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; + if let Some(guard) = &state.revocations { + match guard.read_only( + now_ms, + &opened.claims().authority_id, + &opened.claims().node_id, + ) { + RevocationDecision::CurrentAndAllowed => {} + RevocationDecision::Revoked => { + return error_response(StatusCode::FORBIDDEN, "CredentialRevoked"); + } + RevocationDecision::Stale => { + return error_response(StatusCode::CONFLICT, "RevocationStateStale"); + } + } + } + let (query, _) = opened.into_parts(); let candidates = match &state.revocations { Some(guard) => { state .registry - .query_with_revocation(query, state.now_unix_ms, guard) + .query_with_revocation(query, u64::try_from(now_ms).unwrap_or(u64::MAX), guard) + .await + } + None => { + state + .registry + .query(query, u64::try_from(now_ms).unwrap_or(u64::MAX)) .await } - None => state.registry.query(query, state.now_unix_ms).await, }; match state.identity.seal("route.candidates.v1", &candidates) { Ok(response) => (StatusCode::OK, Json(response)).into_response(), diff --git a/src/transport/enrollment.rs b/src/transport/enrollment.rs index 789addd..73a56cb 100644 --- a/src/transport/enrollment.rs +++ b/src/transport/enrollment.rs @@ -120,7 +120,7 @@ impl EnrollmentClient { return Err(EnrollmentError::InvalidRequest); } let wire = self.send_enrollment(body).await?; - if wire.format_version != "agenet.enrollment-wire.v0.3" { + if wire.format_version != "agenet.enrollment-wire.v0.4" { return Err(EnrollmentError::InvalidRequest); } EnrollmentHandoffValidation::validate(&handoff, attempt, &wire.bundle, current_time_ms())?; @@ -234,7 +234,7 @@ async fn handle_enrollment( .process(&request, current_time_ms()) .map(|bundle| { Json(crate::bootstrap::EnrollmentWireResponse { - format_version: "agenet.enrollment-wire.v0.3".to_owned(), + format_version: "agenet.enrollment-wire.v0.4".to_owned(), bundle, }) }) diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 8e06695..b2faecc 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -10,7 +10,10 @@ pub use directory::{directory_router, directory_router_with_revocation}; pub use enrollment::{ EnrollmentClient, EnrollmentTransportError, enrollment_router, enrollment_tls_config, }; -pub use node::{artifact_router, provider_router, requester_router}; +pub use node::{ + artifact_router, artifact_router_with_revocation, base_router_with_revocation, provider_router, + provider_router_with_revocation, requester_router, requester_router_with_revocation, +}; pub use revocation::{RevocationClient, RevocationTransportError, authority_revocation_router}; pub use tls::{ PeerTlsIdentity, build_peer_client, build_peer_server_config, validate_peer_endpoint_transport, diff --git a/src/transport/node.rs b/src/transport/node.rs index 224a9d8..0b52034 100644 --- a/src/transport/node.rs +++ b/src/transport/node.rs @@ -15,13 +15,66 @@ use crate::{ }, runtime::{ ArtifactAccessService, NodeIdentity, ProviderService, PursuitQuery, PursuitRequest, - RequesterService, RuntimeError, + RequesterService, RevocationGuard, RuntimeError, }, }; use super::MAX_JSON_BODY_BYTES; +#[derive(Clone)] +struct BaseHttpState { + identity: NodeIdentity, + revocations: RevocationGuard, +} + +pub fn base_router_with_revocation(identity: NodeIdentity, revocations: RevocationGuard) -> Router { + Router::new() + .route("/healthz", get(base_health)) + .with_state(Arc::new(BaseHttpState { + identity, + revocations, + })) +} + +async fn base_health(State(state): State>) -> Response { + let decision = state.revocations.read_only( + state.identity.now_ms(), + &state.identity.claims().authority_id, + state.identity.node_id(), + ); + match decision { + crate::protocol::RevocationDecision::CurrentAndAllowed => ( + StatusCode::OK, + Json(json!({"status": "ok", "requester": "disabled"})), + ) + .into_response(), + crate::protocol::RevocationDecision::Revoked => { + error(StatusCode::FORBIDDEN, "CredentialRevoked") + } + crate::protocol::RevocationDecision::Stale => { + error(StatusCode::CONFLICT, "RevocationStateStale") + } + } +} + +#[derive(Clone)] +struct ProviderHttpState { + service: ProviderService, + revocations: Option, +} + pub fn provider_router(service: ProviderService) -> Router { + provider_router_inner(service, None) +} + +pub fn provider_router_with_revocation( + service: ProviderService, + revocations: RevocationGuard, +) -> Router { + provider_router_inner(service, Some(revocations)) +} + +fn provider_router_inner(service: ProviderService, revocations: Option) -> Router { Router::new() .route("/healthz", get(health)) .route("/v0/contracts/propose", post(provider_propose)) @@ -31,34 +84,40 @@ pub fn provider_router(service: ProviderService) -> Router { .layer(axum::middleware::from_fn( super::tls::enforce_tls_envelope_binding, )) - .with_state(Arc::new(service)) + .with_state(Arc::new(ProviderHttpState { + service, + revocations, + })) } async fn provider_propose( - State(service): State>, + State(state): State>, payload: Result, JsonRejection>, ) -> Response { let envelope = match envelope(payload) { Ok(envelope) => envelope, Err(response) => return *response, }; - let now_ms = match i64::try_from(service.validation_time_unix_ms()) { - Ok(value) => value, - Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), - }; - let request: ContractProposeRequest = match envelope.open( + let now_ms = state.service.identity().now_ms(); + let opened = match envelope.open_with_verified_claims::( "contract.propose.v1", - service.identity().root(), - service.identity().domain_id(), + state.service.identity().root(), + state.service.identity().domain_id(), crate::protocol::NodeRole::Requester, now_ms, ) { - Ok(request) => request, + Ok(opened) => opened, Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; - match service.propose(&envelope.issuer_id, request).await { + if let Some(guard) = &state.revocations + && let Err(error) = guard.effectful_verified_claims(now_ms, opened.claims()) + { + return policy_error(error); + } + let (request, _) = opened.into_parts(); + match state.service.propose(&envelope.issuer_id, request).await { Ok(response) => signed_response( - service.identity(), + state.service.identity(), "contract.sealed.v1", &response, StatusCode::OK, @@ -68,18 +127,35 @@ async fn provider_propose( } async fn provider_append( - State(service): State>, + State(state): State>, payload: Result, JsonRejection>, ) -> Response { let envelope = match envelope(payload) { Ok(envelope) => envelope, Err(response) => return *response, }; - match service.append(envelope).await { - Ok(state) => signed_response( - service.identity(), + let now_ms = state.service.identity().now_ms(); + let opened = match envelope.open_with_verified_claims::( + "contract.event.v1", + state.service.identity().root(), + state.service.identity().domain_id(), + crate::protocol::NodeRole::Requester, + now_ms, + ) { + Ok(opened) => opened, + Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; + if let Some(guard) = &state.revocations + && let Err(error) = guard.effectful_verified_claims(now_ms, opened.claims()) + { + return policy_error(error); + } + let (event, _) = opened.into_parts(); + match state.service.append_verified(envelope, event).await { + Ok(projection_state) => signed_response( + state.service.identity(), "contract.event.appended.v1", - &json!({"state": state}), + &json!({"state": projection_state}), StatusCode::OK, ), Err(_) => error(StatusCode::FORBIDDEN, "EventRejected"), @@ -87,30 +163,42 @@ async fn provider_append( } async fn provider_query( - State(service): State>, + State(state): State>, payload: Result, JsonRejection>, ) -> Response { let envelope = match envelope(payload) { Ok(envelope) => envelope, Err(response) => return *response, }; - let now_ms = match i64::try_from(service.validation_time_unix_ms()) { - Ok(value) => value, - Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), - }; + let now_ms = state.service.identity().now_ms(); let query: ContractQuery = match envelope.open( "contract.query.v1", - service.identity().root(), - service.identity().domain_id(), + state.service.identity().root(), + state.service.identity().domain_id(), crate::protocol::NodeRole::Requester, now_ms, ) { Ok(query) => query, Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; - match service.query(&query).await { + if let Some(guard) = &state.revocations { + match guard.read_only( + now_ms, + &state.service.identity().claims().authority_id, + &envelope.issuer_id, + ) { + crate::protocol::RevocationDecision::CurrentAndAllowed => {} + crate::protocol::RevocationDecision::Revoked => { + return error(StatusCode::FORBIDDEN, "CredentialRevoked"); + } + crate::protocol::RevocationDecision::Stale => { + return error(StatusCode::CONFLICT, "RevocationStateStale"); + } + } + } + match state.service.query(&query).await { Ok(projection) => signed_response( - service.identity(), + state.service.identity(), "contract.projection.v1", &projection, StatusCode::OK, @@ -123,9 +211,26 @@ async fn provider_query( struct ArtifactHttpState { identity: NodeIdentity, access: ArtifactAccessService, + revocations: Option, } pub fn artifact_router(identity: NodeIdentity, access: ArtifactAccessService) -> Router { + artifact_router_inner(identity, access, None) +} + +pub fn artifact_router_with_revocation( + identity: NodeIdentity, + access: ArtifactAccessService, + revocations: RevocationGuard, +) -> Router { + artifact_router_inner(identity, access, Some(revocations)) +} + +fn artifact_router_inner( + identity: NodeIdentity, + access: ArtifactAccessService, + revocations: Option, +) -> Router { Router::new() .route("/healthz", get(health)) .route("/v0/artifacts/read", post(artifact_read)) @@ -133,7 +238,11 @@ pub fn artifact_router(identity: NodeIdentity, access: ArtifactAccessService) -> .layer(axum::middleware::from_fn( super::tls::enforce_tls_envelope_binding, )) - .with_state(Arc::new(ArtifactHttpState { identity, access })) + .with_state(Arc::new(ArtifactHttpState { + identity, + access, + revocations, + })) } #[derive(Clone)] @@ -144,6 +253,27 @@ struct RequesterHttpState { pub fn requester_router(service: RequesterService, control_token: String) -> Router { let artifact_routes = artifact_router(service.identity().clone(), service.access().clone()); + requester_router_inner(service, control_token, artifact_routes) +} + +pub fn requester_router_with_revocation( + service: RequesterService, + control_token: String, + revocations: RevocationGuard, +) -> Router { + let artifact_routes = artifact_router_with_revocation( + service.identity().clone(), + service.access().clone(), + revocations, + ); + requester_router_inner(service, control_token, artifact_routes) +} + +fn requester_router_inner( + service: RequesterService, + control_token: String, + artifact_routes: Router, +) -> Router { let local_routes = Router::new() .route("/local/v0/pursuits", post(local_pursuit)) .route("/local/v0/pursuits/query", post(local_pursuit_query)) @@ -213,20 +343,23 @@ async fn artifact_read( Ok(envelope) => envelope, Err(response) => return *response, }; - let now_ms = match i64::try_from(state.identity.validation_time_unix_ms()) { - Ok(value) => value, - Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), - }; - let request: ArtifactReadRequest = match envelope.open( + let now_ms = state.identity.now_ms(); + let opened = match envelope.open_with_verified_claims::( "artifact.read.v1", state.identity.root(), state.identity.domain_id(), crate::protocol::NodeRole::Requester, now_ms, ) { - Ok(request) => request, + Ok(opened) => opened, Err(_) => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), }; + if let Some(guard) = &state.revocations + && let Err(error) = guard.effectful_verified_claims(now_ms, opened.claims()) + { + return policy_error(error); + } + let (request, _) = opened.into_parts(); match state.access.read(&envelope.issuer_id, &request).await { Ok(artifact) => signed_response( &state.identity, @@ -272,6 +405,14 @@ fn signed_response( } } +fn policy_error(error_value: RuntimeError) -> Response { + match error_value { + RuntimeError::RevocationStateStale => error(StatusCode::CONFLICT, "RevocationStateStale"), + RuntimeError::CredentialRevoked => error(StatusCode::FORBIDDEN, "CredentialRevoked"), + _ => error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + } +} + fn error(status: StatusCode, code: &str) -> Response { ( status, diff --git a/src/transport/revocation.rs b/src/transport/revocation.rs index 422f39f..cae3761 100644 --- a/src/transport/revocation.rs +++ b/src/transport/revocation.rs @@ -102,6 +102,38 @@ impl RevocationClient { }) } + pub fn new_with_authority_ca( + boundary: &NetworkBoundary, + endpoint: Url, + authority_ca_pem: &str, + connect_timeout: Duration, + request_timeout: Duration, + ) -> Result { + boundary + .validate_peer_endpoint(endpoint.as_str()) + .map_err(|_| RevocationTransportError::InvalidEndpoint)?; + if endpoint.scheme() != "https" { + return Err(RevocationTransportError::InvalidEndpoint); + } + let authority = reqwest::Certificate::from_pem(authority_ca_pem.as_bytes()) + .map_err(|_| RevocationTransportError::InvalidEndpoint)?; + let client = Client::builder() + .no_proxy() + .redirect(Policy::none()) + .connect_timeout(connect_timeout) + .timeout(request_timeout) + .https_only(true) + .tls_certs_only([authority]) + .build() + .map_err(|_| RevocationTransportError::RequestFailed)?; + let (diagnostics, _) = watch::channel(None); + Ok(Self { + client, + endpoint, + diagnostics, + }) + } + pub async fn refresh( &self, cache: &RevocationCache, diff --git a/src/transport/tls.rs b/src/transport/tls.rs index f611b98..28ae589 100644 --- a/src/transport/tls.rs +++ b/src/transport/tls.rs @@ -50,6 +50,17 @@ pub struct PeerTlsIdentity { pub authority_ca_pem: String, } +impl Clone for PeerTlsIdentity { + fn clone(&self) -> Self { + Self { + node_id: self.node_id.clone(), + certificate_chain_pem: Zeroizing::new(self.certificate_chain_pem.to_string()), + private_key_pem: Zeroizing::new(self.private_key_pem.to_string()), + authority_ca_pem: self.authority_ca_pem.clone(), + } + } +} + impl Debug for PeerTlsIdentity { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { formatter @@ -264,6 +275,7 @@ pub fn build_peer_server_config( #[derive(Clone)] pub(crate) struct PeerTlsConnectionIdentity { pub(crate) node_id: NodeId, + pub(crate) peer_ip: std::net::IpAddr, } pub(crate) async fn enforce_tls_envelope_binding(request: Request, next: Next) -> Response { @@ -350,9 +362,11 @@ where }; let node_id = extract_node_id(leaf).map_err(|_| io::Error::other("invalid peer identity"))?; + let peer_ip = + extract_leaf_ip(leaf).map_err(|_| io::Error::other("invalid peer identity"))?; Ok(( stream, - Extension(PeerTlsConnectionIdentity { node_id }).layer(service), + Extension(PeerTlsConnectionIdentity { node_id, peer_ip }).layer(service), )) }) } @@ -492,6 +506,29 @@ fn validate_leaf_ip( .ok_or(TransportError::TlsRejected) } +fn extract_leaf_ip(certificate: &CertificateDer<'_>) -> Result { + let (_, parsed) = + X509Certificate::from_der(certificate.as_ref()).map_err(|_| TransportError::TlsRejected)?; + let san = parsed + .subject_alternative_name() + .map_err(|_| TransportError::TlsRejected)? + .ok_or(TransportError::TlsRejected)?; + let [x509_parser::extensions::GeneralName::IPAddress(bytes)] = + san.value.general_names.as_slice() + else { + return Err(TransportError::TlsRejected); + }; + match bytes.len() { + 4 => Ok(std::net::IpAddr::from( + <[u8; 4]>::try_from(*bytes).map_err(|_| TransportError::TlsRejected)?, + )), + 16 => Ok(std::net::IpAddr::from( + <[u8; 16]>::try_from(*bytes).map_err(|_| TransportError::TlsRejected)?, + )), + _ => Err(TransportError::TlsRejected), + } +} + fn canonical_der_length(encoded: &[u8]) -> Result<(usize, usize), TransportError> { match encoded[1] { value @ 0..=127 => Ok((2, usize::from(value))), diff --git a/tests/bootstrap_config.rs b/tests/bootstrap_config.rs index ac75640..8aeace0 100644 --- a/tests/bootstrap_config.rs +++ b/tests/bootstrap_config.rs @@ -6,7 +6,7 @@ use agenet::{ UserPlatform, load_startup_bundle, network::{NetworkBoundary, OverlayKind}, }, - protocol::{BootstrapProfile, DomainId, NodeId, NodeRole}, + protocol::{BootstrapProfile, DirectorySeed, DomainId, NodeId, NodeRole}, transport::PeerTlsIdentity, }; use ed25519_dalek::SigningKey; @@ -21,20 +21,28 @@ fn private_config() -> NodeConfigV1 { let ip: IpAddr = "100.64.0.10".parse().unwrap(); NodeConfigV1 { format: "agenet.node-config".to_owned(), - schema_version: 1, + schema_version: 2, domain_id: DomainId::new("domain-a").unwrap(), profile: BootstrapProfile::Provider, + peer_port: 7444, network: NetworkBoundary { kind: OverlayKind::Tailscale, bind_ip: ip, allowed_cidrs: vec!["100.64.0.0/10".parse::().unwrap()], }, - directory_seeds: vec![Url::parse("https://100.64.0.2:7443/").unwrap()], + directory_seeds: vec![seed("https://100.64.0.2:7443/")], authority_endpoint: Url::parse("https://100.64.0.3:7443/").unwrap(), revocation_endpoint: Url::parse("https://100.64.0.3:7443/").unwrap(), } } +fn seed(endpoint: &str) -> DirectorySeed { + DirectorySeed { + endpoint: Url::parse(endpoint).unwrap(), + node_id: NodeId::new("node:directory:test").unwrap(), + } +} + #[test] fn config_parser_accepts_v1_and_rejects_unknown_or_future_schema() { let encoded = serde_json::to_vec(&private_config()).unwrap(); @@ -52,7 +60,7 @@ fn config_parser_accepts_v1_and_rejects_unknown_or_future_schema() { Err(BootstrapError::ResourceLimitExceeded) ); unknown.as_object_mut().unwrap().remove("surprise"); - unknown["schema_version"] = serde_json::json!(2); + unknown["schema_version"] = serde_json::json!(3); assert_eq!( NodeConfigV1::parse_json(&serde_json::to_vec(&unknown).unwrap()), Err(BootstrapError::UnsupportedConfigFormat) @@ -68,7 +76,7 @@ fn config_accepts_exact_ipv6_private_overlay_and_rejects_relative_xdg() { bind_ip: bind, allowed_cidrs: vec!["fd00::/8".parse().unwrap()], }, - directory_seeds: vec![Url::parse("https://[fd00::20]:7443/").unwrap()], + directory_seeds: vec![seed("https://[fd00::20]:7443/")], authority_endpoint: Url::parse("https://[fd00::30]:7443/").unwrap(), revocation_endpoint: Url::parse("https://[fd00::30]:7443/").unwrap(), ..private_config() @@ -103,7 +111,7 @@ fn config_rejects_unbounded_or_ambiguous_peer_endpoints() { "https://directory.internal:7443/", ] { let mut changed = private_config(); - changed.directory_seeds = vec![Url::parse(endpoint).unwrap()]; + changed.directory_seeds = vec![seed(endpoint)]; assert_eq!( changed.validate(), Err(BootstrapError::InvalidConfig), @@ -113,7 +121,7 @@ fn config_rejects_unbounded_or_ambiguous_peer_endpoints() { let loopback = NodeConfigV1 { network: NetworkBoundary::loopback_ipv4(), - directory_seeds: vec![Url::parse("http://127.0.0.1:7443/").unwrap()], + directory_seeds: vec![seed("http://127.0.0.1:7443/")], authority_endpoint: Url::parse("http://127.0.0.1:7444/").unwrap(), revocation_endpoint: Url::parse("http://127.0.0.1:7444/").unwrap(), ..private_config() @@ -225,11 +233,12 @@ fn startup_loader_verifies_credential_role_time_domain_and_tls_identity() { .unwrap(); let config = NodeConfigV1 { format: "agenet.node-config".to_owned(), - schema_version: 1, + schema_version: 2, domain_id: common::domain_id(), profile: BootstrapProfile::Base, network: NetworkBoundary::loopback_ipv4(), - directory_seeds: vec![Url::parse("https://127.0.0.1:7443/").unwrap()], + peer_port: 7443, + directory_seeds: vec![seed("https://127.0.0.1:7443/")], authority_endpoint: Url::parse("https://127.0.0.1:7444/").unwrap(), revocation_endpoint: Url::parse("https://127.0.0.1:7444/").unwrap(), }; diff --git a/tests/enrollment_protocol.rs b/tests/enrollment_protocol.rs index 9c343fe..1fc616e 100644 --- a/tests/enrollment_protocol.rs +++ b/tests/enrollment_protocol.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use agenet::{ bootstrap::{EnrollmentAttempt, InvitationSpec, InvitationStore, NodeTlsCsr}, - protocol::{BootstrapProfile, CapabilityKind, DomainId, NodeId}, + protocol::{BootstrapProfile, CapabilityKind, DirectorySeed, DomainId, NodeId}, }; use ed25519_dalek::SigningKey; use reqwest::Url; @@ -18,10 +18,10 @@ fn invitation() -> (TempDir, agenet::bootstrap::InvitationHandoff) { let handoff = store .create( InvitationSpec { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), domain_id: DomainId::new("domain-test").expect("domain"), authority_endpoint: Url::parse("https://127.0.0.1:8443").expect("url"), - directory_seeds: vec![Url::parse("https://127.0.0.1:9443").expect("url")], + directory_seeds: vec![seed("https://127.0.0.1:9443")], network_kind: agenet::bootstrap::network::OverlayKind::Loopback, allowed_cidrs: vec!["127.0.0.1/32".parse().expect("CIDR")], root_sha256: "11".repeat(32), @@ -37,6 +37,13 @@ fn invitation() -> (TempDir, agenet::bootstrap::InvitationHandoff) { (directory, handoff) } +fn seed(endpoint: &str) -> DirectorySeed { + DirectorySeed { + endpoint: Url::parse(endpoint).expect("url"), + node_id: NodeId::new("node:directory:test").expect("node"), + } +} + fn attempt(handoff: &agenet::bootstrap::InvitationHandoff) -> (SigningKey, EnrollmentAttempt) { let signing_key = SigningKey::from_bytes(&[7_u8; 32]); let csr = NodeTlsCsr::generate().expect("CSR"); @@ -91,7 +98,7 @@ fn invitation_claim_mutation_invalidates_the_exact_request_signature() { let original = handoff.public_claims(); let mut mutations = Vec::new(); let mut claims = original.clone(); - claims.protocol_version = "agenet.enrollment.v0.4".to_owned(); + claims.protocol_version = "agenet.enrollment.v0.5".to_owned(); mutations.push(claims); let mut claims = original.clone(); claims.domain_id = DomainId::new("domain-mutated").expect("domain"); @@ -100,7 +107,7 @@ fn invitation_claim_mutation_invalidates_the_exact_request_signature() { claims.authority_endpoint = Url::parse("https://127.0.0.1:8555/").expect("url"); mutations.push(claims); let mut claims = original.clone(); - claims.directory_seeds = vec![Url::parse("https://127.0.0.1:9555/").expect("url")]; + claims.directory_seeds = vec![seed("https://127.0.0.1:9555/")]; mutations.push(claims); let mut claims = original.clone(); claims.network_kind = agenet::bootstrap::network::OverlayKind::Tailscale; diff --git a/tests/host_runtime.rs b/tests/host_runtime.rs new file mode 100644 index 0000000..f888fcc --- /dev/null +++ b/tests/host_runtime.rs @@ -0,0 +1,49 @@ +use std::{ + collections::BTreeSet, + sync::{ + Arc, + atomic::{AtomicI64, Ordering}, + }, +}; + +use agenet::{ + protocol::{NodeRole, verify_credential_chain}, + runtime::Clock, +}; + +#[derive(Debug)] +struct AdvancingClock(AtomicI64); + +impl AdvancingClock { + fn new(now_ms: i64) -> Self { + Self(AtomicI64::new(now_ms)) + } + + fn advance_to(&self, now_ms: i64) { + self.0.store(now_ms, Ordering::SeqCst); + } +} + +impl Clock for AdvancingClock { + fn now_ms(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } +} + +#[test] +fn request_time_clock_is_live_and_role_sets_are_not_profile_guesses() { + let clock = Arc::new(AdvancingClock::new(10)); + assert_eq!(clock.now_ms(), 10); + clock.advance_to(20); + assert_eq!(clock.now_ms(), 20); + + let provider_roles = + BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]); + assert!(provider_roles.contains(&NodeRole::Executor)); + assert!(provider_roles.contains(&NodeRole::Verifier)); + assert!(!provider_roles.contains(&NodeRole::Directory)); + + // Keep the protocol verifier linked into this integration surface: runtime + // authorization must continue to originate from its signed result. + let _verified_chain = verify_credential_chain; +} diff --git a/tests/http_directory.rs b/tests/http_directory.rs index 425abbb..e804389 100644 --- a/tests/http_directory.rs +++ b/tests/http_directory.rs @@ -5,7 +5,7 @@ use agenet::{ CandidateSet, CapabilityId, CapabilityManifest, CredentialChain, NodeRole, RouteQuery, SideEffectProfile, WireEnvelope, }, - runtime::{DirectoryRegistry, NodeIdentity}, + runtime::{Clock, DirectoryRegistry, NodeIdentity}, transport::{MAX_JSON_BODY_BYTES, directory_router}, }; use axum::{ @@ -15,7 +15,13 @@ use axum::{ }; use ed25519_dalek::SigningKey; use http_body_util::BodyExt; -use std::net::{Ipv4Addr, SocketAddr}; +use std::{ + net::{Ipv4Addr, SocketAddr}, + sync::{ + Arc, + atomic::{AtomicI64, Ordering}, + }, +}; use tower::ServiceExt; const NOW: u64 = 1_800_000_000; @@ -38,6 +44,80 @@ fn identity(root: &SigningKey, node: SigningKey, node_id: &str, role: NodeRole) NodeIdentity::new(node, credential, role, root.verifying_key(), NOW).unwrap() } +#[derive(Debug)] +struct TestClock(AtomicI64); + +impl Clock for TestClock { + fn now_ms(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } +} + +#[tokio::test] +async fn credential_expiring_after_startup_rejects_registration_without_effect() { + let root = signing_key(70); + let clock = Arc::new(TestClock(AtomicI64::new(NOW as i64))); + let directory_key = signing_key(71); + let directory = NodeIdentity::new_with_clock( + directory_key.clone(), + credential( + &root, + &directory_key, + "node:directory-live-clock", + NodeRole::Directory, + ), + NodeRole::Directory, + root.verifying_key(), + clock.clone(), + ) + .unwrap(); + let executor = identity( + &root, + signing_key(72), + "node:executor-live-clock", + NodeRole::Executor, + ); + let registry = DirectoryRegistry::new(); + let app = directory_router(registry.clone(), directory, NOW); + let manifest = CapabilityManifest { + capability_id: CapabilityId::new("capability:expired-after-startup").unwrap(), + provider: executor.node_id().clone(), + kind: "source.metrics".to_owned(), + version: "v1".to_owned(), + description: "must not register after credential expiry".to_owned(), + input_profile: "artifact.source.utf8.v1".to_owned(), + output_profile: "source.metrics.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: "http://127.0.0.1:41416".to_owned(), + evidence_types: vec![], + expires_at_unix_ms: NOW + 120_000, + }; + let envelope = executor.seal("capability.manifest.v1", &manifest).unwrap(); + clock.0.store(NOW as i64 + 60_001, Ordering::SeqCst); + + let response = app + .oneshot(loopback_request(envelope_request( + "/v0/capabilities/register", + &envelope, + ))) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + registry + .query( + RouteQuery { + required_capability: "source.metrics.v1".to_owned(), + }, + NOW + 60_001, + ) + .await + .candidates + .is_empty() + ); +} + #[tokio::test] async fn signed_manifest_registration_and_deterministic_query_round_trip() { let root = signing_key(40); diff --git a/tests/http_enrollment.rs b/tests/http_enrollment.rs index e31936f..f396178 100644 --- a/tests/http_enrollment.rs +++ b/tests/http_enrollment.rs @@ -499,10 +499,13 @@ fn create_handoff( invitations .create( InvitationSpec { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), domain_id: credential.claims.domain_id.clone(), authority_endpoint: endpoint.clone(), - directory_seeds: vec![Url::parse("https://127.0.0.1:9443/").unwrap()], + directory_seeds: vec![agenet::protocol::DirectorySeed { + endpoint: Url::parse("https://127.0.0.1:9443/").unwrap(), + node_id: NodeId::new("node:directory:test").unwrap(), + }], network_kind: agenet::bootstrap::network::OverlayKind::Loopback, allowed_cidrs: vec!["127.0.0.1/32".parse().unwrap()], root_sha256: fingerprint(root.verifying_key().as_bytes()), diff --git a/tests/http_revocation.rs b/tests/http_revocation.rs index f077abc..51fce84 100644 --- a/tests/http_revocation.rs +++ b/tests/http_revocation.rs @@ -262,13 +262,8 @@ async fn directory_registration_fails_stale_and_revoked_but_query_health_remain_ ) .expect("query"); let query_response = route_query(&app, &query).await; - assert_eq!(query_response.status(), StatusCode::OK); - assert!( - route_candidates(query_response, &directory, NOW) - .await - .candidates - .is_empty() - ); + assert_eq!(query_response.status(), StatusCode::CONFLICT); + assert_eq!(error_code(query_response).await, "RevocationStateStale"); let authority = key(6); cache @@ -321,12 +316,10 @@ async fn directory_registration_fails_stale_and_revoked_but_query_health_remain_ ) .expect("authority revocation"); let authority_revoked_query = route_query(&app, &query).await; - assert_eq!(authority_revoked_query.status(), StatusCode::OK); - assert!( - route_candidates(authority_revoked_query, &directory, NOW) - .await - .candidates - .is_empty() + assert_eq!(authority_revoked_query.status(), StatusCode::FORBIDDEN); + assert_eq!( + error_code(authority_revoked_query).await, + "CredentialRevoked" ); let stale_directory_key = key(25); @@ -374,13 +367,8 @@ async fn directory_registration_fails_stale_and_revoked_but_query_health_remain_ ) .expect("stale query"); let stale_response = route_query(&stale_app, &stale_query).await; - assert_eq!(stale_response.status(), StatusCode::OK); - assert!( - route_candidates(stale_response, &stale_directory, NOW + 300_000) - .await - .candidates - .is_empty() - ); + assert_eq!(stale_response.status(), StatusCode::CONFLICT); + assert_eq!(error_code(stale_response).await, "RevocationStateStale"); } #[tokio::test] diff --git a/tests/invitation_store.rs b/tests/invitation_store.rs index cf0c7ce..6badf84 100644 --- a/tests/invitation_store.rs +++ b/tests/invitation_store.rs @@ -12,7 +12,7 @@ use agenet::{ BootstrapError, InvitationHandoff, InvitationPublicClaims, InvitationSpec, InvitationState, InvitationStore, ReservationStatus, display_invitation_handoff_to_tty, }, - protocol::{BootstrapProfile, CapabilityKind, DomainId, NodeId}, + protocol::{BootstrapProfile, CapabilityKind, DirectorySeed, DomainId, NodeId}, }; use proptest::prelude::*; use reqwest::Url; @@ -29,12 +29,19 @@ fn capability(value: &str) -> CapabilityKind { CapabilityKind::new(value).expect("test capability is valid") } +fn seed(endpoint: &str) -> DirectorySeed { + DirectorySeed { + endpoint: Url::parse(endpoint).expect("test URL is valid"), + node_id: NodeId::new("node:directory:test").expect("node id"), + } +} + fn spec() -> InvitationSpec { InvitationSpec { - protocol_version: "agenet.enrollment.v0.3".to_owned(), + protocol_version: "agenet.enrollment.v0.4".to_owned(), domain_id: DomainId::new("domain:test").expect("test domain is valid"), authority_endpoint: Url::parse("https://100.64.0.1:7443").expect("test URL is valid"), - directory_seeds: vec![Url::parse("https://100.64.0.1:7444").expect("test URL is valid")], + directory_seeds: vec![seed("https://100.64.0.1:7444")], network_kind: agenet::bootstrap::network::OverlayKind::Tailscale, allowed_cidrs: vec!["100.64.0.0/10".parse().expect("CIDR")], root_sha256: "11".repeat(32), @@ -177,7 +184,7 @@ fn record_debug_redacts_persisted_authenticator() { fn every_tampered_security_claim_is_rejected_before_reservation() { let mutations: &[ClaimsMutation] = &[ ("protocol_version", |claims| { - claims.protocol_version = "agenet.enrollment.v0.4".to_owned(); + claims.protocol_version = "agenet.enrollment.v0.5".to_owned(); }), ("domain_id", |claims| { claims.domain_id = DomainId::new("domain:attacker").expect("test domain is valid"); @@ -187,8 +194,11 @@ fn every_tampered_security_claim_is_rejected_before_reservation() { Url::parse("https://100.64.0.99:7443/").expect("test URL is valid"); }), ("directory_seeds", |claims| { - claims.directory_seeds = - vec![Url::parse("https://100.64.0.99:7444/").expect("test URL is valid")]; + claims.directory_seeds = vec![seed("https://100.64.0.99:7444/")]; + }), + ("directory_seed_node_id", |claims| { + claims.directory_seeds[0].node_id = + NodeId::new("node:directory:attacker").expect("node id"); }), ("network_kind", |claims| { claims.network_kind = agenet::bootstrap::network::OverlayKind::WireGuard; @@ -594,7 +604,7 @@ fn public_claim_urls_reject_credentials_and_ambient_url_components() { } let mut ipv6 = spec(); ipv6.authority_endpoint = Url::parse("https://[fd00::1]:7443/").expect("test IPv6 URL parses"); - ipv6.directory_seeds = vec![Url::parse("https://[fd00::1]:7444/").expect("IPv6 URL")]; + ipv6.directory_seeds = vec![seed("https://[fd00::1]:7444/")]; ipv6.network_kind = agenet::bootstrap::network::OverlayKind::WireGuard; ipv6.allowed_cidrs = vec!["fd00::/64".parse().expect("CIDR")]; assert!(store.create(ipv6, NOW_MS).is_ok()); @@ -725,15 +735,15 @@ fn replay_fails_closed_on_torn_corrupt_oversized_or_unsafe_state() { } #[test] -fn journal_emits_v3_and_rejects_legacy_or_future_versions() { +fn journal_emits_v4_and_rejects_legacy_or_future_versions() { const HEADER_PREFIX: &[u8] = b"AGENET-INVITATION-JOURNAL\0"; - for unsupported in [2u8, 4u8] { + for unsupported in [3u8, 5u8] { let temp = TempDir::new().expect("temporary directory is created"); drop(create_store(&temp)); let path = temp.path().join(JOURNAL_FILE); let mut bytes = fs::read(&path).expect("journal is readable"); assert_eq!(&bytes[..HEADER_PREFIX.len()], HEADER_PREFIX); - assert_eq!(bytes[HEADER_PREFIX.len()], 3, "new journal must emit v3"); + assert_eq!(bytes[HEADER_PREFIX.len()], 4, "new journal must emit v4"); bytes[HEADER_PREFIX.len()] = unsupported; fs::write(&path, bytes).expect("test journal version is changed"); assert!(matches!( diff --git a/tests/multiprocess_demo.rs b/tests/multiprocess_demo.rs index 120dcb1..354a7eb 100644 --- a/tests/multiprocess_demo.rs +++ b/tests/multiprocess_demo.rs @@ -1,4 +1,9 @@ -use std::{collections::HashSet, fs, path::Path, process::Command}; +use std::{ + collections::HashSet, + fs, + path::Path, + process::{Command, Stdio}, +}; use agenet::{demo::DemoSummary, protocol::ContractState}; use axum::{Json, Router, routing::post}; @@ -53,12 +58,34 @@ async fn demo_runs_four_real_processes_and_reaches_verified_acceptance() { .unwrap(); llm.abort(); + if !output.status.success() { + let runs = temp.path().join("runs"); + if let Ok(entries) = fs::read_dir(&runs) { + for entry in entries.flatten() { + if let Ok(nodes) = fs::read_dir(entry.path()) { + for node in nodes.flatten() { + for name in ["stdout.log", "stderr.log"] { + if let Ok(contents) = fs::read_to_string(node.path().join(name)) { + eprintln!("{} {name}: {contents}", node.path().display()); + } + } + } + } + } + } + } + assert!( output.status.success(), "demo stderr: {}", String::from_utf8_lossy(&output.stderr) ); let summary: DemoSummary = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(summary.environment, "loopback_harness"); + assert!(matches!( + summary.network_surface.as_str(), + "simulated_loopback_aliases_mtls" | "loopback_ports_mtls" + )); assert_eq!(summary.participants.len(), 4); assert_eq!( summary.pursuit.state_path, @@ -113,6 +140,10 @@ async fn demo_runs_four_real_processes_and_reaches_verified_acceptance() { assert!(!ready_text.contains("verifier")); for node in &summary.participants { + assert!(node.address.starts_with("https://")); + assert!(node.state_dir.join("peer-certificate-v1.pem").exists()); + assert!(node.state_dir.join("peer-private-key-v1.pem").exists()); + assert!(node.state_dir.join("authority-ca-v1.pem").exists()); assert!(node.state_dir.join("journal.jsonl").exists()); let stdout = fs::read_to_string(node.state_dir.join("stdout.log")).unwrap(); let stderr = fs::read_to_string(node.state_dir.join("stderr.log")).unwrap(); @@ -134,6 +165,8 @@ fn process_exists(pid: u32) -> bool { Command::new("/bin/kill") .arg("-0") .arg(pid.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) .status() .is_ok_and(|status| status.success()) } diff --git a/tests/network_boundary.rs b/tests/network_boundary.rs index d84fe02..8ec6055 100644 --- a/tests/network_boundary.rs +++ b/tests/network_boundary.rs @@ -324,7 +324,11 @@ async fn node_rejects_non_loopback_before_startup_side_effects() { root_public_key_file: root.path().join("missing-root.pub"), ready_file: ready_file.clone(), directory_seed: None, + directory_node_id: None, control_token_file: None, + tls_certificate_file: root.path().join("missing-cert.pem"), + tls_private_key_file: root.path().join("missing-tls-key.pem"), + authority_ca_file: root.path().join("missing-ca.pem"), network: boundary(OverlayKind::WireGuard, "10.23.0.7", &["10.23.0.0/24"]), }) .await; @@ -347,7 +351,11 @@ async fn node_validates_loopback_boundary_before_startup_side_effects() { root_public_key_file: root.path().join("missing-root.pub"), ready_file: ready_file.clone(), directory_seed: None, + directory_node_id: None, control_token_file: None, + tls_certificate_file: root.path().join("missing-cert.pem"), + tls_private_key_file: root.path().join("missing-tls-key.pem"), + authority_ca_file: root.path().join("missing-ca.pem"), network: boundary(OverlayKind::Loopback, "127.0.0.1", &["10.23.0.0/24"]), }) .await; From 4895eaa00ee6b3df4dc676e95080fa924aae41be Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 05:24:02 +0800 Subject: [PATCH 36/67] [bug] Enforce live effect authorization Root cause: Host runtime froze authorization at startup time. Solution: Recheck live identity, contract, grant, and revocation policy at every final effect boundary. Risks: Revocation convergence remains local-cache bounded. Dependency: e6ecb544069bb6c81007243f9a00e500561ca2e5 Links: plan/01-v2-multi-host-node-bootstrap.md Post-mortem: Trace detached work through its final observable effect. --- ROADMAP.md | 9 + docs/design/agenet-v0.1.md | 8 +- plan/01-v2-multi-host-node-bootstrap.md | 11 + src/node.rs | 51 +- src/protocol/sealed_contract.rs | 12 +- src/runtime/artifact_access.rs | 113 +++- src/runtime/host.rs | 13 +- src/runtime/provider.rs | 66 ++- src/runtime/recorder.rs | 130 ++++- src/runtime/requester.rs | 45 +- src/transport/client.rs | 38 +- tests/host_runtime.rs | 658 +++++++++++++++++++++++- 12 files changed, 1061 insertions(+), 93 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e934df8..e412645 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Task 12 review: live authorization at effect boundaries + +- **Change**: Replaced HostRuntime's startup-frozen Recorder and Artifact clocks with the same injected live clock used by its identity, added current revocation checks for both Contract participants and each Event issuer, and required Provider, Requester, and PeerClient to revalidate local authority immediately before effects. +- **Files**: `src/protocol/sealed_contract.rs`, runtime Recorder/Artifact/Provider/Requester/Host modules, peer client, node harness wiring, focused host-runtime tests, design, and the Task 12 amendment. +- **Root cause / classification**: **技术盲区**. The first Task 12 implementation correctly used a live clock at HTTP envelope admission but converted it to a fixed startup timestamp when constructing Recorder and Artifact services. A detached Provider task could therefore continue after credential expiry or a new revocation snapshot. +- **Solution**: Contract registration, query, and Event append now verify signed participants under the Recorder mutation lock using the current clock and latest cache; Artifact authorization and the final byte-read boundary do the same while tracking only successful reads. Detached execution terminates without writing `Failed` when local policy becomes invalid. Peer send counters advance only after local live-policy validation. +- **Prevention**: Security review must trace every asynchronous effect from HTTP admission through delayed work and its final durable/network/read boundary. Tests advance a shared clock and replace the live revocation snapshot after startup, then compare exact journal bytes and read/request counters rather than mirroring policy with test-only sets. +- **Consistency boundary**: Policy recheck and journal mutation are serialized within one process and one cache view. Distributed revocation propagation is not atomic with an already-running operation; a stale or revoked local cache fails closed at each subsequent effect boundary. + ## 2026-08-15 — Task 12 host runtime, exact Directory identity, and mTLS loopback flow - **Change**: Attached the validated v0.2 host runtime to `node service-run`, migrated Directory seeds and enrollment artifacts to exact endpoint/NodeId pairs, added request-time clock/revocation enforcement, role/ceiling-safe Provider dispatch, readiness lifecycle, and an HTTPS/mTLS four-process loopback flow. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 9ce0c9f..6b37700 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -9,10 +9,16 @@ AgenNet is a coordination substrate in which capabilities, intents, grants, cont - Heterogeneous Agents and deterministic resources are first-class participants. - Directory and Router return candidates; the Requester chooses and signs. - Every effect is authorized by deterministic Grant checks. +- Every delayed effect revalidates local identity, signed Contract parties, + Grant/Contract expiry, and the latest local revocation cache at its final + journal, network-send, or Artifact-read boundary. - Contracts are persistent and event-sourced; connections are transient. - `Delivered` and `Accepted` are different states. - Results carry evidence bound to immutable Artifact references. -- The protocol does not promise general exactly-once side effects. +- The protocol does not promise general exactly-once side effects. Revocation + propagation is not globally atomic: Recorder serializes a final check with + local journal mutation, Artifact access checks immediately before bytes are + read, and stale local policy fails closed. ## Loopback MVP diff --git a/plan/01-v2-multi-host-node-bootstrap.md b/plan/01-v2-multi-host-node-bootstrap.md index ef32155..df63161 100644 --- a/plan/01-v2-multi-host-node-bootstrap.md +++ b/plan/01-v2-multi-host-node-bootstrap.md @@ -94,3 +94,14 @@ without explicit secure local-control/model configuration remains a healthy base runtime with pursuits disabled. All request-time credential and revocation checks use an injected live clock. The demo is an ephemeral loopback harness; its alias/port mTLS evidence cannot replace Task 14 physical-device acceptance. + +Task 12 review clarified that request-time includes work detached after HTTP +admission. HostRuntime passes one live clock and one mutable revocation-cache +view into identity, Recorder, Artifact access, Provider, Requester, and outbound +peer policy. Recorder reopens the exact Event envelope and revalidates the +stored signed Contract under its mutation lock immediately before append. +Artifact access revalidates at authorization and immediately before the +content-addressed read. If credential, Contract, Grant, Authority, node, or +snapshot freshness becomes invalid, delayed work stops without adding a +`Failed` Event or advancing read/network counters. This is a local +serialization guarantee, not globally atomic revocation propagation. diff --git a/src/node.rs b/src/node.rs index a1bd2a1..3824bc1 100644 --- a/src/node.rs +++ b/src/node.rs @@ -80,7 +80,8 @@ pub async fn run(options: NodeOptions) -> Result { ensure_transport_available(&options.network)?; validate_network_boundary(options.network.clone()).await?; fs::create_dir_all(&options.state_dir).map_err(sanitized)?; - let identity = load_identity(&options)?; + let clock: Arc = Arc::new(SystemClock); + let identity = load_identity(&options, Arc::clone(&clock))?; if identity.role() != options.profile.role() { return Err("CredentialRoleMismatch".to_owned()); } @@ -102,11 +103,12 @@ pub async fn run(options: NodeOptions) -> Result { ); let now = u64::try_from(identity.now_ms()).unwrap_or(u64::MAX); let recorder = Arc::new( - ContractRecorder::open( + ContractRecorder::open_with_policy( &options.state_dir, *identity.root(), identity.domain_id().clone(), - now, + Arc::clone(&clock), + RevocationGuard::new(revocations.as_ref().clone()), ) .await .map_err(sanitized)?, @@ -126,17 +128,22 @@ pub async fn run(options: NodeOptions) -> Result { let client = PeerClient::new_mtls_dynamic( *identity.root(), identity.domain_id().clone(), - Arc::new(SystemClock), + Arc::clone(&clock), options.network.clone(), tls_identity.clone(), ) + .map_err(sanitized)? + .with_local_policy( + identity.clone(), + RevocationGuard::new(revocations.as_ref().clone()), + ) .map_err(sanitized)?; - let service = ProviderService::new( + let service = ProviderService::new_for_roles_with_policy( identity.clone(), recorder, client.clone(), - options.profile.role(), - now, + std::collections::BTreeSet::from([options.profile.role()]), + RevocationGuard::new(revocations.as_ref().clone()), ) .map_err(sanitized)?; ( @@ -159,19 +166,25 @@ pub async fn run(options: NodeOptions) -> Result { .to_owned(); let store = ArtifactStore::open(&options.state_dir, identity.node_id().clone()) .map_err(sanitized)?; - let access = ArtifactAccessService::new( + let access = ArtifactAccessService::new_with_policy( store.clone(), *identity.root(), identity.domain_id().clone(), - now, + Arc::clone(&clock), + RevocationGuard::new(revocations.as_ref().clone()), ); let client = PeerClient::new_mtls_dynamic( *identity.root(), identity.domain_id().clone(), - Arc::new(SystemClock), + Arc::clone(&clock), options.network.clone(), tls_identity.clone(), ) + .map_err(sanitized)? + .with_local_policy( + identity.clone(), + RevocationGuard::new(revocations.as_ref().clone()), + ) .map_err(sanitized)?; let decision = LlmDecisionAdapter::new_modelhub( &required_env("OPENAI_BASE_URL")?, @@ -179,7 +192,7 @@ pub async fn run(options: NodeOptions) -> Result { &required_env("VLM_MODEL")?, ) .map_err(sanitized)?; - let service = RequesterService::new_with_directory_seed( + let service = RequesterService::new_with_policy( identity.clone(), store, access, @@ -187,6 +200,7 @@ pub async fn run(options: NodeOptions) -> Result { decision, directory, endpoint.clone(), + RevocationGuard::new(revocations.as_ref().clone()), ) .map_err(sanitized)?; ( @@ -279,7 +293,10 @@ fn ensure_transport_available(boundary: &NetworkBoundary) -> Result<(), String> Ok(()) } -fn load_identity(options: &NodeOptions) -> Result { +fn load_identity( + options: &NodeOptions, + clock: Arc, +) -> Result { let signing_key = read_signing_key(&options.key_file).map_err(sanitized)?; let credential: CredentialChain = serde_json::from_slice(&fs::read(&options.credential_file).map_err(sanitized)?) @@ -295,14 +312,8 @@ fn load_identity(options: &NodeOptions) -> Result { .try_into() .map_err(|_| "InvalidRootPublicKey".to_owned())?; let root = VerifyingKey::from_bytes(&root_array).map_err(sanitized)?; - NodeIdentity::new_with_clock( - signing_key, - credential, - options.profile.role(), - root, - Arc::new(SystemClock), - ) - .map_err(sanitized) + NodeIdentity::new_with_clock(signing_key, credential, options.profile.role(), root, clock) + .map_err(sanitized) } fn load_tls_identity(options: &NodeOptions, node_id: NodeId) -> Result { diff --git a/src/protocol/sealed_contract.rs b/src/protocol/sealed_contract.rs index 1d3938a..7768f0c 100644 --- a/src/protocol/sealed_contract.rs +++ b/src/protocol/sealed_contract.rs @@ -109,6 +109,16 @@ impl SealedContract { expected_domain: &DomainId, now_unix_ms: u64, ) -> Result { + self.verify_with_participants(root, expected_domain, now_unix_ms) + .map(|(draft, _, _)| draft) + } + + pub(crate) fn verify_with_participants( + &self, + root: &VerifyingKey, + expected_domain: &DomainId, + now_unix_ms: u64, + ) -> Result<(ContractDraft, VerifiedNodeClaims, VerifiedNodeClaims), ProtocolError> { let draft_bytes = decode_draft_bytes(&self.draft_payload_base64)?; let draft: ContractDraft = serde_json::from_slice(&draft_bytes).map_err(|_| ProtocolError::SerializationFailed)?; @@ -131,7 +141,7 @@ impl SealedContract { if requester.node_id != draft.requester || provider.node_id != draft.provider { return Err(ProtocolError::InvalidContractSignature); } - Ok(draft) + Ok((draft, requester, provider)) } } diff --git a/src/runtime/artifact_access.rs b/src/runtime/artifact_access.rs index dab3523..a859307 100644 --- a/src/runtime/artifact_access.rs +++ b/src/runtime/artifact_access.rs @@ -1,4 +1,10 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; use ed25519_dalek::VerifyingKey; use tokio::sync::RwLock; @@ -7,7 +13,7 @@ use crate::protocol::{ ArtifactPayload, ArtifactReadRequest, ContractId, DomainId, NodeId, SealedContract, }; -use super::{ArtifactStore, RuntimeError}; +use super::{ArtifactStore, RevocationGuard, RuntimeError}; #[derive(Clone)] pub struct ArtifactAccessService { @@ -15,7 +21,9 @@ pub struct ArtifactAccessService { root: VerifyingKey, domain_id: DomainId, clock: Arc, + revocations: Option, contracts: Arc>>, + successful_reads: Arc, } impl ArtifactAccessService { @@ -32,18 +40,47 @@ impl ArtifactAccessService { clock: Arc::new(super::FixedClock::new( i64::try_from(validation_time_unix_ms).unwrap_or(i64::MAX), )), + revocations: None, + contracts: Arc::new(RwLock::new(HashMap::new())), + successful_reads: Arc::new(AtomicU64::new(0)), + } + } + + pub fn new_with_clock( + store: ArtifactStore, + root: VerifyingKey, + domain_id: DomainId, + clock: Arc, + ) -> Self { + Self { + store, + root, + domain_id, + clock, + revocations: None, contracts: Arc::new(RwLock::new(HashMap::new())), + successful_reads: Arc::new(AtomicU64::new(0)), } } + pub fn new_with_policy( + store: ArtifactStore, + root: VerifyingKey, + domain_id: DomainId, + clock: Arc, + revocations: RevocationGuard, + ) -> Self { + let mut service = Self::new_with_clock(store, root, domain_id, clock); + service.revocations = Some(revocations); + service + } + pub async fn authorize(&self, contract: SealedContract) -> Result<(), RuntimeError> { - let now = u64::try_from(self.clock.now_ms()) - .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; - let draft = contract.verify(&self.root, &self.domain_id, now)?; - self.contracts - .write() - .await - .insert(draft.contract_id, contract); + let mut contracts = self.contracts.write().await; + let (draft, requester, provider) = self.verify_contract(&contract)?; + self.verify_claims(&requester)?; + self.verify_claims(&provider)?; + contracts.insert(draft.contract_id, contract); Ok(()) } @@ -56,19 +93,67 @@ impl ArtifactAccessService { let contract = contracts .get(&request.contract_id) .ok_or(RuntimeError::ArtifactAccessDenied)?; - let now = u64::try_from(self.clock.now_ms()) - .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; - let draft = contract.verify(&self.root, &self.domain_id, now)?; + let (draft, requester, provider) = self.verify_contract(contract)?; + self.verify_claims(&requester)?; + self.verify_claims(&provider)?; + let now = self.now()?; draft .grant .allows(caller, &draft.capability_id, &request.artifact_id, now)?; if request.artifact_id != draft.artifact.artifact_id { return Err(RuntimeError::ArtifactAccessDenied); } - let bytes = self.store.read(&draft.artifact)?; + // This is the final policy check before observable artifact access. The + // contract read lock prevents authorization replacement in this process. + let (current, current_requester, current_provider) = self.verify_contract(contract)?; + self.verify_claims(¤t_requester)?; + self.verify_claims(¤t_provider)?; + current.grant.allows( + caller, + ¤t.capability_id, + &request.artifact_id, + self.now()?, + )?; + let bytes = self.store.read(¤t.artifact)?; + self.successful_reads.fetch_add(1, Ordering::SeqCst); Ok(ArtifactPayload { - artifact: draft.artifact, + artifact: current.artifact, bytes_base64: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes), }) } + + pub fn successful_reads(&self) -> u64 { + self.successful_reads.load(Ordering::SeqCst) + } + + fn now(&self) -> Result { + u64::try_from(self.clock.now_ms()) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired.into()) + } + + fn verify_contract( + &self, + contract: &SealedContract, + ) -> Result< + ( + crate::protocol::ContractDraft, + crate::protocol::VerifiedNodeClaims, + crate::protocol::VerifiedNodeClaims, + ), + RuntimeError, + > { + contract + .verify_with_participants(&self.root, &self.domain_id, self.now()?) + .map_err(RuntimeError::from) + } + + fn verify_claims( + &self, + claims: &crate::protocol::VerifiedNodeClaims, + ) -> Result<(), RuntimeError> { + if let Some(guard) = &self.revocations { + guard.effectful_verified_claims(self.clock.now_ms(), claims)?; + } + Ok(()) + } } diff --git a/src/runtime/host.rs b/src/runtime/host.rs index 7318e3c..a97638a 100644 --- a/src/runtime/host.rs +++ b/src/runtime/host.rs @@ -316,22 +316,27 @@ impl HostRuntime { Arc::clone(&self.clock), self.bundle.config.network.clone(), self.bundle.tls_identity.clone(), + )? + .with_local_policy( + identity.clone(), + RevocationGuard::new(self.revocations.as_ref().clone()), )?; let recorder = Arc::new( - ContractRecorder::open( + ContractRecorder::open_with_policy( &paths.state_dir, self.root, self.bundle.config.domain_id.clone(), - u64::try_from(self.clock.now_ms()).unwrap_or(u64::MAX), + Arc::clone(&self.clock), + RevocationGuard::new(self.revocations.as_ref().clone()), ) .await?, ); - let service = ProviderService::new_for_roles( + let service = ProviderService::new_for_roles_with_policy( identity.clone(), recorder, client.clone(), provider_roles.clone(), - u64::try_from(self.clock.now_ms()).unwrap_or(u64::MAX), + RevocationGuard::new(self.revocations.as_ref().clone()), )?; Ok(( provider_router_with_revocation( diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs index bdf9fb1..4092764 100644 --- a/src/runtime/provider.rs +++ b/src/runtime/provider.rs @@ -12,7 +12,7 @@ use crate::{ transport::{PeerClient, TransportError}, }; -use super::{ContractRecorder, NodeIdentity, RuntimeError}; +use super::{ContractRecorder, NodeIdentity, RevocationGuard, RuntimeError}; #[derive(Clone)] pub struct ProviderService { @@ -20,6 +20,7 @@ pub struct ProviderService { recorder: Arc, client: PeerClient, roles: BTreeSet, + revocations: Option, } impl ProviderService { @@ -62,9 +63,23 @@ impl ProviderService { recorder, client, roles, + revocations: None, }) } + pub fn new_for_roles_with_policy( + identity: NodeIdentity, + recorder: Arc, + client: PeerClient, + roles: BTreeSet, + revocations: RevocationGuard, + ) -> Result { + let mut service = Self::new_for_roles(identity, recorder, client, roles, 0)?; + service.revocations = Some(revocations); + service.verify_local_role(service.identity.role())?; + Ok(service) + } + pub fn identity(&self) -> &NodeIdentity { &self.identity } @@ -84,8 +99,9 @@ impl ProviderService { return Err(RuntimeError::ArtifactAccessDenied); } let authorized_role = self.authorized_contract_role(&draft)?; - self.identity.revalidate(authorized_role)?; + self.verify_local_role(authorized_role)?; let sealed = self.identity.countersign_contract(request.offer.clone())?; + self.verify_local_role(authorized_role)?; self.recorder.register_contract(sealed.clone()).await?; let service = self.clone(); tokio::spawn(async move { @@ -118,12 +134,14 @@ impl ProviderService { request: ContractProposeRequest, ) -> Result<(), RuntimeError> { tokio::time::sleep(Duration::from_millis(100)).await; + self.verify_draft_effect(&draft)?; self.append_provider_event( &draft.contract_id, EventKind::Activated, serde_json::json!({}), ) .await?; + self.verify_draft_effect(&draft)?; self.append_provider_event( &draft.contract_id, EventKind::Started, @@ -149,11 +167,13 @@ impl ProviderService { draft: &ContractDraft, request: &ContractProposeRequest, ) -> Result { + self.verify_draft_effect(draft)?; let read_request = ArtifactReadRequest { contract_id: draft.contract_id.clone(), artifact_id: draft.artifact.artifact_id.clone(), }; let read_envelope = self.identity.seal("artifact.read.v1", &read_request)?; + self.verify_draft_effect(draft)?; let artifact: ArtifactPayload = self .client .post_signed_peer_or_loopback( @@ -166,6 +186,7 @@ impl ProviderService { ) .await .map_err(map_transport)?; + self.verify_draft_effect(draft)?; let bytes = STANDARD .decode(&artifact.bytes_base64) .map_err(|_| RuntimeError::ArtifactIntegrityMismatch)?; @@ -194,7 +215,7 @@ impl ProviderService { capability_version: draft.capability_id.as_str().to_owned(), metrics, producer: self.identity.node_id().clone(), - executed_at_unix_ms: unix_ms(), + executed_at_unix_ms: u64::try_from(self.identity.now_ms()).unwrap_or(u64::MAX), }) } @@ -224,6 +245,8 @@ impl ProviderService { contract_id: &ContractId, error: RuntimeError, ) -> Result<(), RuntimeError> { + let projection = self.recorder.projection(contract_id).await?; + self.verify_draft_effect(&projection.draft)?; let _ = self .append_provider_event( contract_id, @@ -241,6 +264,7 @@ impl ProviderService { payload: serde_json::Value, ) -> Result { let projection = self.recorder.projection(contract_id).await?; + self.verify_draft_effect(&projection.draft)?; let event = ContractEvent { contract_id: contract_id.clone(), event_id: format!("event:{}", uuid::Uuid::new_v4()), @@ -250,20 +274,40 @@ impl ProviderService { issuer: self.identity.node_id().clone(), kind, payload, - occurred_at_unix_ms: unix_ms(), + occurred_at_unix_ms: u64::try_from(self.identity.now_ms()).unwrap_or(u64::MAX), }; + self.verify_draft_effect(&projection.draft)?; let envelope = self.identity.seal("contract.event.v1", &event)?; + self.verify_draft_effect(&projection.draft)?; self.recorder.append_event(envelope).await } + + fn verify_draft_effect(&self, draft: &ContractDraft) -> Result<(), RuntimeError> { + let role = self.authorized_contract_role(draft)?; + self.verify_local_role(role)?; + let now = u64::try_from(self.identity.now_ms()) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; + draft.grant.allows( + self.identity.node_id(), + &draft.capability_id, + &draft.artifact.artifact_id, + now, + )?; + if now > draft.expires_at_unix_ms { + return Err(crate::protocol::ProtocolError::GrantExpired.into()); + } + Ok(()) + } + + fn verify_local_role(&self, role: NodeRole) -> Result<(), RuntimeError> { + let claims = self.identity.revalidate(role)?; + if let Some(guard) = &self.revocations { + guard.effectful_verified_claims(self.identity.now_ms(), &claims)?; + } + Ok(()) + } } fn map_transport(_: TransportError) -> RuntimeError { RuntimeError::ArtifactAccessDenied } - -fn unix_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} diff --git a/src/runtime/recorder.rs b/src/runtime/recorder.rs index ac88142..204d1ba 100644 --- a/src/runtime/recorder.rs +++ b/src/runtime/recorder.rs @@ -15,7 +15,7 @@ use crate::protocol::{ WireEnvelope, apply_event, }; -use super::{Clock, FixedClock, RuntimeError}; +use super::{Clock, FixedClock, RevocationGuard, RuntimeError}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "entry_type", rename_all = "snake_case")] @@ -27,12 +27,14 @@ enum JournalEntry { struct RecorderState { journal: File, projections: HashMap, + contracts: HashMap, } pub struct ContractRecorder { root: VerifyingKey, domain_id: DomainId, clock: Arc, + revocations: Option, state: Mutex, } @@ -42,6 +44,44 @@ impl ContractRecorder { root: VerifyingKey, domain_id: DomainId, validation_time_unix_ms: u64, + ) -> Result { + let now = i64::try_from(validation_time_unix_ms) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; + Self::open_inner( + state_directory, + root, + domain_id, + Arc::new(FixedClock::new(now)), + None, + ) + .await + } + + pub async fn open_with_clock( + state_directory: &Path, + root: VerifyingKey, + domain_id: DomainId, + clock: Arc, + ) -> Result { + Self::open_inner(state_directory, root, domain_id, clock, None).await + } + + pub async fn open_with_policy( + state_directory: &Path, + root: VerifyingKey, + domain_id: DomainId, + clock: Arc, + revocations: RevocationGuard, + ) -> Result { + Self::open_inner(state_directory, root, domain_id, clock, Some(revocations)).await + } + + async fn open_inner( + state_directory: &Path, + root: VerifyingKey, + domain_id: DomainId, + clock: Arc, + revocations: Option, ) -> Result { fs::create_dir_all(state_directory)?; let journal_path = state_directory.join("journal.jsonl"); @@ -50,10 +90,10 @@ impl ContractRecorder { } else { String::new() }; - let now = i64::try_from(validation_time_unix_ms) + let validation_time_unix_ms = u64::try_from(clock.now_ms()) .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; - let clock: Arc = Arc::new(FixedClock::new(now)); let mut projections = HashMap::new(); + let mut contracts = HashMap::new(); for line in contents.lines() { let entry: JournalEntry = serde_json::from_str(line)?; replay_entry( @@ -62,6 +102,7 @@ impl ContractRecorder { &domain_id, validation_time_unix_ms, &mut projections, + &mut contracts, )?; } let journal = OpenOptions::new() @@ -73,9 +114,11 @@ impl ContractRecorder { root, domain_id, clock, + revocations, state: Mutex::new(RecorderState { journal, projections, + contracts, }), }) } @@ -84,17 +127,21 @@ impl ContractRecorder { &self, contract: SealedContract, ) -> Result { - let now = u64::try_from(self.clock.now_ms()) - .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; - let draft = contract.verify(&self.root, &self.domain_id, now)?; let mut state = self.state.lock().await; + let (draft, _, _) = self.verify_contract(&contract)?; if let Some(existing) = state.projections.get(&draft.contract_id) { if existing.draft == draft { return Ok(existing.state); } return Err(RuntimeError::ContractAlreadyExists); } - append_entry(&mut state.journal, &JournalEntry::Contract { contract })?; + append_entry( + &mut state.journal, + &JournalEntry::Contract { + contract: contract.clone(), + }, + )?; + state.contracts.insert(draft.contract_id.clone(), contract); state .projections .insert(draft.contract_id.clone(), ContractProjection::new(draft)); @@ -105,14 +152,17 @@ impl ContractRecorder { &self, envelope: WireEnvelope, ) -> Result { - let event: ContractEvent = envelope.open( + let mut state = self.state.lock().await; + let opened = envelope.open_with_verified_claims::( "contract.event.v1", &self.root, &self.domain_id, crate::protocol::NodeRole::Requester, self.clock.now_ms(), )?; - let mut state = self.state.lock().await; + let (event, issuer) = opened.into_parts(); + self.verify_claims(&issuer)?; + self.verify_stored_contract(&state, &event.contract_id)?; let projection = state .projections .get(&event.contract_id) @@ -139,6 +189,19 @@ impl ContractRecorder { event: ContractEvent, ) -> Result { let mut state = self.state.lock().await; + let opened_envelope = envelope.open_with_verified_claims::( + "contract.event.v1", + &self.root, + &self.domain_id, + crate::protocol::NodeRole::Requester, + self.clock.now_ms(), + )?; + let (opened, issuer): (ContractEvent, _) = opened_envelope.into_parts(); + if opened != event { + return Err(crate::protocol::ProtocolError::InvalidEnvelopeSignature.into()); + } + self.verify_claims(&issuer)?; + self.verify_stored_contract(&state, &event.contract_id)?; let projection = state .projections .get(&event.contract_id) @@ -163,14 +226,55 @@ impl ContractRecorder { &self, contract_id: &ContractId, ) -> Result { - self.state - .lock() - .await + let state = self.state.lock().await; + self.verify_stored_contract(&state, contract_id)?; + state .projections .get(contract_id) .cloned() .ok_or(RuntimeError::UnknownContract) } + + fn verify_contract( + &self, + contract: &SealedContract, + ) -> Result< + ( + crate::protocol::ContractDraft, + crate::protocol::VerifiedNodeClaims, + crate::protocol::VerifiedNodeClaims, + ), + RuntimeError, + > { + let now = u64::try_from(self.clock.now_ms()) + .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?; + let verified = contract.verify_with_participants(&self.root, &self.domain_id, now)?; + self.verify_claims(&verified.1)?; + self.verify_claims(&verified.2)?; + Ok(verified) + } + + fn verify_claims( + &self, + claims: &crate::protocol::VerifiedNodeClaims, + ) -> Result<(), RuntimeError> { + if let Some(guard) = &self.revocations { + guard.effectful_verified_claims(self.clock.now_ms(), claims)?; + } + Ok(()) + } + + fn verify_stored_contract( + &self, + state: &RecorderState, + contract_id: &ContractId, + ) -> Result<(), RuntimeError> { + let contract = state + .contracts + .get(contract_id) + .ok_or(RuntimeError::UnknownContract)?; + self.verify_contract(contract).map(|_| ()) + } } fn append_entry(journal: &mut File, entry: &JournalEntry) -> Result<(), RuntimeError> { @@ -187,6 +291,7 @@ fn replay_entry( domain_id: &DomainId, validation_time_unix_ms: u64, projections: &mut HashMap, + contracts: &mut HashMap, ) -> Result<(), RuntimeError> { match entry { JournalEntry::Contract { contract } => { @@ -194,6 +299,7 @@ fn replay_entry( if projections.contains_key(&draft.contract_id) { return Err(RuntimeError::ContractAlreadyExists); } + contracts.insert(draft.contract_id.clone(), contract); projections.insert(draft.contract_id.clone(), ContractProjection::new(draft)); } JournalEntry::Event { envelope } => { diff --git a/src/runtime/requester.rs b/src/runtime/requester.rs index cdef66f..1773fd3 100644 --- a/src/runtime/requester.rs +++ b/src/runtime/requester.rs @@ -19,7 +19,7 @@ use crate::{ transport::{HttpStats, PeerClient}, }; -use super::{ArtifactAccessService, ArtifactStore, NodeIdentity, RuntimeError}; +use super::{ArtifactAccessService, ArtifactStore, NodeIdentity, RevocationGuard, RuntimeError}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PursuitRequest { @@ -57,6 +57,7 @@ pub struct RequesterService { directory_seed: DirectorySeed, artifact_endpoint: String, pursuits: Arc>>, + revocations: Option, } impl RequesterService { @@ -85,6 +86,7 @@ impl RequesterService { }, artifact_endpoint, pursuits: Arc::new(RwLock::new(HashMap::new())), + revocations: None, }) } @@ -110,9 +112,35 @@ impl RequesterService { directory_seed, artifact_endpoint, pursuits: Arc::new(RwLock::new(HashMap::new())), + revocations: None, }) } + #[allow(clippy::too_many_arguments)] + pub fn new_with_policy( + identity: NodeIdentity, + store: ArtifactStore, + access: ArtifactAccessService, + client: PeerClient, + decision: LlmDecisionAdapter, + directory_seed: DirectorySeed, + artifact_endpoint: String, + revocations: RevocationGuard, + ) -> Result { + let mut service = Self::new_with_directory_seed( + identity, + store, + access, + client, + decision, + directory_seed, + artifact_endpoint, + )?; + service.revocations = Some(revocations); + service.verify_local_effect()?; + Ok(service) + } + pub fn identity(&self) -> &NodeIdentity { &self.identity } @@ -122,6 +150,7 @@ impl RequesterService { } pub async fn pursue(&self, request: PursuitRequest) -> Result { + self.verify_local_effect()?; let started = Instant::now(); let bytes = STANDARD .decode(request.artifact_bytes_base64) @@ -221,6 +250,7 @@ impl RequesterService { } async fn route(&self, capability: &str) -> Result { + self.verify_local_effect()?; let envelope = self.identity.seal( "route.query.v1", &RouteQuery { @@ -284,6 +314,7 @@ impl RequesterService { draft: ContractDraft, expected_metrics: Option, ) -> Result<(ContractId, ContractProjection), RuntimeError> { + self.verify_local_effect()?; let contract_id = draft.contract_id.clone(); let request = ContractProposeRequest { offer: self.identity.create_contract_offer(&draft)?, @@ -291,6 +322,7 @@ impl RequesterService { expected_metrics, }; let envelope = self.identity.seal("contract.propose.v1", &request)?; + self.verify_local_effect()?; let response: ContractProposeResponse = self .client .post_signed_peer_or_loopback( @@ -308,6 +340,7 @@ impl RequesterService { self.identity.domain_id(), u64::try_from(self.identity.now_ms()).unwrap_or(u64::MAX), )?; + self.verify_local_effect()?; self.access.authorize(response.contract).await?; let projection = self .poll_delivered( @@ -365,6 +398,7 @@ impl RequesterService { verification_contract_id: &ContractId, evidence: &EvidenceClaim, ) -> Result { + self.verify_local_effect()?; let event = ContractEvent { contract_id: source.draft.contract_id.clone(), event_id: format!("event:{}", uuid::Uuid::new_v4()), @@ -380,6 +414,7 @@ impl RequesterService { occurred_at_unix_ms: u64::try_from(self.identity.now_ms()).unwrap_or(u64::MAX), }; let envelope = self.identity.seal("contract.event.v1", &event)?; + self.verify_local_effect()?; let response: serde_json::Value = self .client .post_signed_peer_or_loopback( @@ -397,6 +432,14 @@ impl RequesterService { } Ok(ContractState::Accepted) } + + fn verify_local_effect(&self) -> Result<(), RuntimeError> { + let claims = self.identity.revalidate(NodeRole::Requester)?; + if let Some(guard) = &self.revocations { + guard.effectful_verified_claims(self.identity.now_ms(), &claims)?; + } + Ok(()) + } } fn provider_role(manifest: &CapabilityManifest) -> Result { diff --git a/src/transport/client.rs b/src/transport/client.rs index fc0b766..c6d97e3 100644 --- a/src/transport/client.rs +++ b/src/transport/client.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use crate::{ bootstrap::network::NetworkBoundary, protocol::{DomainId, NodeId, NodeRole, WireEnvelope}, - runtime::{Clock, FixedClock}, + runtime::{Clock, FixedClock, NodeIdentity, RevocationGuard}, }; use super::MAX_JSON_BODY_BYTES; @@ -56,6 +56,7 @@ pub struct PeerClient { boundary: NetworkBoundary, expected_tls_peer: Option, dynamic_identity: Option>, + local_policy: Option<(NodeIdentity, RevocationGuard)>, } impl Debug for PeerClient { @@ -137,6 +138,7 @@ impl PeerClient { boundary, expected_tls_peer: None, dynamic_identity: None, + local_policy: None, }) } @@ -178,6 +180,7 @@ impl PeerClient { boundary, expected_tls_peer: Some(expected_peer), dynamic_identity: None, + local_policy: None, }) } @@ -205,9 +208,20 @@ impl PeerClient { boundary, expected_tls_peer: None, dynamic_identity: Some(Arc::new(identity)), + local_policy: None, }) } + pub fn with_local_policy( + mut self, + identity: NodeIdentity, + revocations: RevocationGuard, + ) -> Result { + validate_local_policy(&identity, &revocations)?; + self.local_policy = Some((identity, revocations)); + Ok(self) + } + pub async fn post_signed_to_peer( &self, endpoint: &str, @@ -217,6 +231,7 @@ impl PeerClient { expected_object_type: &str, expected_response_role: NodeRole, ) -> Result { + self.validate_local_policy()?; let identity = self .dynamic_identity .as_ref() @@ -249,6 +264,7 @@ impl PeerClient { expected_object_type: &str, expected_response_role: NodeRole, ) -> Result { + self.validate_local_policy()?; if self.dynamic_identity.is_some() { return self .post_signed_to_peer( @@ -271,6 +287,13 @@ impl PeerClient { .await } + fn validate_local_policy(&self) -> Result<(), TransportError> { + if let Some((identity, revocations)) = &self.local_policy { + validate_local_policy(identity, revocations)?; + } + Ok(()) + } + pub async fn post_signed( &self, endpoint: &str, @@ -279,6 +302,7 @@ impl PeerClient { expected_object_type: &str, expected_response_role: NodeRole, ) -> Result { + self.validate_local_policy()?; let url = endpoint_url(&self.boundary, endpoint, path)?; if !url_host_is_loopback(&url) && self.expected_tls_peer.is_none() { return Err(TransportError::UnsupportedInsecureTransport); @@ -389,6 +413,18 @@ fn url_host_is_loopback(url: &reqwest::Url) -> bool { .is_some_and(|address| address.is_loopback()) } +fn validate_local_policy( + identity: &NodeIdentity, + revocations: &RevocationGuard, +) -> Result<(), TransportError> { + let claims = identity + .revalidate(identity.role()) + .map_err(|_| TransportError::InvalidSignedResponse)?; + revocations + .effectful_verified_claims(identity.now_ms(), &claims) + .map_err(|_| TransportError::InvalidSignedResponse) +} + fn endpoint_url( boundary: &NetworkBoundary, endpoint: &str, diff --git a/tests/host_runtime.rs b/tests/host_runtime.rs index f888fcc..e138843 100644 --- a/tests/host_runtime.rs +++ b/tests/host_runtime.rs @@ -1,49 +1,651 @@ +mod common; + +use agenet::{ + protocol::{ + AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, AuthorityClaims, + AuthorityScope, BootstrapProfile, CapabilityId, ContractDraft, ContractEvent, ContractId, + ContractProposeRequest, EventKind, Grant, IntentId, NodeId, NodeRole, RevocationClaims, + RevocationSnapshot, SealedContract, SignedAuthorityCredential, WireEnvelope, + }, + runtime::{ + ArtifactAccessService, ArtifactStore, Clock, ContractRecorder, NodeIdentity, + ProviderService, RevocationCache, RevocationGuard, + }, + transport::PeerClient, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; use std::{ collections::BTreeSet, + fs, sync::{ Arc, atomic::{AtomicI64, Ordering}, }, }; -use agenet::{ - protocol::{NodeRole, verify_credential_chain}, - runtime::Clock, -}; +const NOW: i64 = 1_800_000_000; #[derive(Debug)] -struct AdvancingClock(AtomicI64); - -impl AdvancingClock { - fn new(now_ms: i64) -> Self { - Self(AtomicI64::new(now_ms)) +struct ManualClock(AtomicI64); +impl Clock for ManualClock { + fn now_ms(&self) -> i64 { + self.0.load(Ordering::SeqCst) } +} +fn key(byte: u8) -> SigningKey { + SigningKey::from_bytes(&[byte; 32]) +} + +fn publisher_credential(root: &SigningKey) -> SignedAuthorityCredential { + let authority = key(98); + SignedAuthorityCredential::issue( + root, + AuthorityClaims { + domain_id: common::domain_id(), + authority_id: NodeId::new("authority:test").unwrap(), + signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([AuthorityScope::PublishRevocationSnapshot]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Base]), + maximum_node_lifetime_ms: 61_000, + issued_at_ms: NOW - 2_000, + expires_at_ms: NOW + 120_000, + }, + ) + .unwrap() +} + +fn snapshot( + root: &SigningKey, + epoch: u64, + generated_at_ms: i64, + next_update_ms: i64, + revoked_authorities: BTreeSet, + revoked_nodes: BTreeSet, +) -> RevocationSnapshot { + let authority = key(98); + RevocationSnapshot::sign( + publisher_credential(root), + &authority, + RevocationClaims { + format_version: "agenet.revocation-snapshot.v0.2".to_owned(), + domain_id: common::domain_id(), + issuer_id: NodeId::new("authority:test").unwrap(), + epoch, + generated_at_ms, + next_update_ms, + revoked_authorities, + revoked_nodes, + }, + &root.verifying_key(), + generated_at_ms, + ) + .unwrap() +} + +fn contract(root: &SigningKey, requester: &SigningKey, provider: &SigningKey) -> SealedContract { + contract_for_artifact( + root, + requester, + provider, + ArtifactRef { + artifact_id: ArtifactId::new("sha256:abcd").unwrap(), + byte_count: 4, + media_type: "text/plain".to_owned(), + owner: NodeId::new("node:requester").unwrap(), + }, + ) +} - fn advance_to(&self, now_ms: i64) { - self.0.store(now_ms, Ordering::SeqCst); +fn contract_for_artifact( + root: &SigningKey, + requester: &SigningKey, + provider: &SigningKey, + artifact: ArtifactRef, +) -> SealedContract { + let artifact_id = artifact.artifact_id.clone(); + SealedContract::seal( + &ContractDraft { + contract_id: ContractId::new("contract:live-policy").unwrap(), + parent_contract_id: None, + intent_id: IntentId::new("intent:live-policy").unwrap(), + requester: NodeId::new("node:requester").unwrap(), + provider: NodeId::new("node:provider").unwrap(), + capability_id: CapabilityId::new("capability:source-metrics-executor").unwrap(), + grant: Grant { + issuer: NodeId::new("node:requester").unwrap(), + subject: NodeId::new("node:provider").unwrap(), + capability_id: CapabilityId::new("capability:source-metrics-executor").unwrap(), + artifact_id: artifact_id.clone(), + expires_at_unix_ms: (NOW + 60_000) as u64, + delegation_depth: 0, + }, + artifact, + acceptance: AcceptanceProfile::ExactSourceMetricsV1, + expires_at_unix_ms: (NOW + 60_000) as u64, + }, + requester, + common::credential_chain( + root, + requester, + "node:requester", + NodeRole::Requester, + NOW as u64, + ), + provider, + common::credential_chain( + root, + provider, + "node:provider", + NodeRole::Executor, + NOW as u64, + ), + ) + .unwrap() +} + +#[tokio::test] +async fn artifact_read_rechecks_contract_after_authorization() { + let temp = tempfile::tempdir().unwrap(); + let root = key(1); + let requester = key(2); + let provider = key(3); + let clock = Arc::new(ManualClock(AtomicI64::new(NOW))); + let store = ArtifactStore::open(temp.path(), NodeId::new("node:requester").unwrap()).unwrap(); + let artifact = store.import(b"live", "text/plain").unwrap(); + let service = ArtifactAccessService::new_with_clock( + store, + root.verifying_key(), + common::domain_id(), + clock.clone(), + ); + let sealed = contract_for_artifact(&root, &requester, &provider, artifact.clone()); + service.authorize(sealed).await.unwrap(); + + clock.0.store(NOW + 60_001, Ordering::SeqCst); + let result = service + .read( + &NodeId::new("node:provider").unwrap(), + &ArtifactReadRequest { + contract_id: ContractId::new("contract:live-policy").unwrap(), + artifact_id: artifact.artifact_id, + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!(service.successful_reads(), 0); +} + +#[tokio::test] +async fn artifact_read_observes_live_revocation_and_staleness_with_zero_reads() { + for mode in 0..3 { + let temp = tempfile::tempdir().unwrap(); + let root = key(1); + let requester = key(2); + let provider = key(3); + let clock = Arc::new(ManualClock(AtomicI64::new(NOW))); + let cache = RevocationCache::open( + &temp.path().join("revocations"), + root.verifying_key(), + common::domain_id(), + publisher_credential(&root), + ) + .unwrap(); + cache + .accept( + snapshot(&root, 1, NOW, NOW + 10, BTreeSet::new(), BTreeSet::new()), + NOW, + ) + .unwrap(); + let store = + ArtifactStore::open(temp.path(), NodeId::new("node:requester").unwrap()).unwrap(); + let artifact = store.import(b"live", "text/plain").unwrap(); + let service = ArtifactAccessService::new_with_policy( + store, + root.verifying_key(), + common::domain_id(), + clock.clone(), + RevocationGuard::new(cache.clone()), + ); + service + .authorize(contract_for_artifact( + &root, + &requester, + &provider, + artifact.clone(), + )) + .await + .unwrap(); + if mode < 2 { + cache + .accept( + snapshot( + &root, + 2, + NOW + 1, + NOW + 11, + if mode == 1 { + BTreeSet::from([NodeId::new("authority:test").unwrap()]) + } else { + BTreeSet::new() + }, + if mode == 0 { + BTreeSet::from([NodeId::new("node:provider").unwrap()]) + } else { + BTreeSet::new() + }, + ), + NOW + 1, + ) + .unwrap(); + clock.0.store(NOW + 1, Ordering::SeqCst); + } else { + clock.0.store(NOW + 11, Ordering::SeqCst); + } + assert!( + service + .read( + &NodeId::new("node:provider").unwrap(), + &ArtifactReadRequest { + contract_id: ContractId::new("contract:live-policy").unwrap(), + artifact_id: artifact.artifact_id, + }, + ) + .await + .is_err() + ); + assert_eq!(service.successful_reads(), 0); } } -impl Clock for AdvancingClock { - fn now_ms(&self) -> i64 { - self.0.load(Ordering::SeqCst) +#[tokio::test] +async fn recorder_rechecks_live_clock_inside_mutation_boundary() { + let temp = tempfile::tempdir().unwrap(); + let root = key(1); + let clock = Arc::new(ManualClock(AtomicI64::new(NOW))); + let recorder = ContractRecorder::open_with_clock( + temp.path(), + root.verifying_key(), + common::domain_id(), + clock.clone(), + ) + .await + .unwrap(); + let sealed = contract(&root, &key(2), &key(3)); + let journal = temp.path().join("journal.jsonl"); + let before = fs::read(&journal).unwrap(); + clock.0.store(NOW + 60_001, Ordering::SeqCst); + assert!(recorder.register_contract(sealed).await.is_err()); + assert_eq!(fs::read(journal).unwrap(), before); +} + +#[tokio::test] +async fn recorder_rejects_live_revocation_changes_without_journal_effect() { + for revoked_authorities in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let root = key(1); + let requester = key(2); + let provider = key(3); + let clock = Arc::new(ManualClock(AtomicI64::new(NOW))); + let cache = RevocationCache::open( + &temp.path().join("revocations"), + root.verifying_key(), + common::domain_id(), + publisher_credential(&root), + ) + .unwrap(); + cache + .accept( + snapshot(&root, 1, NOW, NOW + 1_000, BTreeSet::new(), BTreeSet::new()), + NOW, + ) + .unwrap(); + let recorder = ContractRecorder::open_with_policy( + &temp.path().join("recorder"), + root.verifying_key(), + common::domain_id(), + clock.clone(), + RevocationGuard::new(cache.clone()), + ) + .await + .unwrap(); + let sealed = contract(&root, &requester, &provider); + recorder.register_contract(sealed).await.unwrap(); + let journal = temp.path().join("recorder/journal.jsonl"); + let before = fs::read(&journal).unwrap(); + let authorities = if revoked_authorities { + BTreeSet::from([NodeId::new("authority:test").unwrap()]) + } else { + BTreeSet::new() + }; + let nodes = if revoked_authorities { + BTreeSet::new() + } else { + BTreeSet::from([NodeId::new("node:provider").unwrap()]) + }; + cache + .accept( + snapshot(&root, 2, NOW + 1, NOW + 1_001, authorities, nodes), + NOW + 1, + ) + .unwrap(); + clock.0.store(NOW + 1, Ordering::SeqCst); + let identity = agenet::runtime::NodeIdentity::new_with_clock( + provider.clone(), + common::credential_chain( + &root, + &provider, + "node:provider", + NodeRole::Executor, + NOW as u64, + ), + NodeRole::Executor, + root.verifying_key(), + clock.clone(), + ) + .unwrap(); + let event = ContractEvent { + contract_id: ContractId::new("contract:live-policy").unwrap(), + event_id: "event:revoked".to_owned(), + sequence: 1, + previous_hash: None, + operation_id: "op:revoked".to_owned(), + issuer: identity.node_id().clone(), + kind: EventKind::Activated, + payload: serde_json::json!({}), + occurred_at_unix_ms: (NOW + 1) as u64, + }; + let envelope: WireEnvelope = identity.seal("contract.event.v1", &event).unwrap(); + assert!(recorder.append_event(envelope).await.is_err()); + assert_eq!(fs::read(&journal).unwrap(), before); } } -#[test] -fn request_time_clock_is_live_and_role_sets_are_not_profile_guesses() { - let clock = Arc::new(AdvancingClock::new(10)); - assert_eq!(clock.now_ms(), 10); - clock.advance_to(20); - assert_eq!(clock.now_ms(), 20); +#[tokio::test] +async fn recorder_rejects_snapshot_that_becomes_stale_without_journal_effect() { + let temp = tempfile::tempdir().unwrap(); + let root = key(1); + let clock = Arc::new(ManualClock(AtomicI64::new(NOW))); + let cache = RevocationCache::open( + &temp.path().join("revocations"), + root.verifying_key(), + common::domain_id(), + publisher_credential(&root), + ) + .unwrap(); + cache + .accept( + snapshot(&root, 1, NOW, NOW + 1, BTreeSet::new(), BTreeSet::new()), + NOW, + ) + .unwrap(); + let recorder = ContractRecorder::open_with_policy( + &temp.path().join("recorder"), + root.verifying_key(), + common::domain_id(), + clock.clone(), + RevocationGuard::new(cache), + ) + .await + .unwrap(); + recorder + .register_contract(contract(&root, &key(2), &key(3))) + .await + .unwrap(); + let journal = temp.path().join("recorder/journal.jsonl"); + let before = fs::read(&journal).unwrap(); + clock.0.store(NOW + 2, Ordering::SeqCst); + assert!( + recorder + .projection(&ContractId::new("contract:live-policy").unwrap()) + .await + .is_err() + ); + assert_eq!(fs::read(&journal).unwrap(), before); +} + +#[tokio::test] +async fn detached_provider_rechecks_expiry_before_any_execution_effect() { + let temp = tempfile::tempdir().unwrap(); + let root = key(1); + let requester = key(2); + let provider = key(3); + let clock = Arc::new(ManualClock(AtomicI64::new(NOW))); + let recorder = Arc::new( + ContractRecorder::open_with_clock( + temp.path(), + root.verifying_key(), + common::domain_id(), + clock.clone(), + ) + .await + .unwrap(), + ); + let identity = NodeIdentity::new_with_clock( + provider.clone(), + common::credential_chain( + &root, + &provider, + "node:provider", + NodeRole::Executor, + NOW as u64, + ), + NodeRole::Executor, + root.verifying_key(), + clock.clone(), + ) + .unwrap(); + let client = PeerClient::new(root.verifying_key(), common::domain_id(), NOW as u64).unwrap(); + let service = ProviderService::new( + identity, + recorder, + client.clone(), + NodeRole::Executor, + NOW as u64, + ) + .unwrap(); + let sealed = contract(&root, &requester, &provider); + let draft: ContractDraft = + serde_json::from_slice(&STANDARD.decode(&sealed.draft_payload_base64).unwrap()).unwrap(); + let offer = agenet::protocol::ContractOffer::create( + &draft, + &requester, + common::credential_chain( + &root, + &requester, + "node:requester", + NodeRole::Requester, + NOW as u64, + ), + ) + .unwrap(); + service + .propose( + &NodeId::new("node:requester").unwrap(), + ContractProposeRequest { + offer, + artifact_endpoint: "http://127.0.0.1:9".to_owned(), + expected_metrics: None, + }, + ) + .await + .unwrap(); + let journal = temp.path().join("journal.jsonl"); + let before = fs::read(&journal).unwrap(); + clock.0.store(NOW + 60_001, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(175)).await; + + assert_eq!(fs::read(journal).unwrap(), before); + assert_eq!(client.stats().requests, 0); +} + +#[tokio::test] +async fn detached_provider_observes_node_revocation_before_execution_effect() { + let temp = tempfile::tempdir().unwrap(); + let root = key(1); + let requester = key(2); + let provider = key(3); + let clock = Arc::new(ManualClock(AtomicI64::new(NOW))); + let cache = RevocationCache::open( + &temp.path().join("revocations"), + root.verifying_key(), + common::domain_id(), + publisher_credential(&root), + ) + .unwrap(); + cache + .accept( + snapshot(&root, 1, NOW, NOW + 1_000, BTreeSet::new(), BTreeSet::new()), + NOW, + ) + .unwrap(); + let guard = RevocationGuard::new(cache.clone()); + let recorder = Arc::new( + ContractRecorder::open_with_policy( + &temp.path().join("recorder"), + root.verifying_key(), + common::domain_id(), + clock.clone(), + guard.clone(), + ) + .await + .unwrap(), + ); + let identity = NodeIdentity::new_with_clock( + provider.clone(), + common::credential_chain( + &root, + &provider, + "node:provider", + NodeRole::Executor, + NOW as u64, + ), + NodeRole::Executor, + root.verifying_key(), + clock.clone(), + ) + .unwrap(); + let client = PeerClient::new(root.verifying_key(), common::domain_id(), NOW as u64).unwrap(); + let service = ProviderService::new_for_roles_with_policy( + identity, + recorder, + client.clone(), + BTreeSet::from([NodeRole::Executor]), + guard, + ) + .unwrap(); + let sealed = contract(&root, &requester, &provider); + let draft: ContractDraft = + serde_json::from_slice(&STANDARD.decode(&sealed.draft_payload_base64).unwrap()).unwrap(); + let offer = agenet::protocol::ContractOffer::create( + &draft, + &requester, + common::credential_chain( + &root, + &requester, + "node:requester", + NodeRole::Requester, + NOW as u64, + ), + ) + .unwrap(); + service + .propose( + &NodeId::new("node:requester").unwrap(), + ContractProposeRequest { + offer, + artifact_endpoint: "http://127.0.0.1:9".to_owned(), + expected_metrics: None, + }, + ) + .await + .unwrap(); + let journal = temp.path().join("recorder/journal.jsonl"); + let before = fs::read(&journal).unwrap(); + cache + .accept( + snapshot( + &root, + 2, + NOW + 1, + NOW + 1_001, + BTreeSet::new(), + BTreeSet::from([NodeId::new("node:provider").unwrap()]), + ), + NOW + 1, + ) + .unwrap(); + clock.0.store(NOW + 1, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(175)).await; + + assert_eq!(fs::read(journal).unwrap(), before); + assert_eq!(client.stats().requests, 0); +} - let provider_roles = - BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]); - assert!(provider_roles.contains(&NodeRole::Executor)); - assert!(provider_roles.contains(&NodeRole::Verifier)); - assert!(!provider_roles.contains(&NodeRole::Directory)); +#[tokio::test] +async fn peer_client_rejects_expired_local_identity_before_request_counter() { + let temp = tempfile::tempdir().unwrap(); + let root = key(1); + let requester = key(2); + let clock = Arc::new(ManualClock(AtomicI64::new(NOW))); + let cache = RevocationCache::open( + &temp.path().join("revocations"), + root.verifying_key(), + common::domain_id(), + publisher_credential(&root), + ) + .unwrap(); + cache + .accept( + snapshot( + &root, + 1, + NOW, + NOW + 100_000, + BTreeSet::new(), + BTreeSet::new(), + ), + NOW, + ) + .unwrap(); + let identity = NodeIdentity::new_with_clock( + requester.clone(), + common::credential_chain( + &root, + &requester, + "node:requester", + NodeRole::Requester, + NOW as u64, + ), + NodeRole::Requester, + root.verifying_key(), + clock.clone(), + ) + .unwrap(); + let envelope = identity + .seal( + "contract.query.v1", + &serde_json::json!({"contract_id":"contract:x"}), + ) + .unwrap(); + let client = PeerClient::new(root.verifying_key(), common::domain_id(), NOW as u64) + .unwrap() + .with_local_policy(identity, RevocationGuard::new(cache)) + .unwrap(); + clock.0.store(NOW + 60_001, Ordering::SeqCst); - // Keep the protocol verifier linked into this integration surface: runtime - // authorization must continue to originate from its signed result. - let _verified_chain = verify_credential_chain; + let result: Result = client + .post_signed_peer_or_loopback( + "http://127.0.0.1:9", + &NodeId::new("node:directory").unwrap(), + "/v0/contracts/events/query", + &envelope, + "contract.projection.v1", + NodeRole::Directory, + ) + .await; + assert!(result.is_err()); + assert_eq!(client.stats().requests, 0); } From 595850e310eb0f91d7a9a86a7f7dfa879a50c568 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 05:49:58 +0800 Subject: [PATCH 37/67] [bug] Bind contract capability role Root cause: Contract verification assumed every provider was Executor. Solution: Centralize exact capability-role binding and enforce it across contracts, manifests, dispatch, artifact reads, replay, and the demo. Risks: New provider capabilities must extend the fail-closed protocol map. Dependency: 4895eaa00ee6b3df4dc676e95080fa924aae41be Links: plan/01-v2-multi-host-node-bootstrap.md Post-mortem: Role inference was duplicated instead of protocol-owned. --- README.md | 2 +- ROADMAP.md | 9 ++ docs/design/agenet-v0.1.md | 10 +- plan/01-v2-multi-host-node-bootstrap.md | 9 ++ src/demo.rs | 21 ++- src/node.rs | 28 ++-- src/protocol/capability.rs | 67 +++++++++ src/protocol/mod.rs | 6 + src/protocol/sealed_contract.rs | 16 +- src/runtime/artifact_access.rs | 18 +++ src/runtime/directory.rs | 8 +- src/runtime/host.rs | 26 ++-- src/runtime/provider.rs | 21 +-- src/runtime/recorder.rs | 40 ++++- src/runtime/requester.rs | 12 +- src/transport/directory.rs | 21 +-- src/transport/node.rs | 22 ++- tests/common/mod.rs | 54 +++++++ tests/host_runtime.rs | 188 +++++++++++++++++++++++- tests/http_artifact.rs | 4 +- tests/http_directory.rs | 65 +++++++- tests/http_mtls.rs | 8 +- tests/http_revocation.rs | 5 +- tests/multiprocess_demo.rs | 32 +++- tests/protocol_kernel.rs | 160 ++++++++++++++++---- tests/runtime_storage.rs | 6 +- 26 files changed, 722 insertions(+), 136 deletions(-) create mode 100644 src/protocol/capability.rs diff --git a/README.md b/README.md index 1239e2d..238247e 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,7 @@ Requester → Verifier → parent-linked verification Contract → Delivered Ev Requester → Executor → signed Accepted Event ``` -`agenet demo` is provisioning and test scaffolding, not a control plane. The Requester child receives only the Directory seed; Executor and Verifier endpoints are learned from signed Capability Manifests. +`agenet demo` is provisioning and test scaffolding, not a control plane. Its four credentials are least privilege: Directory-only, Requester-only, Executor-only with the executor capability ceiling, and Verifier-only with the verifier ceiling. The Requester child receives only the Directory seed; Executor and Verifier endpoints are learned from signed Capability Manifests. ## Development gates diff --git a/ROADMAP.md b/ROADMAP.md index e412645..35d865c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Task 12 review: bind Contract capability to Provider role + +- **Change**: Added one protocol-owned capability registry for exact Capability ID, versioned kind, and required Provider role; reused it in bilateral Contract verification, Recorder Event replay, Provider dispatch, Directory registration, Requester routing, Artifact reads, and manifest construction. +- **Files**: protocol capability/Contract modules, runtime Provider/Recorder/Directory/Requester/Artifact/Host modules, peer handlers, demo provisioning, focused protocol/HTTP/multiprocess tests, design, and Task 12 amendment. +- **Root cause / classification**: **技术盲区**. Contract verification hard-coded every Provider as `Executor`, while several runtime and transport layers independently mapped strings. Verifier-only credentials were rejected, Executor-capable credentials could mask the mismatch, and least-privilege Event/Artifact envelopes could not traverse the full path. +- **Solution**: Unknown IDs and ID/kind cross-pairs fail closed. Contract verification derives the exact role and checks the exact capability ceiling plus Grant/Contract/Artifact binding. Provider Events and Artifact reads derive role from the already authorized stored Contract. The demo now provisions Directory-only, Requester-only, Executor-only, and Verifier-only credentials with exact ceilings. +- **Prevention**: Every future Capability must add one protocol registry entry and negative ID/kind/role/ceiling tests before any runtime handler. No transport or runtime layer may infer authorization from profile names or duplicate string matches. +- **Product boundary**: Enrollment `Provider` remains a policy profile whose issued credential may explicitly enable multiple permitted roles; effective execution is still limited by signed roles and the invitation-derived capability ceiling. The demo deliberately uses least privilege and does not rely on that broader product choice. + ## 2026-08-15 — Task 12 review: live authorization at effect boundaries - **Change**: Replaced HostRuntime's startup-frozen Recorder and Artifact clocks with the same injected live clock used by its identity, added current revocation checks for both Contract participants and each Event issuer, and required Provider, Requester, and PeerClient to revalidate local authority immediately before effects. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 6b37700..5bc8b72 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -48,6 +48,14 @@ Each node state directory owns an append-only `journal.jsonl`, an Artifact direc The HTTP adapter rejects non-loopback Capability endpoints, caps JSON bodies at 256 KiB, maps failures to sanitized typed errors, and gives read-only requests a bounded retry path. Directory matching is deterministic equality on the public `kind.version`; it neither invokes a model nor selects a final provider. +The protocol owns the only mapping from an exact Capability ID to its +versioned kind and required Provider role. Contract verification, Directory +registration, Provider dispatch, Event replay, and Artifact reads consume that +mapping; unknown IDs and ID/kind cross-pairs fail closed. Role mapping does not +replace Grant or capability-ceiling checks. The enrollment `Provider` profile +may explicitly authorize multiple permitted roles as a product choice, but an +operation still requires the exact signed role and invitation-derived ceiling. + ## Verified source-metrics flow The Requester imports UTF-8 source bytes, asks the Directory separately for `source.metrics.v1` and `source.metrics.verify.v1`, and signs a scoped bilateral Contract for each provider. The Executor and Verifier retrieve bytes through signed Artifact requests whose caller, Contract, Capability, Artifact hash, length, and expiry are checked. The two providers use separate metric implementations. The verification Contract links to the source Contract through `parent_contract_id`. @@ -58,7 +66,7 @@ Only the Requester can append `Accepted`, and it does so only after matching the The Decision layer receives only the natural-language goal, the strict Intent projection schema, and the public Capability kind. Two explicitly tested wire styles exist: `openai_chat_completions_v1` appends `/chat/completions` when required and uses a sensitive Authorization header; the Walkman-backed manual demo uses `gemini_multimodal_inline_v1`, treats the configured URL as a complete endpoint, places the credential in the `ak` query parameter, and sends inline text content. Neither style puts credentials in Debug output or logs. Both use `temperature: 0`, limit responses to 64 KiB, perform at most one real format-repair call, reject redirects, and have no manual-demo fallback. -The demo provisions an ephemeral Domain Root, one Authority, four v0.3 Node Credentials, exact-IP peer certificates, and a current signed revocation snapshot. It starts four copies of the `agenet node` binary on dynamic HTTPS loopback listeners, waits for mTLS health before signed Capability registration, submits one local pursuit with a bearer token read from a `0600` file, enforces a 90-second outer timeout, sends SIGTERM, and retains state for audit. +The demo provisions an ephemeral Domain Root, one Authority, four v0.3 Node Credentials, exact-IP peer certificates, and a current signed revocation snapshot. The credentials are least privilege: Directory-only, Requester-only, Executor-only with only `source.metrics.v1`, and Verifier-only with only `source.metrics.verify.v1`. It starts four copies of the `agenet node` binary on dynamic HTTPS loopback listeners, waits for mTLS health before signed Capability registration, submits one local pursuit with a bearer token read from a `0600` file, enforces a 90-second outer timeout, sends SIGTERM, and retains state for audit. ## Validation matrix diff --git a/plan/01-v2-multi-host-node-bootstrap.md b/plan/01-v2-multi-host-node-bootstrap.md index df63161..9825399 100644 --- a/plan/01-v2-multi-host-node-bootstrap.md +++ b/plan/01-v2-multi-host-node-bootstrap.md @@ -105,3 +105,12 @@ content-addressed read. If credential, Contract, Grant, Authority, node, or snapshot freshness becomes invalid, delayed work stops without adding a `Failed` Event or advancing read/network counters. This is a local serialization guarantee, not globally atomic revocation propagation. + +Task 12 role-binding review adds a single protocol registry for exact +Capability ID, versioned kind, and Provider role. All Contract, Directory, +Provider, Recorder, Artifact, and Requester decisions reuse it; unknown IDs and +ID/kind cross-pairs fail closed before effects. Grant scope and signed +capability ceiling remain separate mandatory checks. Enrollment's `Provider` +profile may explicitly enable multiple permitted roles, but the loopback demo +uses four least-privilege credentials so its verification path cannot be +accidentally satisfied by a Provider holding both Executor and Verifier roles. diff --git a/src/demo.rs b/src/demo.rs index 55a5e0e..b62da39 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -353,9 +353,8 @@ fn provision_nodes( let allowed_roles = match profile { NodeProfile::Directory => BTreeSet::from([NodeRole::Directory]), NodeProfile::Requester => BTreeSet::from([NodeRole::Requester]), - NodeProfile::Executor | NodeProfile::Verifier => { - BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]) - } + NodeProfile::Executor => BTreeSet::from([NodeRole::Executor]), + NodeProfile::Verifier => BTreeSet::from([NodeRole::Verifier]), }; let claims = NodeCredentialClaims { format_version: "agenet.node-credential.v0.3".to_owned(), @@ -367,14 +366,14 @@ fn provision_nodes( allowed_roles, capability_ceiling: match profile { NodeProfile::Directory | NodeProfile::Requester => BTreeSet::new(), - NodeProfile::Executor => { - BTreeSet::from([CapabilityKind::new("source.metrics.v1").map_err(sanitized)?]) - } - NodeProfile::Verifier => { - BTreeSet::from([ - CapabilityKind::new("source.metrics.verify.v1").map_err(sanitized)? - ]) - } + NodeProfile::Executor => BTreeSet::from([CapabilityKind::new( + crate::protocol::SOURCE_METRICS_EXECUTOR_KIND, + ) + .map_err(sanitized)?]), + NodeProfile::Verifier => BTreeSet::from([CapabilityKind::new( + crate::protocol::SOURCE_METRICS_VERIFIER_KIND, + ) + .map_err(sanitized)?]), }, issued_at_ms: now_ms.saturating_sub(1_000), expires_at_ms: now_ms.saturating_add(600_000), diff --git a/src/node.rs b/src/node.rs index 3824bc1..2d18cee 100644 --- a/src/node.rs +++ b/src/node.rs @@ -15,8 +15,8 @@ use crate::{ adapters::LlmDecisionAdapter, bootstrap::network::NetworkBoundary, protocol::{ - CapabilityId, CapabilityManifest, CredentialChain, DirectorySeed, NodeId, NodeRole, - ProtocolError, SideEffectProfile, + CapabilityManifest, CredentialChain, DirectorySeed, NodeId, NodeRole, ProtocolError, + SideEffectProfile, }, runtime::{ ArtifactAccessService, ArtifactStore, ContractRecorder, DirectoryRegistry, NodeIdentity, @@ -352,21 +352,21 @@ async fn register_capability( client: &PeerClient, profile: NodeProfile, ) -> Result<(), String> { - let (capability_id, kind, description) = match profile { - NodeProfile::Executor => ( - "capability:source-metrics-executor", - "source.metrics", - "Compute source metrics from an authorized Artifact", - ), - NodeProfile::Verifier => ( - "capability:source-metrics-verifier", - "source.metrics.verify", - "Independently recompute and verify source metrics", - ), + let role = profile.role(); + let capability_id = crate::protocol::provider_capability_id(role).map_err(sanitized)?; + let capability_kind = + crate::protocol::required_capability_kind(&capability_id).map_err(sanitized)?; + let kind = capability_kind + .as_str() + .strip_suffix(".v1") + .ok_or_else(|| "UnsupportedCapabilityProfile".to_owned())?; + let description = match profile { + NodeProfile::Executor => "Compute source metrics from an authorized Artifact", + NodeProfile::Verifier => "Independently recompute and verify source metrics", _ => return Err("UnsupportedCapabilityProfile".to_owned()), }; let manifest = CapabilityManifest { - capability_id: CapabilityId::new(capability_id).map_err(sanitized)?, + capability_id, provider: identity.node_id().clone(), kind: kind.to_owned(), version: "v1".to_owned(), diff --git a/src/protocol/capability.rs b/src/protocol/capability.rs new file mode 100644 index 0000000..ae56279 --- /dev/null +++ b/src/protocol/capability.rs @@ -0,0 +1,67 @@ +use super::{CapabilityId, CapabilityKind, NodeRole, ProtocolError}; + +pub const SOURCE_METRICS_EXECUTOR_CAPABILITY_ID: &str = "capability:source-metrics-executor"; +pub const SOURCE_METRICS_VERIFIER_CAPABILITY_ID: &str = "capability:source-metrics-verifier"; +pub const SOURCE_METRICS_EXECUTOR_KIND: &str = "source.metrics.v1"; +pub const SOURCE_METRICS_VERIFIER_KIND: &str = "source.metrics.verify.v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KnownProviderCapability { + SourceMetricsExecutor, + SourceMetricsVerifier, +} + +impl KnownProviderCapability { + fn from_id(capability_id: &CapabilityId) -> Result { + match capability_id.as_str() { + SOURCE_METRICS_EXECUTOR_CAPABILITY_ID => Ok(Self::SourceMetricsExecutor), + SOURCE_METRICS_VERIFIER_CAPABILITY_ID => Ok(Self::SourceMetricsVerifier), + _ => Err(ProtocolError::ContractMismatch), + } + } + + fn role(self) -> NodeRole { + match self { + Self::SourceMetricsExecutor => NodeRole::Executor, + Self::SourceMetricsVerifier => NodeRole::Verifier, + } + } + + fn kind(self) -> &'static str { + match self { + Self::SourceMetricsExecutor => SOURCE_METRICS_EXECUTOR_KIND, + Self::SourceMetricsVerifier => SOURCE_METRICS_VERIFIER_KIND, + } + } +} + +pub fn required_provider_role(capability_id: &CapabilityId) -> Result { + KnownProviderCapability::from_id(capability_id).map(KnownProviderCapability::role) +} + +pub fn provider_capability_id(role: NodeRole) -> Result { + let value = match role { + NodeRole::Executor => SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, + NodeRole::Verifier => SOURCE_METRICS_VERIFIER_CAPABILITY_ID, + _ => return Err(ProtocolError::CredentialRoleMismatch), + }; + CapabilityId::new(value) +} + +pub fn required_capability_kind( + capability_id: &CapabilityId, +) -> Result { + let capability = KnownProviderCapability::from_id(capability_id)?; + CapabilityKind::new(capability.kind()) +} + +pub fn validate_capability_binding( + capability_id: &CapabilityId, + capability_kind: &CapabilityKind, +) -> Result { + let capability = KnownProviderCapability::from_id(capability_id)?; + if capability.kind() != capability_kind.as_str() { + return Err(ProtocolError::ContractMismatch); + } + Ok(capability.role()) +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index f3212cc..4f993b1 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -1,4 +1,5 @@ mod authority; +mod capability; mod contract; mod enrollment; mod envelope; @@ -13,6 +14,11 @@ pub use authority::{ verify_credential_chain, }; pub(crate) use authority::{roles_for_bootstrap_profile, verify_authority_credential}; +pub use capability::{ + SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, SOURCE_METRICS_EXECUTOR_KIND, + SOURCE_METRICS_VERIFIER_CAPABILITY_ID, SOURCE_METRICS_VERIFIER_KIND, provider_capability_id, + required_capability_kind, required_provider_role, validate_capability_binding, +}; pub use contract::{ContractProjection, apply_event, event_hash}; pub use enrollment::{EnrollmentBundle, MAX_ENROLLMENT_CSR_BYTES}; pub(crate) use enrollment::{ diff --git a/src/protocol/sealed_contract.rs b/src/protocol/sealed_contract.rs index 7768f0c..7a42812 100644 --- a/src/protocol/sealed_contract.rs +++ b/src/protocol/sealed_contract.rs @@ -130,17 +130,31 @@ impl SealedContract { NodeRole::Requester, now_unix_ms, )?; + let provider_role = super::required_provider_role(&draft.capability_id)?; let provider = verify_party( &draft_bytes, &self.provider_signature, root, expected_domain, - NodeRole::Executor, + provider_role, now_unix_ms, )?; if requester.node_id != draft.requester || provider.node_id != draft.provider { return Err(ProtocolError::InvalidContractSignature); } + if draft.grant.issuer != draft.requester + || !provider + .capability_ceiling + .contains(&super::required_capability_kind(&draft.capability_id)?) + { + return Err(ProtocolError::GrantScopeViolation); + } + draft.grant.allows( + &draft.provider, + &draft.capability_id, + &draft.artifact.artifact_id, + now_unix_ms, + )?; Ok((draft, requester, provider)) } } diff --git a/src/runtime/artifact_access.rs b/src/runtime/artifact_access.rs index a859307..f7c6e51 100644 --- a/src/runtime/artifact_access.rs +++ b/src/runtime/artifact_access.rs @@ -122,6 +122,24 @@ impl ArtifactAccessService { }) } + pub(crate) async fn reader_role( + &self, + contract_id: &ContractId, + caller: &NodeId, + ) -> Result { + let contracts = self.contracts.read().await; + let contract = contracts + .get(contract_id) + .ok_or(RuntimeError::UnknownContract)?; + let (draft, requester, provider) = self.verify_contract(contract)?; + self.verify_claims(&requester)?; + self.verify_claims(&provider)?; + if caller != &draft.provider { + return Err(RuntimeError::ArtifactAccessDenied); + } + crate::protocol::required_provider_role(&draft.capability_id).map_err(RuntimeError::from) + } + pub fn successful_reads(&self) -> u64 { self.successful_reads.load(Ordering::SeqCst) } diff --git a/src/runtime/directory.rs b/src/runtime/directory.rs index 0b84361..c974ff1 100644 --- a/src/runtime/directory.rs +++ b/src/runtime/directory.rs @@ -34,11 +34,9 @@ impl DirectoryRegistry { validate_manifest(&claims.node_id, &manifest, now_unix_ms)?; let kind = CapabilityKind::new(manifest.capability_kind_version()) .map_err(|_| RuntimeError::CapabilityNotAuthorized)?; - let required_role = match manifest.kind.as_str() { - "source.metrics" => crate::protocol::NodeRole::Executor, - "source.metrics.verify" => crate::protocol::NodeRole::Verifier, - _ => return Err(RuntimeError::CapabilityNotAuthorized), - }; + let required_role = + crate::protocol::validate_capability_binding(&manifest.capability_id, &kind) + .map_err(|_| RuntimeError::CapabilityNotAuthorized)?; if !claims.allowed_roles.contains(&required_role) { return Err(RuntimeError::CapabilityNotAuthorized); } diff --git a/src/runtime/host.rs b/src/runtime/host.rs index a97638a..9d772a6 100644 --- a/src/runtime/host.rs +++ b/src/runtime/host.rs @@ -15,8 +15,7 @@ use crate::{ PersistedStartupBundle, load_startup_bundle, }, protocol::{ - CapabilityId, CapabilityKind, CapabilityManifest, CredentialChain, NodeRole, - RevocationDecision, SideEffectProfile, + CapabilityManifest, CredentialChain, NodeRole, RevocationDecision, SideEffectProfile, }, transport::{ PeerClient, RevocationClient, base_router_with_revocation, @@ -547,25 +546,22 @@ fn manifest( role: NodeRole, now: i64, ) -> Result { - let (id, kind, description) = match role { - NodeRole::Executor => ( - "capability:source-metrics-executor", - "source.metrics", - "Compute source metrics from an authorized Artifact", - ), - NodeRole::Verifier => ( - "capability:source-metrics-verifier", - "source.metrics.verify", - "Independently recompute and verify source metrics", - ), + let capability_id = crate::protocol::provider_capability_id(role)?; + let authorized = crate::protocol::required_capability_kind(&capability_id)?; + let kind = authorized + .as_str() + .strip_suffix(".v1") + .ok_or(RuntimeError::CapabilityNotAuthorized)?; + let description = match role { + NodeRole::Executor => "Compute source metrics from an authorized Artifact", + NodeRole::Verifier => "Independently recompute and verify source metrics", _ => return Err(RuntimeError::CredentialRoleMismatch), }; - let authorized = CapabilityKind::new(format!("{kind}.v1"))?; if !identity.claims().capability_ceiling.contains(&authorized) { return Err(RuntimeError::CapabilityNotAuthorized); } Ok(CapabilityManifest { - capability_id: CapabilityId::new(id)?, + capability_id, provider: identity.node_id().clone(), kind: kind.to_owned(), version: "v1".to_owned(), diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs index 4092764..ccbac74 100644 --- a/src/runtime/provider.rs +++ b/src/runtime/provider.rs @@ -5,9 +5,9 @@ use base64::{Engine, engine::general_purpose::STANDARD}; use crate::{ adapters::{executor_metrics, verifier_metrics}, protocol::{ - ArtifactPayload, ArtifactReadRequest, CapabilityKind, ContractDraft, ContractEvent, - ContractId, ContractProjection, ContractProposeRequest, ContractProposeResponse, - ContractQuery, ContractState, EventKind, EvidenceClaim, NodeRole, WireEnvelope, event_hash, + ArtifactPayload, ArtifactReadRequest, ContractDraft, ContractEvent, ContractId, + ContractProjection, ContractProposeRequest, ContractProposeResponse, ContractQuery, + ContractState, EventKind, EvidenceClaim, NodeRole, WireEnvelope, event_hash, }, transport::{PeerClient, TransportError}, }; @@ -220,17 +220,10 @@ impl ProviderService { } fn authorized_contract_role(&self, draft: &ContractDraft) -> Result { - let (role, kind) = match draft.capability_id.as_str() { - "capability:source-metrics-executor" => ( - NodeRole::Executor, - CapabilityKind::new("source.metrics.v1")?, - ), - "capability:source-metrics-verifier" => ( - NodeRole::Verifier, - CapabilityKind::new("source.metrics.verify.v1")?, - ), - _ => return Err(RuntimeError::CapabilityNotAuthorized), - }; + let role = crate::protocol::required_provider_role(&draft.capability_id) + .map_err(|_| RuntimeError::CapabilityNotAuthorized)?; + let kind = crate::protocol::required_capability_kind(&draft.capability_id) + .map_err(|_| RuntimeError::CapabilityNotAuthorized)?; if !self.roles.contains(&role) || !self.identity.claims().allowed_roles.contains(&role) || !self.identity.claims().capability_ceiling.contains(&kind) diff --git a/src/runtime/recorder.rs b/src/runtime/recorder.rs index 204d1ba..2e5fceb 100644 --- a/src/runtime/recorder.rs +++ b/src/runtime/recorder.rs @@ -6,6 +6,7 @@ use std::{ sync::Arc, }; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use ed25519_dalek::VerifyingKey; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; @@ -153,11 +154,13 @@ impl ContractRecorder { envelope: WireEnvelope, ) -> Result { let mut state = self.state.lock().await; + let untrusted_event = decode_event(&envelope)?; + let expected_role = event_issuer_role(&state.projections, &untrusted_event)?; let opened = envelope.open_with_verified_claims::( "contract.event.v1", &self.root, &self.domain_id, - crate::protocol::NodeRole::Requester, + expected_role, self.clock.now_ms(), )?; let (event, issuer) = opened.into_parts(); @@ -189,11 +192,12 @@ impl ContractRecorder { event: ContractEvent, ) -> Result { let mut state = self.state.lock().await; + let expected_role = event_issuer_role(&state.projections, &event)?; let opened_envelope = envelope.open_with_verified_claims::( "contract.event.v1", &self.root, &self.domain_id, - crate::protocol::NodeRole::Requester, + expected_role, self.clock.now_ms(), )?; let (opened, issuer): (ContractEvent, _) = opened_envelope.into_parts(); @@ -303,11 +307,13 @@ fn replay_entry( projections.insert(draft.contract_id.clone(), ContractProjection::new(draft)); } JournalEntry::Event { envelope } => { + let untrusted_event = decode_event(&envelope)?; + let expected_role = event_issuer_role(projections, &untrusted_event)?; let event: ContractEvent = envelope.open( "contract.event.v1", root, domain_id, - crate::protocol::NodeRole::Requester, + expected_role, i64::try_from(validation_time_unix_ms) .map_err(|_| crate::protocol::ProtocolError::CredentialExpired)?, )?; @@ -319,3 +325,31 @@ fn replay_entry( } Ok(()) } + +fn decode_event(envelope: &WireEnvelope) -> Result { + let bytes = STANDARD + .decode(&envelope.payload_base64) + .map_err(|_| crate::protocol::ProtocolError::InvalidBase64)?; + serde_json::from_slice(&bytes).map_err(RuntimeError::from) +} + +fn event_issuer_role( + projections: &HashMap, + event: &ContractEvent, +) -> Result { + let projection = projections + .get(&event.contract_id) + .ok_or(RuntimeError::UnknownContract)?; + match event.kind { + crate::protocol::EventKind::Accepted | crate::protocol::EventKind::VerificationFailed => { + Ok(crate::protocol::NodeRole::Requester) + } + crate::protocol::EventKind::Activated + | crate::protocol::EventKind::Started + | crate::protocol::EventKind::Delivered + | crate::protocol::EventKind::Failed => { + crate::protocol::required_provider_role(&projection.draft.capability_id) + .map_err(RuntimeError::from) + } + } +} diff --git a/src/runtime/requester.rs b/src/runtime/requester.rs index 1773fd3..845c167 100644 --- a/src/runtime/requester.rs +++ b/src/runtime/requester.rs @@ -170,7 +170,9 @@ impl RequesterService { let route_started = Instant::now(); let executor = self.route(&decision.decision.required_capability).await?; - let verifier = self.route("source.metrics.verify.v1").await?; + let verifier = self + .route(crate::protocol::SOURCE_METRICS_VERIFIER_KIND) + .await?; phase_ms.insert("routing".to_owned(), elapsed_ms(route_started)); let source_started = Instant::now(); @@ -443,11 +445,9 @@ impl RequesterService { } fn provider_role(manifest: &CapabilityManifest) -> Result { - match manifest.kind.as_str() { - "source.metrics" => Ok(NodeRole::Executor), - "source.metrics.verify" => Ok(NodeRole::Verifier), - _ => Err(RuntimeError::CapabilityUnavailable), - } + let kind = crate::protocol::CapabilityKind::new(manifest.capability_kind_version())?; + crate::protocol::validate_capability_binding(&manifest.capability_id, &kind) + .map_err(|_| RuntimeError::CapabilityUnavailable) } fn delivered_evidence(projection: &ContractProjection) -> Result { diff --git a/src/transport/directory.rs b/src/transport/directory.rs index 97013a6..5cbe98d 100644 --- a/src/transport/directory.rs +++ b/src/transport/directory.rs @@ -153,24 +153,9 @@ async fn register( } fn advertised_registration_role(envelope: &WireEnvelope) -> Result { - let bytes = STANDARD - .decode(&envelope.credential_chain.node.claims_base64) - .map_err(|_| ())?; - let claims: crate::protocol::NodeCredentialClaims = - serde_json::from_slice(&bytes).map_err(|_| ())?; - if claims - .allowed_roles - .contains(&crate::protocol::NodeRole::Executor) - { - return Ok(crate::protocol::NodeRole::Executor); - } - if claims - .allowed_roles - .contains(&crate::protocol::NodeRole::Verifier) - { - return Ok(crate::protocol::NodeRole::Verifier); - } - Err(()) + let bytes = STANDARD.decode(&envelope.payload_base64).map_err(|_| ())?; + let manifest: CapabilityManifest = serde_json::from_slice(&bytes).map_err(|_| ())?; + crate::protocol::required_provider_role(&manifest.capability_id).map_err(|_| ()) } async fn query( diff --git a/src/transport/node.rs b/src/transport/node.rs index 0b52034..3d0c56a 100644 --- a/src/transport/node.rs +++ b/src/transport/node.rs @@ -7,6 +7,7 @@ use axum::{ response::{IntoResponse, Response}, routing::{get, post}, }; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde_json::json; use crate::{ @@ -344,11 +345,30 @@ async fn artifact_read( Err(response) => return *response, }; let now_ms = state.identity.now_ms(); + let untrusted_request = match STANDARD + .decode(&envelope.payload_base64) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + { + Some(request) => request, + None => return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"), + }; + let expected_role = match state + .access + .reader_role(&untrusted_request.contract_id, &envelope.issuer_id) + .await + { + Ok(role) => role, + Err(RuntimeError::UnknownContract) => { + return error(StatusCode::CONFLICT, "ContractNotAuthorized"); + } + Err(_) => return error(StatusCode::FORBIDDEN, "ArtifactReadRejected"), + }; let opened = match envelope.open_with_verified_claims::( "artifact.read.v1", state.identity.root(), state.identity.domain_id(), - crate::protocol::NodeRole::Requester, + expected_role, now_ms, ) { Ok(opened) => opened, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 677ade6..2aee2bd 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -84,3 +84,57 @@ pub fn credential_chain( node: node_credential, } } + +#[allow(dead_code)] +pub fn least_privilege_provider_credential( + root: &SigningKey, + node: &SigningKey, + node_id: &str, + role: NodeRole, + capability_kind: &str, + now: u64, +) -> CredentialChain { + assert!(matches!(role, NodeRole::Executor | NodeRole::Verifier)); + let now_ms = i64::try_from(now).expect("test timestamp fits i64"); + let authority = SigningKey::from_bytes(&[98; 32]); + let authority_credential = SignedAuthorityCredential::issue( + root, + AuthorityClaims { + domain_id: domain_id(), + authority_id: NodeId::new("authority:test").expect("valid Authority ID"), + signing_public_key_base64: STANDARD.encode(authority.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([AuthorityScope::IssueNodeCredential]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Provider]), + maximum_node_lifetime_ms: 61_000, + issued_at_ms: now_ms - 2_000, + expires_at_ms: now_ms + 120_000, + }, + ) + .expect("Authority credential issued"); + let node_credential = authority_credential + .issue_node_credential( + &root.verifying_key(), + &authority, + NodeCredentialClaims { + format_version: "agenet.node-credential.v0.3".to_owned(), + domain_id: domain_id(), + authority_id: authority_credential.claims.authority_id.clone(), + node_id: NodeId::new(node_id).expect("valid Node ID"), + signing_public_key_base64: STANDARD.encode(node.verifying_key().to_bytes()), + bootstrap_profile: BootstrapProfile::Provider, + allowed_roles: BTreeSet::from([role]), + capability_ceiling: BTreeSet::from([ + CapabilityKind::new(capability_kind).expect("valid capability kind") + ]), + issued_at_ms: now_ms - 1_000, + expires_at_ms: now_ms + 60_000, + }, + now_ms, + ) + .expect("Node credential issued"); + CredentialChain { + authority: authority_credential, + node: node_credential, + } +} diff --git a/tests/host_runtime.rs b/tests/host_runtime.rs index e138843..be81858 100644 --- a/tests/host_runtime.rs +++ b/tests/host_runtime.rs @@ -5,7 +5,9 @@ use agenet::{ AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityId, ContractDraft, ContractEvent, ContractId, ContractProposeRequest, EventKind, Grant, IntentId, NodeId, NodeRole, RevocationClaims, - RevocationSnapshot, SealedContract, SignedAuthorityCredential, WireEnvelope, + RevocationSnapshot, SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, SOURCE_METRICS_EXECUTOR_KIND, + SOURCE_METRICS_VERIFIER_CAPABILITY_ID, SOURCE_METRICS_VERIFIER_KIND, SealedContract, + SignedAuthorityCredential, WireEnvelope, }, runtime::{ ArtifactAccessService, ArtifactStore, Clock, ContractRecorder, NodeIdentity, @@ -145,6 +147,63 @@ fn contract_for_artifact( .unwrap() } +fn least_privilege_contract( + root: &SigningKey, + requester: &SigningKey, + provider: &SigningKey, + provider_id: &str, + capability_id: &str, + provider_role: NodeRole, + capability_kind: &str, +) -> SealedContract { + let artifact_id = ArtifactId::new("sha256:abcd").unwrap(); + let draft = ContractDraft { + contract_id: ContractId::new(format!("contract:{provider_id}:{capability_id}")).unwrap(), + parent_contract_id: None, + intent_id: IntentId::new("intent:least-privilege").unwrap(), + requester: NodeId::new("node:requester").unwrap(), + provider: NodeId::new(provider_id).unwrap(), + capability_id: CapabilityId::new(capability_id).unwrap(), + grant: Grant { + issuer: NodeId::new("node:requester").unwrap(), + subject: NodeId::new(provider_id).unwrap(), + capability_id: CapabilityId::new(capability_id).unwrap(), + artifact_id: artifact_id.clone(), + expires_at_unix_ms: (NOW + 60_000) as u64, + delegation_depth: 0, + }, + artifact: ArtifactRef { + artifact_id, + byte_count: 4, + media_type: "text/plain".to_owned(), + owner: NodeId::new("node:requester").unwrap(), + }, + acceptance: AcceptanceProfile::ExactSourceMetricsV1, + expires_at_unix_ms: (NOW + 60_000) as u64, + }; + SealedContract::seal( + &draft, + requester, + common::credential_chain( + root, + requester, + "node:requester", + NodeRole::Requester, + NOW as u64, + ), + provider, + common::least_privilege_provider_credential( + root, + provider, + provider_id, + provider_role, + capability_kind, + NOW as u64, + ), + ) + .unwrap() +} + #[tokio::test] async fn artifact_read_rechecks_contract_after_authorization() { let temp = tempfile::tempdir().unwrap(); @@ -281,6 +340,133 @@ async fn recorder_rechecks_live_clock_inside_mutation_boundary() { assert_eq!(fs::read(journal).unwrap(), before); } +#[tokio::test] +async fn recorder_accepts_and_replays_a_verifier_only_contract_and_event() { + let temp = tempfile::tempdir().unwrap(); + let root = key(1); + let requester = key(2); + let verifier = key(3); + let clock = Arc::new(ManualClock(AtomicI64::new(NOW))); + let sealed = least_privilege_contract( + &root, + &requester, + &verifier, + "node:verifier-only", + SOURCE_METRICS_VERIFIER_CAPABILITY_ID, + NodeRole::Verifier, + SOURCE_METRICS_VERIFIER_KIND, + ); + let contract_id = sealed + .verify(&root.verifying_key(), &common::domain_id(), NOW as u64) + .unwrap() + .contract_id; + let recorder = ContractRecorder::open_with_clock( + temp.path(), + root.verifying_key(), + common::domain_id(), + clock.clone(), + ) + .await + .unwrap(); + recorder.register_contract(sealed).await.unwrap(); + let identity = NodeIdentity::new_with_clock( + verifier.clone(), + common::least_privilege_provider_credential( + &root, + &verifier, + "node:verifier-only", + NodeRole::Verifier, + SOURCE_METRICS_VERIFIER_KIND, + NOW as u64, + ), + NodeRole::Verifier, + root.verifying_key(), + clock, + ) + .unwrap(); + let event = ContractEvent { + contract_id: contract_id.clone(), + event_id: "event:verifier-active".to_owned(), + sequence: 1, + previous_hash: None, + operation_id: "op:verifier-active".to_owned(), + issuer: identity.node_id().clone(), + kind: EventKind::Activated, + payload: serde_json::json!({}), + occurred_at_unix_ms: NOW as u64, + }; + recorder + .append_event(identity.seal("contract.event.v1", &event).unwrap()) + .await + .unwrap(); + drop(recorder); + + let replayed = ContractRecorder::open( + temp.path(), + root.verifying_key(), + common::domain_id(), + NOW as u64, + ) + .await + .unwrap(); + assert_eq!( + replayed.projection(&contract_id).await.unwrap().state, + agenet::protocol::ContractState::Active + ); +} + +#[tokio::test] +async fn recorder_rejects_mismatched_and_unknown_capability_roles_without_effect() { + let cases = [ + ( + "node:executor-only", + SOURCE_METRICS_VERIFIER_CAPABILITY_ID, + NodeRole::Executor, + SOURCE_METRICS_EXECUTOR_KIND, + ), + ( + "node:verifier-only", + SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, + NodeRole::Verifier, + SOURCE_METRICS_VERIFIER_KIND, + ), + ( + "node:verifier-unknown", + "capability:unknown", + NodeRole::Verifier, + SOURCE_METRICS_VERIFIER_KIND, + ), + ]; + for (index, (provider_id, capability_id, role, kind)) in cases.into_iter().enumerate() { + let temp = tempfile::tempdir().unwrap(); + let root = key(10 + index as u8); + let requester = key(20 + index as u8); + let provider = key(30 + index as u8); + let recorder = ContractRecorder::open( + temp.path(), + root.verifying_key(), + common::domain_id(), + NOW as u64, + ) + .await + .unwrap(); + let journal = temp.path().join("journal.jsonl"); + let before = fs::read(&journal).unwrap(); + let sealed = least_privilege_contract( + &root, + &requester, + &provider, + provider_id, + capability_id, + role, + kind, + ); + + assert!(recorder.register_contract(sealed).await.is_err()); + assert_eq!(fs::read(journal).unwrap(), before); + } +} + #[tokio::test] async fn recorder_rejects_live_revocation_changes_without_journal_effect() { for revoked_authorities in [false, true] { diff --git a/tests/http_artifact.rs b/tests/http_artifact.rs index d608339..65fc910 100644 --- a/tests/http_artifact.rs +++ b/tests/http_artifact.rs @@ -4,7 +4,7 @@ use agenet::{ protocol::{ AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, CapabilityId, ContractDraft, ContractId, CredentialChain, Grant, IntentId, NodeId, NodeRole, - SealedContract, + SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, SealedContract, }, runtime::{ArtifactAccessService, ArtifactStore, NodeIdentity}, transport::artifact_router, @@ -58,7 +58,7 @@ async fn artifact_endpoint_requires_the_exact_contract_caller_and_hash() { let artifact = store.import(b"fn main() {}\n", "text/x-rust").unwrap(); let access = ArtifactAccessService::new(store, root.verifying_key(), common::domain_id(), NOW); let contract_id = ContractId::new("contract:source").unwrap(); - let capability_id = CapabilityId::new("capability:source-metrics").unwrap(); + let capability_id = CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(); let contract = SealedContract::seal( &draft( contract_id.clone(), diff --git a/tests/http_directory.rs b/tests/http_directory.rs index e804389..a3d5d8f 100644 --- a/tests/http_directory.rs +++ b/tests/http_directory.rs @@ -3,6 +3,7 @@ mod common; use agenet::{ protocol::{ CandidateSet, CapabilityId, CapabilityManifest, CredentialChain, NodeRole, RouteQuery, + SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, SOURCE_METRICS_VERIFIER_CAPABILITY_ID, SideEffectProfile, WireEnvelope, }, runtime::{Clock, DirectoryRegistry, NodeIdentity}, @@ -80,7 +81,7 @@ async fn credential_expiring_after_startup_rejects_registration_without_effect() let registry = DirectoryRegistry::new(); let app = directory_router(registry.clone(), directory, NOW); let manifest = CapabilityManifest { - capability_id: CapabilityId::new("capability:expired-after-startup").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), provider: executor.node_id().clone(), kind: "source.metrics".to_owned(), version: "v1".to_owned(), @@ -137,7 +138,7 @@ async fn signed_manifest_registration_and_deterministic_query_round_trip() { let app = directory_router(DirectoryRegistry::new(), directory, NOW); let manifest = CapabilityManifest { - capability_id: CapabilityId::new("capability:source-metrics").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), provider: executor.node_id().clone(), kind: "source.metrics".to_owned(), version: "v1".to_owned(), @@ -216,9 +217,9 @@ async fn credential_capability_ceiling_rejects_out_of_scope_manifest() { ); let app = directory_router(DirectoryRegistry::new(), directory, NOW); let manifest = CapabilityManifest { - capability_id: CapabilityId::new("capability:outside-ceiling").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_VERIFIER_CAPABILITY_ID).unwrap(), provider: executor.node_id().clone(), - kind: "project.build".to_owned(), + kind: "source.metrics.verify".to_owned(), version: "v1".to_owned(), description: "must be rejected".to_owned(), input_profile: "project.v1".to_owned(), @@ -239,6 +240,60 @@ async fn credential_capability_ceiling_rejects_out_of_scope_manifest() { assert_eq!(response.status(), StatusCode::FORBIDDEN); } +#[tokio::test] +async fn capability_id_and_manifest_kind_cross_pair_is_rejected() { + let root = signing_key(47); + let directory = identity( + &root, + signing_key(48), + "node:directory-binding", + NodeRole::Directory, + ); + let executor = identity( + &root, + signing_key(49), + "node:executor-binding", + NodeRole::Executor, + ); + let registry = DirectoryRegistry::new(); + let app = directory_router(registry.clone(), directory, NOW); + let manifest = CapabilityManifest { + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), + provider: executor.node_id().clone(), + kind: "source.metrics.verify".to_owned(), + version: "v1".to_owned(), + description: "cross pair must fail".to_owned(), + input_profile: "artifact.source.utf8.v1".to_owned(), + output_profile: "source.metrics.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: "http://127.0.0.1:41417".to_owned(), + evidence_types: vec![], + expires_at_unix_ms: NOW + 60_000, + }; + let envelope = executor.seal("capability.manifest.v1", &manifest).unwrap(); + let response = app + .oneshot(loopback_request(envelope_request( + "/v0/capabilities/register", + &envelope, + ))) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + registry + .query( + RouteQuery { + required_capability: "source.metrics.verify.v1".to_owned(), + }, + NOW, + ) + .await + .candidates + .is_empty() + ); +} + #[tokio::test] async fn unsigned_and_oversized_directory_requests_are_rejected() { let root = signing_key(50); @@ -291,7 +346,7 @@ async fn missing_or_nonloopback_connection_identity_cannot_register() { let registry = DirectoryRegistry::new(); let app = directory_router(registry.clone(), directory, NOW); let manifest = CapabilityManifest { - capability_id: CapabilityId::new("capability:identity-boundary").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), provider: executor.node_id().clone(), kind: "source.metrics".to_owned(), version: "v1".to_owned(), diff --git a/tests/http_mtls.rs b/tests/http_mtls.rs index 91e66f1..c655d9b 100644 --- a/tests/http_mtls.rs +++ b/tests/http_mtls.rs @@ -15,8 +15,9 @@ use agenet::{ }, protocol::{ AuthorityClaims, AuthorityScope, BootstrapProfile, CapabilityId, CapabilityManifest, - NodeId, NodeRole, RevocationClaims, RevocationSnapshot, SideEffectProfile, - SignedAuthorityCredential, WireEnvelope, + NodeId, NodeRole, RevocationClaims, RevocationSnapshot, + SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, SideEffectProfile, SignedAuthorityCredential, + WireEnvelope, }, runtime::{DirectoryRegistry, NodeIdentity, RevocationCache, RevocationGuard, serve_peer_tls}, transport::{ @@ -1092,7 +1093,8 @@ fn publisher_credential( fn manifest(provider: &str, endpoint: &str, now: u64) -> CapabilityManifest { CapabilityManifest { - capability_id: CapabilityId::new("capability:mtls-test").expect("capability ID"), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID) + .expect("capability ID"), provider: NodeId::new(provider).expect("provider"), kind: "source.metrics".to_owned(), version: "v1".to_owned(), diff --git a/tests/http_revocation.rs b/tests/http_revocation.rs index 51fce84..b7623bb 100644 --- a/tests/http_revocation.rs +++ b/tests/http_revocation.rs @@ -13,7 +13,8 @@ use agenet::{ protocol::{ AuthorityClaims, AuthorityScope, BootstrapProfile, CandidateSet, CapabilityId, CapabilityManifest, NodeId, NodeRole, RevocationClaims, RevocationSnapshot, RouteQuery, - SideEffectProfile, SignedAuthorityCredential, WireEnvelope, + SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, SideEffectProfile, SignedAuthorityCredential, + WireEnvelope, }, runtime::{ AuthorityRevocationStore, DirectoryRegistry, NodeIdentity, RevocationCache, RevocationGuard, @@ -216,7 +217,7 @@ async fn directory_registration_fails_stale_and_revoked_but_query_health_remain_ RevocationGuard::new(cache.clone()), ); let manifest = CapabilityManifest { - capability_id: CapabilityId::new("capability:metrics").expect("id"), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).expect("id"), provider: executor.node_id().clone(), kind: "source.metrics".to_owned(), version: "v1".to_owned(), diff --git a/tests/multiprocess_demo.rs b/tests/multiprocess_demo.rs index 354a7eb..b0ff467 100644 --- a/tests/multiprocess_demo.rs +++ b/tests/multiprocess_demo.rs @@ -1,12 +1,19 @@ use std::{ - collections::HashSet, + collections::{BTreeSet, HashSet}, fs, path::Path, process::{Command, Stdio}, }; -use agenet::{demo::DemoSummary, protocol::ContractState}; +use agenet::{ + demo::DemoSummary, + protocol::{ + CapabilityKind, ContractState, CredentialChain, NodeRole, SOURCE_METRICS_EXECUTOR_KIND, + SOURCE_METRICS_VERIFIER_KIND, + }, +}; use axum::{Json, Router, routing::post}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde_json::json; use sha2::{Digest, Sha256}; use tempfile::TempDir; @@ -145,6 +152,27 @@ async fn demo_runs_four_real_processes_and_reaches_verified_acceptance() { assert!(node.state_dir.join("peer-private-key-v1.pem").exists()); assert!(node.state_dir.join("authority-ca-v1.pem").exists()); assert!(node.state_dir.join("journal.jsonl").exists()); + let credential: CredentialChain = + serde_json::from_slice(&fs::read(node.state_dir.join("credential.json")).unwrap()) + .unwrap(); + let claims: agenet::protocol::NodeCredentialClaims = + serde_json::from_slice(&STANDARD.decode(&credential.node.claims_base64).unwrap()) + .unwrap(); + let (roles, ceiling) = match node.profile.as_str() { + "directory" => (BTreeSet::from([NodeRole::Directory]), BTreeSet::new()), + "requester" => (BTreeSet::from([NodeRole::Requester]), BTreeSet::new()), + "executor" => ( + BTreeSet::from([NodeRole::Executor]), + BTreeSet::from([CapabilityKind::new(SOURCE_METRICS_EXECUTOR_KIND).unwrap()]), + ), + "verifier" => ( + BTreeSet::from([NodeRole::Verifier]), + BTreeSet::from([CapabilityKind::new(SOURCE_METRICS_VERIFIER_KIND).unwrap()]), + ), + _ => panic!("unexpected profile"), + }; + assert_eq!(claims.allowed_roles, roles); + assert_eq!(claims.capability_ceiling, ceiling); let stdout = fs::read_to_string(node.state_dir.join("stdout.log")).unwrap(); let stderr = fs::read_to_string(node.state_dir.join("stderr.log")).unwrap(); assert!(!stdout.contains(sentinel)); diff --git a/tests/protocol_kernel.rs b/tests/protocol_kernel.rs index 5c302b8..ba8857e 100644 --- a/tests/protocol_kernel.rs +++ b/tests/protocol_kernel.rs @@ -1,11 +1,13 @@ use std::collections::BTreeSet; use agenet::protocol::{ - AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, CandidateSet, CapabilityId, - CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractOffer, - ContractProjection, ContractState, CredentialChain, DomainId, ErrorEnvelope, EventKind, - EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, NodeRole, ProtocolError, RouteQuery, - SealedContract, SideEffectProfile, SourceMetrics, WireEnvelope, apply_event, event_hash, + AcceptanceProfile, ArtifactId, ArtifactReadRequest, ArtifactRef, BootstrapProfile, + CandidateSet, CapabilityId, CapabilityManifest, ContractDraft, ContractEvent, ContractId, + ContractOffer, ContractProjection, ContractState, CredentialChain, DomainId, ErrorEnvelope, + EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, NodeRole, ProtocolError, + RouteQuery, SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, SOURCE_METRICS_EXECUTOR_KIND, + SOURCE_METRICS_VERIFIER_CAPABILITY_ID, SOURCE_METRICS_VERIFIER_KIND, SealedContract, + SideEffectProfile, SourceMetrics, WireEnvelope, apply_event, event_hash, }; use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::SigningKey; @@ -38,6 +40,52 @@ fn credential_chain_for_domain( node_id: &str, role: NodeRole, domain: &str, +) -> CredentialChain { + let bootstrap_profile = match role { + NodeRole::Directory => panic!("Directory credentials require the founding Domain path"), + NodeRole::Requester => BootstrapProfile::Base, + NodeRole::Executor | NodeRole::Verifier => BootstrapProfile::Provider, + }; + let allowed_roles = match bootstrap_profile { + BootstrapProfile::Base | BootstrapProfile::AgentCandidate => { + BTreeSet::from([NodeRole::Requester]) + } + BootstrapProfile::Provider => { + BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]) + } + }; + let capability_ceiling = + match role { + NodeRole::Executor => BTreeSet::from([agenet::protocol::CapabilityKind::new( + SOURCE_METRICS_EXECUTOR_KIND, + ) + .unwrap()]), + NodeRole::Verifier => BTreeSet::from([agenet::protocol::CapabilityKind::new( + SOURCE_METRICS_VERIFIER_KIND, + ) + .unwrap()]), + _ => BTreeSet::new(), + }; + credential_chain_with_authorization( + root, + node, + node_id, + domain, + bootstrap_profile, + allowed_roles, + capability_ceiling, + ) +} + +#[allow(clippy::too_many_arguments)] +fn credential_chain_with_authorization( + root: &SigningKey, + node: &SigningKey, + node_id: &str, + domain: &str, + bootstrap_profile: agenet::protocol::BootstrapProfile, + allowed_roles: BTreeSet, + capability_ceiling: BTreeSet, ) -> CredentialChain { use agenet::protocol::{ AuthorityClaims, AuthorityScope, BootstrapProfile, NodeCredentialClaims, @@ -61,19 +109,6 @@ fn credential_chain_for_domain( }, ) .expect("Authority credential issued"); - let bootstrap_profile = match role { - NodeRole::Directory => panic!("Directory credentials require the founding Domain path"), - NodeRole::Requester => BootstrapProfile::Base, - NodeRole::Executor | NodeRole::Verifier => BootstrapProfile::Provider, - }; - let allowed_roles = match bootstrap_profile { - BootstrapProfile::Base | BootstrapProfile::AgentCandidate => { - BTreeSet::from([NodeRole::Requester]) - } - BootstrapProfile::Provider => { - BTreeSet::from([NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier]) - } - }; let node_credential = authority_credential .issue_node_credential( &root.verifying_key(), @@ -86,7 +121,7 @@ fn credential_chain_for_domain( signing_public_key_base64: STANDARD.encode(node.verifying_key().to_bytes()), bootstrap_profile, allowed_roles, - capability_ceiling: BTreeSet::new(), + capability_ceiling, issued_at_ms: NOW as i64 - 1_000, expires_at_ms: NOW as i64 + 60_000, }, @@ -99,6 +134,75 @@ fn credential_chain_for_domain( } } +#[test] +fn verification_contract_accepts_a_verifier_only_credential() { + let root = signing_key(40); + let requester = signing_key(41); + let verifier = signing_key(42); + let mut verification = draft(); + verification.provider = NodeId::new("node:verifier").unwrap(); + verification.capability_id = CapabilityId::new(SOURCE_METRICS_VERIFIER_CAPABILITY_ID).unwrap(); + verification.grant.subject = verification.provider.clone(); + verification.grant.capability_id = verification.capability_id.clone(); + let credential = credential_chain_with_authorization( + &root, + &verifier, + "node:verifier", + "domain:test", + agenet::protocol::BootstrapProfile::Provider, + BTreeSet::from([NodeRole::Verifier]), + BTreeSet::from([ + agenet::protocol::CapabilityKind::new(SOURCE_METRICS_VERIFIER_KIND).unwrap(), + ]), + ); + let sealed = SealedContract::seal( + &verification, + &requester, + credential_chain(&root, &requester, "node:requester", NodeRole::Requester), + &verifier, + credential, + ) + .unwrap(); + + assert_eq!( + sealed.verify( + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NOW, + ), + Ok(verification) + ); +} + +#[test] +fn verification_contract_accepts_explicit_multi_role_provider_with_exact_ceiling() { + let root = signing_key(43); + let requester = signing_key(44); + let provider = signing_key(45); + let mut verification = draft(); + verification.provider = NodeId::new("node:multi-role").unwrap(); + verification.capability_id = CapabilityId::new(SOURCE_METRICS_VERIFIER_CAPABILITY_ID).unwrap(); + verification.grant.subject = verification.provider.clone(); + verification.grant.capability_id = verification.capability_id.clone(); + let sealed = SealedContract::seal( + &verification, + &requester, + credential_chain(&root, &requester, "node:requester", NodeRole::Requester), + &provider, + credential_chain(&root, &provider, "node:multi-role", NodeRole::Verifier), + ) + .unwrap(); + + assert_eq!( + sealed.verify( + &root.verifying_key(), + &DomainId::new("domain:test").unwrap(), + NOW, + ), + Ok(verification) + ); +} + #[test] fn legacy_v1_credential_shape_returns_stable_migration_error() { let legacy = br#"{ @@ -257,7 +361,7 @@ fn grant_enforces_subject_capability_artifact_and_expiry() { let grant = Grant { issuer: NodeId::new("node:requester").unwrap(), subject: NodeId::new("node:executor").unwrap(), - capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), artifact_id: ArtifactId::new("sha256:abcd").unwrap(), expires_at_unix_ms: NOW + 1_000, delegation_depth: 0, @@ -267,7 +371,7 @@ fn grant_enforces_subject_capability_artifact_and_expiry() { grant .allows( &NodeId::new("node:executor").unwrap(), - &CapabilityId::new("source.metrics.v1").unwrap(), + &CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), &ArtifactId::new("sha256:abcd").unwrap(), NOW, ) @@ -276,7 +380,7 @@ fn grant_enforces_subject_capability_artifact_and_expiry() { assert_eq!( grant.allows( &NodeId::new("node:verifier").unwrap(), - &CapabilityId::new("source.metrics.v1").unwrap(), + &CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), &ArtifactId::new("sha256:abcd").unwrap(), NOW, ), @@ -285,7 +389,7 @@ fn grant_enforces_subject_capability_artifact_and_expiry() { assert_eq!( grant.allows( &NodeId::new("node:executor").unwrap(), - &CapabilityId::new("source.metrics.v1").unwrap(), + &CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), &ArtifactId::new("sha256:ffff").unwrap(), NOW, ), @@ -294,7 +398,7 @@ fn grant_enforces_subject_capability_artifact_and_expiry() { assert_eq!( grant.allows( &NodeId::new("node:executor").unwrap(), - &CapabilityId::new("source.metrics.v1").unwrap(), + &CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), &ArtifactId::new("sha256:abcd").unwrap(), NOW + 1_001, ), @@ -379,11 +483,11 @@ fn draft() -> ContractDraft { intent_id: IntentId::new("intent:metrics").unwrap(), requester: NodeId::new("node:requester").unwrap(), provider: NodeId::new("node:executor").unwrap(), - capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), grant: Grant { issuer: NodeId::new("node:requester").unwrap(), subject: NodeId::new("node:executor").unwrap(), - capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), artifact_id: artifact_id.clone(), expires_at_unix_ms: NOW + 60_000, delegation_depth: 0, @@ -725,7 +829,7 @@ proptest! { let grant = Grant { issuer: NodeId::new("node:requester").unwrap(), subject: NodeId::new("node:executor").unwrap(), - capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), artifact_id: authorized, expires_at_unix_ms: NOW + 1, delegation_depth: 0, @@ -734,7 +838,7 @@ proptest! { prop_assert_eq!( grant.allows( &NodeId::new("node:executor").unwrap(), - &CapabilityId::new("source.metrics.v1").unwrap(), + &CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), &candidate, NOW, ), diff --git a/tests/runtime_storage.rs b/tests/runtime_storage.rs index df1bc24..57418dc 100644 --- a/tests/runtime_storage.rs +++ b/tests/runtime_storage.rs @@ -11,7 +11,7 @@ use agenet::{ protocol::{ AcceptanceProfile, ArtifactId, ArtifactRef, CapabilityId, ContractDraft, ContractEvent, ContractId, ContractState, CredentialChain, EventKind, Grant, IntentId, NodeId, NodeRole, - SealedContract, WireEnvelope, event_hash, + SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, SealedContract, WireEnvelope, event_hash, }, runtime::{ArtifactStore, ContractRecorder, RuntimeError, read_signing_key, write_signing_key}, }; @@ -118,11 +118,11 @@ fn contract(root: &SigningKey, requester: &SigningKey, executor: &SigningKey) -> intent_id: IntentId::new("intent:metrics").unwrap(), requester: NodeId::new("node:requester").unwrap(), provider: NodeId::new("node:executor").unwrap(), - capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), grant: Grant { issuer: NodeId::new("node:requester").unwrap(), subject: NodeId::new("node:executor").unwrap(), - capability_id: CapabilityId::new("source.metrics.v1").unwrap(), + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), artifact_id: artifact_id.clone(), expires_at_unix_ms: NOW + 60_000, delegation_depth: 0, From 74f35c4f27557a515170f3c9d9511aa92d678f1b Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 06:57:54 +0800 Subject: [PATCH 38/67] [feat][Bootstrap][13/14] Complete node lifecycle Root cause: NA Solution: Add renewal, revocation, recoverable leave and uninstall, and machine-readable diagnostics. Risks: Uninstall cannot remove overlay software outside AgenNet. Dependency: Bootstrap step 12. Links: plan/01-v1-multi-host-node-bootstrap.md --- README.md | 40 +- ROADMAP.md | 10 + docs/design/agenet-v0.1.md | 51 + plan/01-v2-multi-host-node-bootstrap.md | 26 + plan/01-v3-multi-host-node-bootstrap.md | 58 ++ src/bootstrap/config.rs | 49 +- src/bootstrap/enrollment.rs | 225 +++- src/bootstrap/identity_generation.rs | 342 +++++++ src/bootstrap/managed_binary.rs | 249 +++++ src/bootstrap/mod.rs | 11 + src/bootstrap/paths.rs | 16 +- src/cli/doctor.rs | 535 ++++++++++ src/cli/lifecycle.rs | 1252 +++++++++++++++++++++++ src/cli/mod.rs | 52 + src/cli/node.rs | 114 ++- src/cli_diagnostics.rs | 70 ++ src/lib.rs | 1 + src/protocol/error.rs | 6 + src/protocol/lifecycle.rs | 194 ++++ src/protocol/mod.rs | 14 +- src/protocol/types.rs | 19 + src/runtime/directory.rs | 42 +- src/runtime/host.rs | 57 +- src/runtime/key_store.rs | 28 + src/runtime/mod.rs | 5 +- src/runtime/revocation.rs | 143 ++- src/service/macos.rs | 13 +- src/transport/directory.rs | 54 +- src/transport/lifecycle.rs | 160 +++ src/transport/mod.rs | 3 + tests/bootstrap_config.rs | 42 +- tests/bootstrap_identity_generation.rs | 68 ++ tests/cli_bootstrap.rs | 5 + tests/doctor_cli.rs | 64 ++ tests/fixtures/service_probe.c | 18 + tests/http_directory.rs | 99 +- tests/lifecycle_cli.rs | 158 +++ tests/revocation.rs | 64 +- 38 files changed, 4302 insertions(+), 55 deletions(-) create mode 100644 plan/01-v3-multi-host-node-bootstrap.md create mode 100644 src/bootstrap/identity_generation.rs create mode 100644 src/bootstrap/managed_binary.rs create mode 100644 src/cli/doctor.rs create mode 100644 src/cli/lifecycle.rs create mode 100644 src/cli_diagnostics.rs create mode 100644 src/protocol/lifecycle.rs create mode 100644 src/transport/lifecycle.rs create mode 100644 tests/bootstrap_identity_generation.rs create mode 100644 tests/doctor_cli.rs create mode 100644 tests/fixtures/service_probe.c create mode 100644 tests/lifecycle_cli.rs diff --git a/README.md b/README.md index 238247e..55e8b17 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,40 @@ Root passphrases and complete invitations use only the controlling terminal; they are not accepted through argv, environment variables, JSON, or ordinary stdin. `node join` returns `credential_issued` instead of claiming registration or health. The current join result has no next command; -`domain init` advertises only the existing `invite create` command. User-service -start, registration, doctor, -and physical two-device proof remain later gates. +`domain init` advertises only the existing `invite create` command. + +After enrollment, the lifecycle surface is: + +```text +agenet node start|stop|status +agenet credential renew +agenet node revoke +agenet node leave +agenet uninstall [--purge] +agenet node doctor --output +``` + +Renewal keeps the Node Ed25519 identity and rotates the TLS private key through +a fresh CSR. Startup identity is stored as complete, owner-only UUID generations +with exact file hashes. A single atomic active pointer selects one generation; +once present, the runtime never combines new and legacy files. Renewal restarts +the actual user service and requires its readiness plus new-identity mTLS health +before retired-key cleanup. Cleanup uncertainty retains old material and emits a +warning rather than claiming deletion. + +`node revoke` is available only on the founding administrative host and requires +a controlling TTY, exact NodeId confirmation, and hidden Domain Root unlock. +`node leave` stops the service but retains identity, config, credentials, +journals, and audit state; Directory outage leaves a durable pending departure. +Default uninstall retains all state and removes a binary only when its recorded +path, owner, mode, device, inode, size, hash, version, basename, and approved +per-user installation root still match. `--purge` is destructive, requires both +the exact NodeId and `PURGE` on the controlling TTY, and always retains Domain +Root plus founding Authority/administrative material. + +Doctor is read-only and bounded. JSON output is deterministic and contains only +stable check code, `ok|warn|error|skipped`, sanitized message, remediation link, +and a stable overall exit code: 0 for all OK/skipped, 1 for warnings, 2 for any +error, and 3 only when report output itself fails. It does not refresh state. + +Physical two-device reachability and setup automation remain Task 14 gates. diff --git a/ROADMAP.md b/ROADMAP.md index 35d865c..b79ff21 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,15 @@ # ROADMAP +## 2026-08-15 — Task 13 lifecycle, renewal generations, and diagnostics + +- **Change**: Added mTLS credential renewal, Root-authorized revocation, signed recoverable leave, conservative uninstall/purge, trusted managed-binary metadata, and deterministic read-only doctor checks. +- **Files**: lifecycle protocol/transport/runtime/CLI modules, identity-generation and managed-binary persistence, Directory departure handling, revocation Authority state, service reconciliation, tests, README/design, and v2/v3 plan amendments. +- **Decision reason**: Lifecycle operations must preserve the Task 12 live authorization boundary while remaining recoverable across service-manager, network, and filesystem uncertainty. Enrollment bearer authentication cannot prove current mTLS/private-key possession, and arbitrary executable paths cannot be trusted for uninstall. +- **Post-mortem (技术盲区 / 计划集成缺口)**: The initial Task 13 renewal implementation atomically replaced each credential/TLS file but did not make the set atomic. A crash could therefore leave a mixture of old and new valid files. The plan also omitted the exact binary producer metadata required for safe deletion. +- **Solution**: Renewal now writes a complete hash-manifested UUID generation, syncs it, and switches one owner-only active pointer. Startup with a pointer loads exactly that generation. The real user service must restart and publish readiness before inactive generation cleanup. Managed binary deletion requires an approved per-user root and exact owner/mode/device/inode/size/hash/version match; every mismatch retains it with a stable warning. +- **Prevention**: Every future multi-file security bundle must name its commit record, crash states, adoption observable, rollback rule, and retired-resource selector before implementation. Destructive commands require a producer-owned provenance record and fd-relative exact-object deletion tests. +- **Boundary**: Loopback mTLS and filesystem-backed tests do not establish physical overlay reachability. Native adoption evidence is platform/session-specific, Linux native service behavior remains an honest gap, and Task 14 is not started. + ## 2026-08-15 — Task 12 review: bind Contract capability to Provider role - **Change**: Added one protocol-owned capability registry for exact Capability ID, versioned kind, and required Provider role; reused it in bilateral Contract verification, Recorder Event replay, Provider dispatch, Directory registration, Requester routing, Artifact reads, and manifest construction. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 5bc8b72..c26c971 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -76,6 +76,10 @@ The demo provisions an ephemeral Domain Root, one Authority, four v0.3 Node Cred | Grant and Artifact read scope | automated | protocol and Axum tests | | Journal replay and operation idempotency | automated | restart test | | Login-scoped service and host runtime | automated | validated bundle, live policy clock, exact bind, mTLS self-probe, readiness withdrawal | +| TLS credential renewal | automated/pending native | exact signed request, durable Authority idempotency, fresh CSR/key, generation pointer recovery; native service adoption gate required | +| Root-authorized node revocation | automated | exact Root signature, target/epoch/operation binding, replay and conflict tests | +| Leave and uninstall safety | automated | signed departure receipt/pending record, verified stop, exact managed-binary identity, TTY-only purge allowlist | +| Read-only diagnostics | automated | deterministic JSON, stable exit codes, no-proxy bounded checks, sentinel redaction | | Independent source metric reproduction | automated | separate implementations plus third oracle | | Four PIDs and four dynamic ports | automated | real child-process test | | Real ModelHub Intent projection | verified locally | Walkman env run `3cf7bccc-932b-4b77-9ae5-514f8a52f961` | @@ -95,3 +99,50 @@ The demo provisions an ephemeral Domain Root, one Authority, four v0.3 Node Cred - multi-party contracts, reputation, payments, and federation Each deferred feature must preserve the MVP's protocol object and Capability-handler seams or document why evidence requires changing them. + +## Node lifecycle and diagnostic boundary + +Credential renewal is a peer-mTLS effect, not enrollment bearer reuse. The +connection TLS NodeId must equal the strictly verified signed-envelope issuer, +and current credential/revocation policy is checked immediately before the +Authority mutation lock. The request proves possession of the unchanged Node +Ed25519 key and carries a fresh TLS CSR. The Authority returns the same NodeId, +Domain, roles, profile, and capability ceiling with a new credential/leaf; +exact operation replay returns the same durable result. + +Renewal persistence uses `identity-generations//` and one +`active-identity-v1.json`. Each generation is owner-only and contains the +credential, same Ed25519 private key, TLS certificate, new TLS private key, CA, +and a versioned manifest with exact hashes. Every material and directory is +synced before pointer publication. If a pointer exists, startup reads only its +generation and never falls back to legacy files. A pointer-before crash leaves +the old generation active; pointer-after recovery observes the actual pointer, +restarts the real user service, and requires runtime readiness plus mTLS health +with the new identity. Failed adoption rolls back only while the old credential +remains valid. Cleanup selects only a manifest-validated inactive UUID directory +with an exact allowlist; uncertainty retains it and emits a warning. + +Node revocation is a founding-host administrative flow. The operator must use a +controlling TTY, type the exact target NodeId, and unlock the Domain Root through +a hidden prompt. The short-lived Root authorization binds Domain, target, +expected current epoch, operation ID, and expiry. The online Authority preserves +both revoked sets and publishes epoch+1. Self/founding Authority revocation is +fail-closed and requires a future dedicated recovery design. + +Leave unregisters only manifests issued by the departing NodeId and records a +signed Directory receipt, or preserves a durable pending departure when the +Directory is unavailable. `Left` is written only after the service is verified +stopped; identity, config, credential generations, journal, and audit evidence +remain. Default uninstall deletes only the user-service artifact and a binary +whose trusted record still matches approved root, exact path/basename, owner, +mode, device, inode, size, hash, and version. Purge is controlling-TTY-only, +lists logical items first, requires exact NodeId plus `PURGE`, uses fixed +allowlists, and never deletes Root or founding Authority/admin material. + +Doctor never creates, refreshes, repairs, or rewrites state. It bounds reads and +network time, disables proxy and redirects, and checks owner/mode/type/symlink, +config schema, full credential/TLS/key binding and expiry, bind ownership, +signed revocation epoch/freshness/clock relation, Authority/Directory health, +service/process/readiness, and exact managed-binary metadata. Output omits full +paths, usernames, private IPs, raw credentials, prompts, invitations, and all +secret material. diff --git a/plan/01-v2-multi-host-node-bootstrap.md b/plan/01-v2-multi-host-node-bootstrap.md index 9825399..a4fafed 100644 --- a/plan/01-v2-multi-host-node-bootstrap.md +++ b/plan/01-v2-multi-host-node-bootstrap.md @@ -114,3 +114,29 @@ capability ceiling remain separate mandatory checks. Enrollment's `Provider` profile may explicitly enable multiple permitted roles, but the loopback demo uses four least-privilege credentials so its verification path cannot be accidentally satisfied by a Provider holding both Executor and Verifier roles. + +## Task 13 lifecycle amendment + +Task 13 adds `credential renew`, Root-authorized `node revoke`, recoverable +`node leave`, managed `uninstall [--purge]`, and deterministic read-only +`node doctor`. Renewal does not rotate the Node Ed25519 identity. It creates a +fresh TLS key and CSR over the existing authenticated mTLS session, while the +Authority preserves the exact NodeId, role set, capability ceiling, profile, +and Domain. + +The original multi-file startup layout was insufficient for renewal because a +crash could expose a valid mixture of old and new files. The focused v3 plan +replaces renewal publication with complete UUID identity generations and one +atomic active pointer. Legacy material is read only while no pointer exists. +After a pointer exists, startup never combines or falls back to legacy files. +The real user service must restart, publish readiness from the selected new +generation, and pass new-identity mTLS health before retired identity cleanup. + +Revocation requires a controlling TTY, exact target confirmation, and a +short-lived Root-signed authorization bound to target, current epoch, operation +ID, and Domain. Leave retains identity/config/journal state and records either +a signed Directory receipt or a durable pending departure. Default uninstall +removes only a service artifact and an exact trusted managed-user binary; +`--purge` requires NodeId plus `PURGE` confirmation and never removes Root or +founding administrative/Authority material. Doctor performs bounded read-only +checks with deterministic, sanitized codes and stable exit status. diff --git a/plan/01-v3-multi-host-node-bootstrap.md b/plan/01-v3-multi-host-node-bootstrap.md new file mode 100644 index 0000000..b3d80db --- /dev/null +++ b/plan/01-v3-multi-host-node-bootstrap.md @@ -0,0 +1,58 @@ +# AgenNet multi-host bootstrap plan v3 — lifecycle identity generations + +## Goal + +Close the Task 13 persistence gap discovered while implementing credential +renewal. A renewal must never expose a startup bundle assembled from old and +new credential/TLS files, and lifecycle cleanup must remain recoverable. + +## Preconditions + +- Tasks 1–12 are complete at commit `595850e`. +- Node Ed25519 identity is retained during renewal; only the TLS private key is + rotated. +- Existing installations without an active identity pointer remain readable + only as the initial legacy generation. + +## Steps + +1. Write every identity into an owner-only UUID generation directory containing + credential, Ed25519 key, TLS certificate/private key, CA, and a manifest of + exact hashes. Sync every file and directory before it is selectable. +2. Publish one versioned `active-identity-v1.json` pointer atomically. Once the + pointer exists, startup loads only that generation and never falls back to or + combines legacy files. +3. Persist renewal operation, old pointer, new pointer, and fresh TLS key before + switching. On restart, reconcile the observed pointer instead of guessing + whether a rename completed. +4. Restart the real user service and require its durable runtime-ready record + plus a new-identity mTLS health request. Roll back the pointer and restart the + old runtime only while the old credential is still valid. +5. After confirmed adoption, delete only an inactive, manifest-validated exact + generation allowlist through fd-relative operations. Unknown entries or any + durability uncertainty retain the generation and emit a repair warning. +6. Apply the same exact allowlist to destructive purge; always retain Domain + Root, founding Authority, invitation/audit administration, and revocation + authority material. + +## Acceptance criteria + +- Every crash before pointer publication loads the complete old identity; + every crash after publication loads the complete new identity; no loader path + can return a mixed bundle. +- Traversal, symlink, foreign owner/mode, hash mismatch, unknown cleanup entry, + active-generation selection, and pointer uncertainty fail closed. +- Successful renewal proves that the platform service adopted the new + generation. Cleanup uncertainty is a warning and never a false deletion + claim. +- Task 13 focused tests, full Rust gates, secret scan, and ignored evidence are + complete before Task 14 begins. + +## Risks + +- Service-manager restart is platform dependent. An unavailable user session is + a typed incomplete rotation, not simulated readiness. +- Directory registration is repeated by the restarted HostRuntime; this plan + does not add a second registration mechanism to the lifecycle CLI. +- Physical overlay verification remains Task 14 and cannot be inferred from + loopback mTLS evidence. diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index 7cf691d..738491c 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -15,7 +15,7 @@ use crate::{ use zeroize::Zeroizing; use super::{ - BootstrapError, NodePaths, + BootstrapError, NodePaths, load_active_identity_pointer, load_identity_generation, network::{NetworkBoundary, OverlayKind}, }; @@ -65,9 +65,20 @@ pub fn load_startup_bundle( now_ms: i64, ) -> Result { let config = paths.read_config()?; - let credential_bytes = paths.read_material(&paths.credential_file, 64 * 1024)?; - let credential: CredentialChain = - serde_json::from_slice(&credential_bytes).map_err(|_| BootstrapError::InvalidConfig)?; + let active = match std::fs::symlink_metadata(&paths.active_identity_file) { + Ok(_) => Some(load_identity_generation( + paths, + &load_active_identity_pointer(paths)?, + )?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(BootstrapError::UnsafeStatePath), + }; + let credential = if let Some(generation) = &active { + generation.credential.clone() + } else { + let credential_bytes = paths.read_material(&paths.credential_file, 64 * 1024)?; + serde_json::from_slice(&credential_bytes).map_err(|_| BootstrapError::InvalidConfig)? + }; let verified = verify_credential_chain( trusted_root, &credential, @@ -79,20 +90,30 @@ pub fn load_startup_bundle( if verified.bootstrap_profile != config.profile { return Err(BootstrapError::InvalidConfig); } - let signing_key = + let signing_key = if let Some(generation) = &active { + generation.signing_key.clone() + } else { crate::runtime::key_store::read_signing_key_hardened(&paths.signing_private_key_file) - .map_err(|_| BootstrapError::UnsafeStatePath)?; + .map_err(|_| BootstrapError::UnsafeStatePath)? + }; if signing_key.verifying_key() != verified.signing_public_key { return Err(BootstrapError::InvalidConfig); } - let certificate = read_utf8(&paths.tls_certificate_file, 64 * 1024)?; - let private_key = read_utf8(&paths.tls_private_key_file, 16 * 1024)?; - let authority_ca = read_utf8(&paths.tls_authority_ca_file, 64 * 1024)?; - let tls_identity = PeerTlsIdentity { - node_id: verified.node_id, - certificate_chain_pem: certificate, - private_key_pem: private_key, - authority_ca_pem: authority_ca.to_string(), + let tls_identity = if let Some(generation) = active { + if generation.tls_identity.node_id != verified.node_id { + return Err(BootstrapError::InvalidConfig); + } + generation.tls_identity + } else { + let certificate = read_utf8(&paths.tls_certificate_file, 64 * 1024)?; + let private_key = read_utf8(&paths.tls_private_key_file, 16 * 1024)?; + let authority_ca = read_utf8(&paths.tls_authority_ca_file, 64 * 1024)?; + PeerTlsIdentity { + node_id: verified.node_id, + certificate_chain_pem: certificate, + private_key_pem: private_key, + authority_ca_pem: authority_ca.to_string(), + } }; validate_persisted_peer_identity(&tls_identity, &config.network, now_ms) .map_err(|_| BootstrapError::InvalidPki)?; diff --git a/src/bootstrap/enrollment.rs b/src/bootstrap/enrollment.rs index 80da713..4fc1d13 100644 --- a/src/bootstrap/enrollment.rs +++ b/src/bootstrap/enrollment.rs @@ -23,8 +23,9 @@ use zeroize::{Zeroize, Zeroizing}; use crate::protocol::{ BootstrapProfile, CredentialChain, EnrollmentBundle, EnrollmentRequestClaims, - NodeCredentialClaims, NodeId, NodeRole, SignedAuthorityCredential, roles_for_bootstrap_profile, - sign_enrollment_claims, verify_credential_chain, verify_enrollment_claims, + NodeCredentialClaims, NodeId, NodeRole, RenewalBundle, SignedAuthorityCredential, + SignedRenewalRequest, VerifiedNodeClaims, roles_for_bootstrap_profile, sign_enrollment_claims, + verify_credential_chain, verify_enrollment_claims, verify_renewal_request, }; use crate::runtime::key_store::atomic_write_owner_only; @@ -37,6 +38,42 @@ const ENROLLMENT_WIRE_VERSION: &str = "agenet.enrollment-wire.v0.4"; const MAX_RESULT_BYTES: usize = 256 * 1024; pub(crate) const MAX_ENROLLMENT_REQUEST_BYTES: usize = 256 * 1024; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DurableRenewalResult { + format_version: String, + node_id: NodeId, + operation_id: Uuid, + request_sha256: [u8; 32], + bundle: RenewalBundle, +} + +fn read_owner_json Deserialize<'de>>( + path: &Path, +) -> Result, EnrollmentError> { + let mut file = match OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(EnrollmentError::PersistenceFailed), + }; + validate_owner_only_file(&file)?; + let mut bytes = Vec::new(); + (&mut file) + .take((MAX_RESULT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| EnrollmentError::PersistenceFailed)?; + if bytes.len() > MAX_RESULT_BYTES { + return Err(EnrollmentError::ResponseTooLarge); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| EnrollmentError::PersistenceFailed) +} + #[derive(Clone, PartialEq, Eq)] pub struct EnrollmentAttempt { pub operation_id: Uuid, @@ -369,6 +406,104 @@ impl EnrollmentAuthority { self.process_with_fault(request, now_ms, EnrollmentFault::None) } + pub(crate) fn renew( + &self, + verified: &VerifiedNodeClaims, + request: &SignedRenewalRequest, + now_ms: i64, + ) -> Result { + let _issuance_guard = self + .issuance_lock + .lock() + .map_err(|_| EnrollmentError::PersistenceFailed)?; + let claims = verify_renewal_request(request, &verified.signing_public_key) + .map_err(|_| EnrollmentError::InvalidRequest)?; + if claims.node_id != verified.node_id || claims.requested_bind_ip.is_unspecified() { + return Err(EnrollmentError::PolicyRejected); + } + let request_bytes = + serde_json::to_vec(request).map_err(|_| EnrollmentError::InvalidRequest)?; + let request_sha256: [u8; 32] = Sha256::digest(&request_bytes).into(); + let path = self + .result_directory + .join(format!("renewal-{}.json", claims.operation_id)); + if let Some(existing) = read_owner_json::(&path)? { + if existing.node_id != verified.node_id + || existing.operation_id != claims.operation_id + || existing.request_sha256 != request_sha256 + { + return Err(EnrollmentError::InvalidRequest); + } + return Ok(existing.bundle); + } + if claims.requested_at_ms.abs_diff(now_ms) > 30_000 { + return Err(EnrollmentError::PolicyRejected); + } + let total_lifetime = verified + .expires_at_ms + .checked_sub(verified.issued_at_ms) + .ok_or(EnrollmentError::PolicyRejected)?; + let renewal_opens = verified + .issued_at_ms + .checked_add(total_lifetime.saturating_mul(2) / 3) + .ok_or(EnrollmentError::PolicyRejected)?; + if now_ms < renewal_opens || now_ms >= verified.expires_at_ms { + return Err(EnrollmentError::PolicyRejected); + } + let (issued_at_ms, expires_at_ms) = self.credential_validity(now_ms)?; + let node = self + .authority_credential + .issue_node_credential( + &self.root_public_key, + &self.authority_signing_key, + NodeCredentialClaims { + format_version: "agenet.node-credential.v0.3".to_owned(), + domain_id: verified.domain_id.clone(), + authority_id: verified.authority_id.clone(), + node_id: verified.node_id.clone(), + signing_public_key_base64: STANDARD + .encode(verified.signing_public_key.to_bytes()), + bootstrap_profile: verified.bootstrap_profile, + allowed_roles: verified.allowed_roles.clone(), + capability_ceiling: verified.capability_ceiling.clone(), + issued_at_ms, + expires_at_ms, + }, + now_ms, + ) + .map_err(|_| EnrollmentError::PolicyRejected)?; + let certificate = self + .pki + .issue_peer( + &claims.tls_csr_pem, + &verified.node_id, + claims.requested_bind_ip, + issued_at_ms, + expires_at_ms, + ) + .map_err(|_| EnrollmentError::CertificateRejected)?; + let bundle = RenewalBundle { + format_version: "agenet.credential-renewal-result.v0.1".to_owned(), + operation_id: claims.operation_id, + credential_chain: CredentialChain { + authority: self.authority_credential.clone(), + node, + }, + tls_peer_certificate_pem: certificate.cert_pem, + }; + let result = DurableRenewalResult { + format_version: "agenet.credential-renewal-state.v0.1".to_owned(), + node_id: verified.node_id.clone(), + operation_id: claims.operation_id, + request_sha256, + bundle: bundle.clone(), + }; + let bytes = serde_json::to_vec(&result).map_err(|_| EnrollmentError::PersistenceFailed)?; + atomic_write_owner_only(&path, &bytes, false) + .map_err(|_| EnrollmentError::PersistenceFailed)?; + Ok(bundle) + } + fn process_with_fault( &self, request: &EnrollmentWireRequest, @@ -1037,6 +1172,7 @@ mod tests { use tempfile::TempDir; + use crate::bootstrap::NodeTlsCsr; use crate::protocol::{ AuthorityClaims, AuthorityScope, CapabilityKind, DomainId, SignedAuthorityCredential, }; @@ -1130,6 +1266,91 @@ mod tests { } } + #[test] + fn renewal_rejects_too_early_rotates_tls_key_and_recovers_exact_operation() { + let fixture = fixture(Uuid::from_u128(700)); + let wire = + EnrollmentWireRequest::from_local(&fixture.handoff, &fixture.attempt).expect("request"); + let original = fixture + .authority + .process(&wire, NOW_MS) + .expect("enrollment"); + let node_key = SigningKey::from_bytes(&[43_u8; 32]); + let early_time = NOW_MS + 60_000; + let early_csr = NodeTlsCsr::generate().expect("CSR"); + let early = crate::protocol::sign_renewal_request( + &crate::protocol::RenewalRequestClaims { + format_version: "agenet.credential-renewal.v0.1".to_owned(), + operation_id: Uuid::from_u128(701), + node_id: fixture.attempt.node_id.clone(), + requested_bind_ip: fixture.attempt.requested_bind_ip, + tls_csr_pem: early_csr.csr_pem, + requested_at_ms: early_time, + }, + &node_key, + ) + .expect("signed early request"); + let early_claims = verify_credential_chain( + &fixture.root_public_key, + &original.credential_chain, + &original.domain_id, + NodeRole::Requester, + early_time, + ) + .expect("current claims"); + assert_eq!( + fixture.authority.renew(&early_claims, &early, early_time), + Err(EnrollmentError::PolicyRejected) + ); + + let renewal_time = NOW_MS + 21 * 60_000; + let csr = NodeTlsCsr::generate().expect("fresh CSR"); + let request = crate::protocol::sign_renewal_request( + &crate::protocol::RenewalRequestClaims { + format_version: "agenet.credential-renewal.v0.1".to_owned(), + operation_id: Uuid::from_u128(702), + node_id: fixture.attempt.node_id.clone(), + requested_bind_ip: fixture.attempt.requested_bind_ip, + tls_csr_pem: csr.csr_pem, + requested_at_ms: renewal_time, + }, + &node_key, + ) + .expect("signed renewal"); + let current = verify_credential_chain( + &fixture.root_public_key, + &original.credential_chain, + &original.domain_id, + NodeRole::Requester, + renewal_time, + ) + .expect("current claims"); + let renewed = fixture + .authority + .renew(¤t, &request, renewal_time) + .expect("renewed"); + let recovered = fixture + .authority + .renew(¤t, &request, renewal_time) + .expect("recovered"); + assert_eq!(renewed, recovered); + assert_ne!( + renewed.tls_peer_certificate_pem, + original.tls_client_certificate_pem + ); + let verified = verify_credential_chain( + &fixture.root_public_key, + &renewed.credential_chain, + &original.domain_id, + NodeRole::Requester, + renewal_time, + ) + .expect("renewed chain"); + assert_eq!(verified.node_id, current.node_id); + assert_eq!(verified.allowed_roles, current.allowed_roles); + assert_eq!(verified.capability_ceiling, current.capability_ceiling); + } + #[test] fn crash_boundaries_release_before_publish_and_reconcile_after_publish() { let before = fixture(Uuid::from_u128(101)); diff --git a/src/bootstrap/identity_generation.rs b/src/bootstrap/identity_generation.rs new file mode 100644 index 0000000..ae6e066 --- /dev/null +++ b/src/bootstrap/identity_generation.rs @@ -0,0 +1,342 @@ +use std::{ + collections::BTreeMap, + fs::File, + path::{Path, PathBuf}, +}; + +use ed25519_dalek::SigningKey; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{ + protocol::{CredentialChain, NodeId}, + runtime::key_store::{ + atomic_write_owner_only_strict, ensure_owner_only_dir, read_owner_only, + write_signing_key_strict, + }, + transport::PeerTlsIdentity, +}; + +use super::{BootstrapError, NodePaths}; + +const MANIFEST: &str = "identity-manifest-v1.json"; +const CREDENTIAL: &str = "node-credential-v1.json"; +const SIGNING_KEY: &str = "node-signing-key-v1.key"; +const TLS_CERTIFICATE: &str = "peer-certificate-v1.pem"; +const TLS_PRIVATE_KEY: &str = "peer-private-key-v1.pem"; +const AUTHORITY_CA: &str = "authority-ca-v1.pem"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IdentityGenerationManifestV1 { + pub format_version: String, + pub generation_id: String, + pub node_id: NodeId, + pub file_sha256: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActiveIdentityPointerV1 { + pub format_version: String, + pub generation_id: String, + pub manifest_sha256: String, +} + +impl ActiveIdentityPointerV1 { + #[doc(hidden)] + pub fn for_test(generation_id: impl Into) -> Self { + Self { + format_version: "agenet.active-identity.v0.1".to_owned(), + generation_id: generation_id.into(), + manifest_sha256: "sha256:test".to_owned(), + } + } +} + +#[derive(Debug)] +pub struct LoadedIdentityGeneration { + pub generation_id: Uuid, + pub credential: CredentialChain, + pub signing_key: SigningKey, + pub tls_identity: PeerTlsIdentity, +} + +pub fn write_identity_generation( + paths: &NodePaths, + generation_id: Uuid, + credential: &CredentialChain, + signing_key: &SigningKey, + identity: &PeerTlsIdentity, +) -> Result { + paths.ensure_secure_layout()?; + ensure_owner_only_dir(&paths.identity_generations_dir) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + let directory = generation_directory(paths, generation_id)?; + ensure_owner_only_dir(&directory).map_err(|_| BootstrapError::UnsafeStatePath)?; + let mut credential_bytes = + serde_json::to_vec(credential).map_err(|_| BootstrapError::InvalidConfig)?; + credential_bytes.push(b'\n'); + let materials: [(&str, &[u8]); 4] = [ + (CREDENTIAL, &credential_bytes), + (TLS_CERTIFICATE, identity.certificate_chain_pem.as_bytes()), + (TLS_PRIVATE_KEY, identity.private_key_pem.as_bytes()), + (AUTHORITY_CA, identity.authority_ca_pem.as_bytes()), + ]; + let mut hashes = BTreeMap::new(); + for (name, bytes) in materials { + write_generation_file(&directory, name, bytes)?; + hashes.insert(name.to_owned(), hash(bytes)); + } + let signing_path = directory.join(SIGNING_KEY); + write_signing_key_strict(&signing_path, signing_key) + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + hashes.insert( + SIGNING_KEY.to_owned(), + hash(&read_owner_only(&signing_path, 4096).map_err(|_| BootstrapError::UnsafeStatePath)?), + ); + let manifest = IdentityGenerationManifestV1 { + format_version: "agenet.identity-generation.v0.1".to_owned(), + generation_id: generation_id.to_string(), + node_id: identity.node_id.clone(), + file_sha256: hashes, + }; + let manifest_bytes = + serde_json::to_vec(&manifest).map_err(|_| BootstrapError::InvalidConfig)?; + write_generation_file(&directory, MANIFEST, &manifest_bytes)?; + sync_directory(&directory)?; + Ok(ActiveIdentityPointerV1 { + format_version: "agenet.active-identity.v0.1".to_owned(), + generation_id: generation_id.to_string(), + manifest_sha256: hash(&manifest_bytes), + }) +} + +pub fn publish_active_identity( + paths: &NodePaths, + pointer: &ActiveIdentityPointerV1, +) -> Result<(), BootstrapError> { + validate_pointer(pointer)?; + let generation = load_identity_generation(paths, pointer)?; + if generation.generation_id.to_string() != pointer.generation_id { + return Err(BootstrapError::InvalidConfig); + } + let bytes = serde_json::to_vec(pointer).map_err(|_| BootstrapError::InvalidConfig)?; + atomic_write_owner_only_strict(&paths.active_identity_file, &bytes, true) + .map_err(|_| BootstrapError::PersistenceUnavailable) +} + +pub fn load_active_identity_pointer( + paths: &NodePaths, +) -> Result { + let bytes = read_owner_only(&paths.active_identity_file, 4096) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + let pointer: ActiveIdentityPointerV1 = + serde_json::from_slice(&bytes).map_err(|_| BootstrapError::InvalidConfig)?; + validate_pointer(&pointer)?; + Ok(pointer) +} + +pub fn load_identity_generation( + paths: &NodePaths, + pointer: &ActiveIdentityPointerV1, +) -> Result { + validate_pointer(pointer)?; + let generation_id = + Uuid::parse_str(&pointer.generation_id).map_err(|_| BootstrapError::InvalidConfig)?; + let directory = generation_directory(paths, generation_id)?; + ensure_existing_owner_directory(&directory)?; + let manifest_bytes = read_owner_only(&directory.join(MANIFEST), 64 * 1024) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + if hash(&manifest_bytes) != pointer.manifest_sha256 { + return Err(BootstrapError::InvalidConfig); + } + let manifest: IdentityGenerationManifestV1 = + serde_json::from_slice(&manifest_bytes).map_err(|_| BootstrapError::InvalidConfig)?; + if manifest.format_version != "agenet.identity-generation.v0.1" + || manifest.generation_id != pointer.generation_id + || manifest.file_sha256.len() != 5 + { + return Err(BootstrapError::InvalidConfig); + } + let read = |name: &str, limit| -> Result, BootstrapError> { + let bytes = read_owner_only(&directory.join(name), limit) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + if manifest.file_sha256.get(name) != Some(&hash(&bytes)) { + return Err(BootstrapError::InvalidConfig); + } + Ok(bytes) + }; + let credential: CredentialChain = serde_json::from_slice(&read(CREDENTIAL, 64 * 1024)?) + .map_err(|_| BootstrapError::InvalidConfig)?; + let signing_key = + crate::runtime::key_store::read_signing_key_hardened(&directory.join(SIGNING_KEY)) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + let certificate = utf8(read(TLS_CERTIFICATE, 64 * 1024)?)?; + let private_key = utf8(read(TLS_PRIVATE_KEY, 16 * 1024)?)?; + let authority_ca = utf8(read(AUTHORITY_CA, 64 * 1024)?)?; + Ok(LoadedIdentityGeneration { + generation_id, + credential, + signing_key, + tls_identity: PeerTlsIdentity { + node_id: manifest.node_id, + certificate_chain_pem: zeroize::Zeroizing::new(certificate), + private_key_pem: zeroize::Zeroizing::new(private_key), + authority_ca_pem: authority_ca, + }, + }) +} + +pub fn remove_inactive_identity_generation( + paths: &NodePaths, + retired: &ActiveIdentityPointerV1, +) -> Result<(), BootstrapError> { + let active = load_active_identity_pointer(paths)?; + if active.generation_id == retired.generation_id { + return Err(BootstrapError::InvalidConfig); + } + load_identity_generation(paths, retired)?; + let generation_id = + Uuid::parse_str(&retired.generation_id).map_err(|_| BootstrapError::InvalidConfig)?; + let directory = generation_directory(paths, generation_id)?; + let allowed = [ + MANIFEST, + CREDENTIAL, + SIGNING_KEY, + TLS_CERTIFICATE, + TLS_PRIVATE_KEY, + AUTHORITY_CA, + ]; + let entries = std::fs::read_dir(&directory).map_err(|_| BootstrapError::UnsafeStatePath)?; + for entry in entries { + let entry = entry.map_err(|_| BootstrapError::UnsafeStatePath)?; + let name = entry.file_name(); + let name = name.to_str().ok_or(BootstrapError::UnsafeStatePath)?; + if !allowed.contains(&name) { + return Err(BootstrapError::UnsafeStatePath); + } + let metadata = entry + .file_type() + .map_err(|_| BootstrapError::UnsafeStatePath)?; + if !metadata.is_file() || metadata.is_symlink() { + return Err(BootstrapError::UnsafeStatePath); + } + } + for name in allowed { + crate::runtime::key_store::remove_owner_only_user_service_file(&directory.join(name)) + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + } + crate::runtime::key_store::remove_owner_only_empty_directory(&directory) + .map_err(|_| BootstrapError::PersistenceUnavailable) +} + +pub fn purge_identity_generations(paths: &NodePaths) -> Result<(), BootstrapError> { + match std::fs::symlink_metadata(&paths.identity_generations_dir) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(BootstrapError::UnsafeStatePath), + Ok(_) => ensure_existing_owner_directory(&paths.identity_generations_dir)?, + } + let allowed = [ + MANIFEST, + CREDENTIAL, + SIGNING_KEY, + TLS_CERTIFICATE, + TLS_PRIVATE_KEY, + AUTHORITY_CA, + ]; + let entries = std::fs::read_dir(&paths.identity_generations_dir) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + for entry in entries { + let entry = entry.map_err(|_| BootstrapError::UnsafeStatePath)?; + let id = entry + .file_name() + .to_str() + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or(BootstrapError::UnsafeStatePath)?; + let directory = generation_directory(paths, id)?; + ensure_existing_owner_directory(&directory)?; + for child in std::fs::read_dir(&directory).map_err(|_| BootstrapError::UnsafeStatePath)? { + let child = child.map_err(|_| BootstrapError::UnsafeStatePath)?; + let name = child + .file_name() + .to_str() + .map(str::to_owned) + .ok_or(BootstrapError::UnsafeStatePath)?; + if !allowed.contains(&name.as_str()) + || !child.file_type().is_ok_and(|kind| kind.is_file()) + { + return Err(BootstrapError::UnsafeStatePath); + } + } + for name in allowed { + crate::runtime::key_store::remove_owner_only_user_service_file(&directory.join(name)) + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + } + crate::runtime::key_store::remove_owner_only_empty_directory(&directory) + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + } + crate::runtime::key_store::remove_owner_only_empty_directory(&paths.identity_generations_dir) + .map_err(|_| BootstrapError::PersistenceUnavailable) +} + +fn validate_pointer(pointer: &ActiveIdentityPointerV1) -> Result<(), BootstrapError> { + if pointer.format_version != "agenet.active-identity.v0.1" + || Uuid::parse_str(&pointer.generation_id).is_err() + || !pointer.manifest_sha256.starts_with("sha256:") + { + return Err(BootstrapError::InvalidConfig); + } + Ok(()) +} + +fn generation_directory(paths: &NodePaths, id: Uuid) -> Result { + let path = paths.identity_generations_dir.join(id.to_string()); + if path.parent() != Some(paths.identity_generations_dir.as_path()) { + return Err(BootstrapError::InvalidStatePath); + } + Ok(path) +} + +fn write_generation_file(directory: &Path, name: &str, bytes: &[u8]) -> Result<(), BootstrapError> { + atomic_write_owner_only_strict(&directory.join(name), bytes, false) + .map_err(|_| BootstrapError::PersistenceUnavailable) +} + +fn ensure_existing_owner_directory(path: &Path) -> Result<(), BootstrapError> { + let metadata = std::fs::symlink_metadata(path).map_err(|_| BootstrapError::UnsafeStatePath)?; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.permissions().mode() & 0o777 != 0o700 + { + return Err(BootstrapError::UnsafeStatePath); + } + Ok(()) +} + +fn sync_directory(path: &Path) -> Result<(), BootstrapError> { + File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|_| BootstrapError::PersistenceUnavailable) +} + +fn utf8(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|error| { + let _rejected = zeroize::Zeroizing::new(error.into_bytes()); + BootstrapError::InvalidPki + }) +} + +fn hash(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(7 + digest.len() * 2); + encoded.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(encoded, "{byte:02x}"); + } + encoded +} diff --git a/src/bootstrap/managed_binary.rs b/src/bootstrap/managed_binary.rs new file mode 100644 index 0000000..ce10a3e --- /dev/null +++ b/src/bootstrap/managed_binary.rs @@ -0,0 +1,249 @@ +use std::{ + ffi::CString, + fs::{File, OpenOptions}, + io::Read, + os::{fd::AsRawFd, unix::fs::MetadataExt, unix::fs::OpenOptionsExt}, + path::{Path, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const FORMAT: &str = "agenet.managed-binary.v0.1"; +const MAX_BINARY_BYTES: u64 = 256 * 1024 * 1024; +const SERVICE_METADATA_FORMAT: &str = "agenet.service-metadata.v0.3"; +const MAX_SERVICE_METADATA_BYTES: usize = 32 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedBinaryMetadataV1 { + pub format: String, + pub path: PathBuf, + pub sha256: String, + pub size: u64, + pub device: u64, + pub inode: u64, + pub owner: u32, + pub version: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManagedBinaryError { + UnsafePath, + MetadataMismatch, + IoUncertain, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServiceMetadataV3 { + pub format: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub managed_binary: Option, + pub runtime_ready: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub endpoint: Option, +} + +impl ServiceMetadataV3 { + pub fn empty(managed_binary: Option) -> Self { + Self { + format: SERVICE_METADATA_FORMAT.to_owned(), + managed_binary, + runtime_ready: false, + node_id: None, + endpoint: None, + } + } + + pub fn parse(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_SERVICE_METADATA_BYTES { + return Err(ManagedBinaryError::MetadataMismatch); + } + let value: Self = + serde_json::from_slice(bytes).map_err(|_| ManagedBinaryError::MetadataMismatch)?; + if value.format != SERVICE_METADATA_FORMAT { + return Err(ManagedBinaryError::MetadataMismatch); + } + Ok(value) + } +} + +pub fn reconcile_managed_binary_metadata( + home: &Path, + executable: &Path, + version: &str, +) -> Result, ManagedBinaryError> { + let approved = approved_path(home)?; + if executable != approved { + return Ok(None); + } + validate_parent( + home, + approved.parent().ok_or(ManagedBinaryError::UnsafePath)?, + )?; + let (mut file, metadata) = open_binary(&approved)?; + let sha256 = digest(&mut file, metadata.size)?; + Ok(Some(ManagedBinaryMetadataV1 { + format: FORMAT.to_owned(), + path: approved, + sha256, + size: metadata.size, + device: metadata.device, + inode: metadata.inode, + owner: metadata.owner, + version: version.to_owned(), + })) +} + +pub fn remove_verified_managed_binary( + home: &Path, + recorded: &ManagedBinaryMetadataV1, +) -> Result<(), ManagedBinaryError> { + let approved = approved_path(home)?; + if recorded.format != FORMAT || recorded.path != approved || recorded.owner != current_euid() { + return Err(ManagedBinaryError::MetadataMismatch); + } + let parent = approved.parent().ok_or(ManagedBinaryError::UnsafePath)?; + validate_parent(home, parent)?; + let (mut file, metadata) = open_binary(&approved)?; + if metadata.device != recorded.device + || metadata.inode != recorded.inode + || metadata.owner != recorded.owner + || metadata.size != recorded.size + || digest(&mut file, metadata.size)? != recorded.sha256 + { + return Err(ManagedBinaryError::MetadataMismatch); + } + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(parent) + .map_err(|_| ManagedBinaryError::UnsafePath)?; + let name = CString::new("agenet").map_err(|_| ManagedBinaryError::UnsafePath)?; + let mut stat = std::mem::MaybeUninit::::uninit(); + // SAFETY: `stat` is valid writable storage and both descriptors/names remain alive. + let result = unsafe { + libc::fstatat( + directory.as_raw_fd(), + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if result != 0 { + return Err(ManagedBinaryError::MetadataMismatch); + } + // SAFETY: successful fstatat initialized `stat`. + let stat = unsafe { stat.assume_init() }; + if stat.st_dev as u64 != recorded.device || stat.st_ino != recorded.inode { + return Err(ManagedBinaryError::MetadataMismatch); + } + // SAFETY: directory descriptor and NUL-terminated fixed basename are valid. + if unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) } != 0 { + return Err(ManagedBinaryError::IoUncertain); + } + directory + .sync_all() + .map_err(|_| ManagedBinaryError::IoUncertain) +} + +struct BinaryMetadata { + size: u64, + device: u64, + inode: u64, + owner: u32, +} + +fn open_binary(path: &Path) -> Result<(File, BinaryMetadata), ManagedBinaryError> { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(|_| ManagedBinaryError::MetadataMismatch)?; + let metadata = file + .metadata() + .map_err(|_| ManagedBinaryError::MetadataMismatch)?; + let mode = metadata.mode(); + if !metadata.is_file() + || metadata.uid() != current_euid() + || mode & 0o6000 != 0 + || mode & 0o022 != 0 + || metadata.len() > MAX_BINARY_BYTES + { + return Err(ManagedBinaryError::MetadataMismatch); + } + Ok(( + file, + BinaryMetadata { + size: metadata.len(), + device: metadata.dev(), + inode: metadata.ino(), + owner: metadata.uid(), + }, + )) +} + +fn digest(file: &mut File, expected: u64) -> Result { + let mut hasher = Sha256::new(); + let mut read = 0_u64; + let mut buffer = [0_u8; 8192]; + loop { + let count = file + .read(&mut buffer) + .map_err(|_| ManagedBinaryError::MetadataMismatch)?; + if count == 0 { + break; + } + read = read + .checked_add(count as u64) + .ok_or(ManagedBinaryError::MetadataMismatch)?; + if read > expected { + return Err(ManagedBinaryError::MetadataMismatch); + } + hasher.update(&buffer[..count]); + } + if read != expected { + return Err(ManagedBinaryError::MetadataMismatch); + } + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").map_err(|_| ManagedBinaryError::MetadataMismatch)?; + } + Ok(encoded) +} + +fn approved_path(home: &Path) -> Result { + if !home.is_absolute() || home == Path::new("/") { + return Err(ManagedBinaryError::UnsafePath); + } + Ok(home.join(".local/bin/agenet")) +} + +fn validate_parent(home: &Path, parent: &Path) -> Result<(), ManagedBinaryError> { + if parent != home.join(".local/bin") { + return Err(ManagedBinaryError::UnsafePath); + } + for path in [home, &home.join(".local"), parent] { + let metadata = + std::fs::symlink_metadata(path).map_err(|_| ManagedBinaryError::UnsafePath)?; + let owner = metadata.uid(); + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || (owner != 0 && owner != current_euid()) + || metadata.mode() & 0o022 != 0 + { + return Err(ManagedBinaryError::UnsafePath); + } + } + Ok(()) +} + +fn current_euid() -> u32 { + // SAFETY: geteuid has no preconditions. + unsafe { libc::geteuid() } +} diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index d2e1444..a78975b 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -2,9 +2,11 @@ mod config; mod enrollment; +mod identity_generation; mod invitation; mod journal; mod keystore; +mod managed_binary; pub mod network; mod paths; mod pki; @@ -18,6 +20,11 @@ pub use enrollment::{ pub(crate) use enrollment::{ EnrollmentWireRequest, EnrollmentWireResponse, serialize_enrollment_request, }; +pub use identity_generation::{ + ActiveIdentityPointerV1, IdentityGenerationManifestV1, LoadedIdentityGeneration, + load_active_identity_pointer, load_identity_generation, publish_active_identity, + purge_identity_generations, remove_inactive_identity_generation, write_identity_generation, +}; pub use invitation::{ ConsumptionResult, InvitationAuthentication, InvitationHandoff, InvitationPublicClaims, InvitationRecord, InvitationSpec, InvitationState, InvitationStore, ReservationStatus, @@ -27,6 +34,10 @@ pub use keystore::{ AgeRootKeystore, DomainRootMaterial, LegacyV1MigrationPolicy, RootKeystore, RootKeystoreFormatVersion, UnlockedRootKeystore, prompt_root_passphrase, }; +pub use managed_binary::{ + ManagedBinaryError, ManagedBinaryMetadataV1, ServiceMetadataV3, + reconcile_managed_binary_metadata, remove_verified_managed_binary, +}; pub use paths::{NodePathEnvironment, NodePaths, UserPlatform}; pub use pki::{ AGENET_NODE_ID_OID, AuthorityPki, IssuedClientCertificate, IssuedServerIdentity, NodeTlsCsr, diff --git a/src/bootstrap/paths.rs b/src/bootstrap/paths.rs index 82fd605..75121a3 100644 --- a/src/bootstrap/paths.rs +++ b/src/bootstrap/paths.rs @@ -61,6 +61,13 @@ pub struct NodePaths { pub invitation_state_dir: PathBuf, pub enrollment_result_dir: PathBuf, pub pending_join_file: PathBuf, + pub pending_renewal_file: PathBuf, + pub pending_renewal_private_key_file: PathBuf, + pub retired_tls_private_key_file: PathBuf, + pub identity_generations_dir: PathBuf, + pub active_identity_file: PathBuf, + pub departure_receipt_file: PathBuf, + pub pending_departure_file: PathBuf, } impl NodePaths { @@ -127,7 +134,7 @@ impl NodePaths { tls_private_key_file: state_dir.join("peer-private-key-v1.pem"), tls_authority_ca_file: state_dir.join("authority-ca-v1.pem"), service_metadata_file: state_dir.join("service-metadata-v1.json"), - revocation_file: state_dir.join("revocation-cache-v1.json"), + revocation_file: state_dir.join("revocation-cache.json"), journal_file: state_dir.join("bootstrap-state-v1.jsonl"), lock_file: state_dir.join("bootstrap-state-v1.lock"), root_keystore_file: config_dir.join("domain-root-v2.age"), @@ -139,6 +146,13 @@ impl NodePaths { invitation_state_dir: state_dir.join("invitations"), enrollment_result_dir: state_dir.join("enrollment-results-v1"), pending_join_file: state_dir.join("pending-join-v1.json"), + pending_renewal_file: state_dir.join("pending-renewal-v1.json"), + pending_renewal_private_key_file: state_dir.join("pending-renewal-key-v1.pem"), + retired_tls_private_key_file: state_dir.join("retired-peer-private-key-v1.pem"), + identity_generations_dir: state_dir.join("identity-generations"), + active_identity_file: state_dir.join("active-identity-v1.json"), + departure_receipt_file: state_dir.join("departure-receipt-v1.json"), + pending_departure_file: state_dir.join("pending-departure-v1.json"), config_dir, state_dir, service_definition, diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs new file mode 100644 index 0000000..8380067 --- /dev/null +++ b/src/cli/doctor.rs @@ -0,0 +1,535 @@ +use std::time::Duration; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use clap::{Args, ValueEnum}; +use ed25519_dalek::VerifyingKey; + +use crate::{ + bootstrap::{ + NodePaths, ServiceMetadataV3, load_active_identity_pointer, load_startup_bundle, + reconcile_managed_binary_metadata, + }, + cli_diagnostics::{CheckStatus, DoctorCheck, DoctorReport}, + protocol::{CredentialChain, NodeRole, verify_credential_chain}, + runtime::{Clock, SystemClock, inspect_revocation_cache_read_only}, +}; + +use super::{CliError, output}; + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum DoctorOutput { + Text, + Json, +} + +#[derive(Debug, Args)] +pub struct DoctorArgs { + #[arg(long, value_enum, default_value = "text")] + pub output: DoctorOutput, +} + +pub async fn execute(args: DoctorArgs) -> i32 { + let report = inspect().await; + let exit = report.exit_code(); + let format = match args.output { + DoctorOutput::Text => output::OutputFormat::Human, + DoctorOutput::Json => output::OutputFormat::Json, + }; + let human = render_text(&report); + match output::emit(format, &human, &report) { + Ok(()) => exit, + Err(_) => 3, + } +} + +async fn inspect() -> DoctorReport { + let Ok(paths) = NodePaths::for_current_user() else { + return DoctorReport::new(vec![check( + "paths.layout", + CheckStatus::Error, + "Managed user paths could not be resolved.", + "path-layout", + )]); + }; + let mut checks = vec![]; + checks.push(file_check(&paths.config_file, "config.file")); + if paths.active_identity_file.exists() { + checks.push(file_check( + &paths.active_identity_file, + "identity.active-pointer", + )); + } else { + checks.push(file_check(&paths.credential_file, "credential.file")); + checks.push(file_check( + &paths.signing_private_key_file, + "identity.signing-key", + )); + checks.push(file_check(&paths.tls_private_key_file, "identity.tls-key")); + } + let config = match paths.read_config() { + Ok(config) => { + checks.push(check( + "config.schema", + CheckStatus::Ok, + "Configuration schema is supported.", + "config-schema", + )); + Some(config) + } + Err(_) => { + checks.push(check( + "config.schema", + CheckStatus::Error, + "Configuration schema validation failed.", + "config-schema", + )); + None + } + }; + let root = read_root(&paths).ok(); + let credential = read_credential(&paths).ok(); + let now = SystemClock.now_ms(); + let role = root + .as_ref() + .zip(credential.as_ref()) + .and_then(|(root, credential)| first_role(root, credential, now)); + if let (Some(root), Some(role)) = (root.as_ref(), role) { + match load_startup_bundle(&paths, root, role, now) { + Ok(bundle) => { + checks.push(check( + "credential.chain", + CheckStatus::Ok, + "Credential and TLS identity are valid.", + "credential-invalid", + )); + checks.push( + if bundle + .credential + .node + .decode_claims_for_doctor() + .is_ok_and(|claims| { + claims.expires_at_ms.saturating_sub(now) < 24 * 60 * 60 * 1_000 + }) + { + check( + "credential.expiry", + CheckStatus::Warn, + "Credential renewal is due soon.", + "credential-renewal", + ) + } else { + check( + "credential.expiry", + CheckStatus::Ok, + "Credential expiry is within the supported window.", + "credential-renewal", + ) + }, + ); + checks.push(match bundle.config.network.validate_bind() { + Ok(()) => check( + "network.bind-ownership", + CheckStatus::Ok, + "The exact configured bind address is assigned.", + "network-boundary", + ), + Err(_) => check( + "network.bind-ownership", + CheckStatus::Error, + "The exact configured bind address is not assigned.", + "network-boundary", + ), + }); + checks.push(revocation_snapshot_check(&paths, root, &bundle, now)); + checks.extend(network_checks(&bundle).await); + } + Err(_) => checks.push(check( + "credential.chain", + CheckStatus::Error, + "Credential or TLS validation failed.", + "credential-invalid", + )), + } + } else { + checks.push(check( + "credential.chain", + CheckStatus::Error, + "Credential chain validation failed.", + "credential-invalid", + )); + } + checks.extend(service_checks(&paths)); + checks.push(binary_check(&paths)); + checks.push(if now > 0 { + check( + "clock.local", + CheckStatus::Ok, + "Local clock is usable for signed validity checks.", + "clock-skew", + ) + } else { + check( + "clock.local", + CheckStatus::Error, + "Local clock is unavailable.", + "clock-skew", + ) + }); + if config.is_none() { + checks.push(check( + "directory.seed", + CheckStatus::Skipped, + "Directory seed check was skipped because configuration is invalid.", + "directory-unavailable", + )); + } + DoctorReport::new(checks) +} + +async fn network_checks(bundle: &crate::bootstrap::PersistedStartupBundle) -> Vec { + let mut checks = vec![]; + let authority = reqwest::Certificate::from_pem(bundle.tls_identity.authority_ca_pem.as_bytes()) + .ok() + .and_then(|ca| { + reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .https_only(true) + .tls_certs_only([ca]) + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(5)) + .build() + .ok() + }); + checks.push(match authority { + Some(client) if endpoint_health(&client, &bundle.config.authority_endpoint).await => check( + "authority.reachability", + CheckStatus::Ok, + "Enrollment Authority is reachable.", + "authority-unavailable", + ), + _ => check( + "authority.reachability", + CheckStatus::Warn, + "Enrollment Authority is unreachable.", + "authority-unavailable", + ), + }); + checks.push( + match reqwest::Certificate::from_pem(bundle.tls_identity.authority_ca_pem.as_bytes()) + .ok() + .and_then(|ca| { + reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .https_only(true) + .tls_certs_only([ca]) + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(5)) + .build() + .ok() + }) { + Some(client) if endpoint_health(&client, &bundle.config.revocation_endpoint).await => { + check( + "revocation.reachability", + CheckStatus::Ok, + "Revocation Authority is reachable.", + "authority-unavailable", + ) + } + _ => check( + "revocation.reachability", + CheckStatus::Warn, + "Revocation Authority is unreachable.", + "authority-unavailable", + ), + }, + ); + let directory = bundle.config.directory_seeds.first().and_then(|seed| { + crate::transport::build_peer_client( + &bundle.tls_identity, + &bundle.config.network, + &seed.node_id, + ) + .ok() + .map(|client| (client, seed)) + }); + checks.push(match directory { + Some((client, seed)) if endpoint_health(&client, &seed.endpoint).await => check( + "directory.reachability", + CheckStatus::Ok, + "Pinned Directory is reachable over mutual TLS.", + "directory-unavailable", + ), + _ => check( + "directory.reachability", + CheckStatus::Warn, + "Pinned Directory is unreachable over mutual TLS.", + "directory-unavailable", + ), + }); + checks +} + +async fn endpoint_health(client: &reqwest::Client, endpoint: &reqwest::Url) -> bool { + let Ok(url) = endpoint.join("healthz") else { + return false; + }; + client + .get(url) + .send() + .await + .is_ok_and(|response| response.status().is_success()) +} + +fn file_check(path: &std::path::Path, code: &'static str) -> DoctorCheck { + match crate::runtime::key_store::read_owner_only(path, 256 * 1024) { + Ok(_) => check( + code, + CheckStatus::Ok, + "Owner-only regular file metadata is valid.", + "unsafe-state-file", + ), + Err(_) => check( + code, + CheckStatus::Error, + "Owner-only regular file validation failed.", + "unsafe-state-file", + ), + } +} + +fn service_checks(paths: &NodePaths) -> Vec { + let status = super::service_node::status_for_doctor(paths); + let service = match &status { + Ok(status) + if status.installed + && status.process == crate::service::ServiceProcessState::Running => + { + check( + "service.runtime", + CheckStatus::Ok, + "Login-scoped service is installed and running.", + "service-runtime", + ) + } + Ok(status) if status.installed => check( + "service.runtime", + CheckStatus::Warn, + "Login-scoped service is installed but stopped.", + "service-runtime", + ), + Ok(_) => check( + "service.runtime", + CheckStatus::Warn, + "Login-scoped service is not installed.", + "service-runtime", + ), + Err(_) => check( + "service.runtime", + CheckStatus::Error, + "Login-scoped service state is uncertain.", + "service-runtime", + ), + }; + let ready = match status { + Ok(status) if status.process != crate::service::ServiceProcessState::Running => check( + "service.runtime-ready", + CheckStatus::Skipped, + "Runtime readiness is skipped while the service is stopped.", + "service-runtime", + ), + Ok(_) if super::service_node::runtime_ready_for_lifecycle(paths).unwrap_or(false) => check( + "service.runtime-ready", + CheckStatus::Ok, + "The running service published a current readiness record.", + "service-runtime", + ), + Ok(_) => check( + "service.runtime-ready", + CheckStatus::Error, + "The running service has no valid readiness record.", + "service-runtime", + ), + Err(_) => check( + "service.runtime-ready", + CheckStatus::Skipped, + "Runtime readiness is skipped because service state is uncertain.", + "service-runtime", + ), + }; + vec![service, ready] +} + +fn binary_check(paths: &NodePaths) -> DoctorCheck { + let metadata = paths + .read_material(&paths.service_metadata_file, 32 * 1024) + .ok() + .and_then(|bytes| ServiceMetadataV3::parse(&bytes).ok()); + match metadata.and_then(|value| value.managed_binary) { + Some(binary) + if directories::BaseDirs::new() + .and_then(|base| { + reconcile_managed_binary_metadata( + base.home_dir(), + &binary.path, + env!("CARGO_PKG_VERSION"), + ) + .ok() + .flatten() + }) + .is_some_and(|current| current == binary) => + { + check( + "binary.metadata", + CheckStatus::Ok, + "Managed binary metadata matches the exact installed file.", + "binary-metadata", + ) + } + Some(_) => check( + "binary.metadata", + CheckStatus::Warn, + "Managed binary metadata version differs.", + "binary-metadata", + ), + None => check( + "binary.metadata", + CheckStatus::Warn, + "No trusted managed-binary metadata is present.", + "binary-metadata", + ), + } +} + +fn revocation_snapshot_check( + paths: &NodePaths, + root: &VerifyingKey, + bundle: &crate::bootstrap::PersistedStartupBundle, + now: i64, +) -> DoctorCheck { + match inspect_revocation_cache_read_only( + &paths.revocation_file, + root, + &bundle.config.domain_id, + &bundle.credential.authority, + ) { + Ok(snapshot) if snapshot.generated_at_ms > now.saturating_add(30_000) => check( + "revocation.snapshot", + CheckStatus::Error, + "Signed revocation time is ahead of the local clock.", + "clock-skew", + ), + Ok(snapshot) if snapshot.next_update_ms <= now || snapshot.epoch == 0 => check( + "revocation.snapshot", + CheckStatus::Error, + "The signed revocation snapshot is stale.", + "revocation-stale", + ), + Ok(_) => check( + "revocation.snapshot", + CheckStatus::Ok, + "The signed revocation epoch is current.", + "revocation-stale", + ), + Err(_) => check( + "revocation.snapshot", + CheckStatus::Error, + "The signed revocation snapshot is missing or invalid.", + "revocation-stale", + ), + } +} + +fn read_root(paths: &NodePaths) -> Result { + let bytes = paths + .read_material(&paths.root_public_key_file, 256) + .map_err(|_| invalid())?; + let decoded = STANDARD + .decode(std::str::from_utf8(&bytes).map_err(|_| invalid())?.trim()) + .map_err(|_| invalid())?; + VerifyingKey::from_bytes(&decoded.try_into().map_err(|_| invalid())?).map_err(|_| invalid()) +} +fn read_credential(paths: &NodePaths) -> Result { + if paths.active_identity_file.exists() { + let pointer = load_active_identity_pointer(paths).map_err(|_| invalid())?; + return crate::bootstrap::load_identity_generation(paths, &pointer) + .map(|generation| generation.credential) + .map_err(|_| invalid()); + } + serde_json::from_slice( + &paths + .read_material(&paths.credential_file, 64 * 1024) + .map_err(|_| invalid())?, + ) + .map_err(|_| invalid()) +} +fn first_role(root: &VerifyingKey, credential: &CredentialChain, now: i64) -> Option { + [ + NodeRole::Directory, + NodeRole::Requester, + NodeRole::Executor, + NodeRole::Verifier, + ] + .into_iter() + .find(|role| { + verify_credential_chain( + root, + credential, + &credential.authority.claims.domain_id, + *role, + now, + ) + .is_ok() + }) +} +fn check( + code: &'static str, + status: CheckStatus, + message: &'static str, + doc: &'static str, +) -> DoctorCheck { + DoctorCheck::new( + code, + status, + message, + match doc { + "path-layout" => "https://docs.agenet.dev/errors/path-layout", + "config-schema" => "https://docs.agenet.dev/errors/config-schema", + "credential-invalid" => "https://docs.agenet.dev/errors/credential-invalid", + "credential-renewal" => "https://docs.agenet.dev/errors/credential-renewal", + "network-boundary" => "https://docs.agenet.dev/errors/network-boundary", + "authority-unavailable" => "https://docs.agenet.dev/errors/authority-unavailable", + "directory-unavailable" => "https://docs.agenet.dev/errors/directory-unavailable", + "service-runtime" => "https://docs.agenet.dev/errors/service-runtime", + "binary-metadata" => "https://docs.agenet.dev/errors/binary-metadata", + "clock-skew" => "https://docs.agenet.dev/errors/clock-skew", + _ => "https://docs.agenet.dev/errors/unsafe-state-file", + }, + ) +} +fn render_text(report: &DoctorReport) -> String { + report + .checks + .iter() + .map(|check| format!("{}: {:?}: {}", check.code, check.status, check.message)) + .collect::>() + .join("\n") +} +fn invalid() -> CliError { + CliError::new( + "DoctorStateInvalid", + "Doctor could not validate local state.", + false, + ) +} + +trait SignedNodeCredentialDoctor { + fn decode_claims_for_doctor(&self) -> Result; +} +impl SignedNodeCredentialDoctor for crate::protocol::SignedNodeCredential { + fn decode_claims_for_doctor(&self) -> Result { + let bytes = STANDARD.decode(&self.claims_base64).map_err(|_| ())?; + serde_json::from_slice(&bytes).map_err(|_| ()) + } +} diff --git a/src/cli/lifecycle.rs b/src/cli/lifecycle.rs new file mode 100644 index 0000000..393f2f6 --- /dev/null +++ b/src/cli/lifecycle.rs @@ -0,0 +1,1252 @@ +use std::{path::Path, sync::Arc, time::Duration}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use clap::{Args, Subcommand}; +use ed25519_dalek::VerifyingKey; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + bootstrap::{ + ActiveIdentityPointerV1, AgeRootKeystore, BootstrapPhase, BootstrapStateStore, + BootstrapTransition, NodePaths, NodeTlsCsr, RootKeystore, ServiceMetadataV3, + load_active_identity_pointer, load_startup_bundle, publish_active_identity, + write_identity_generation, + }, + protocol::{ + CredentialChain, NodeDepartureReceipt, NodeDepartureRequest, NodeId, NodeRole, + RenewalBundle, RenewalRequestClaims, RevocationAuthorizationClaims, + SignedRevocationAuthorization, sign_renewal_request, sign_revocation_authorization, + verify_credential_chain, + }, + runtime::{ + Clock, NodeIdentity, RevocationCache, RevocationGuard, SystemClock, + key_store::atomic_write_owner_only_strict, + }, + transport::{PeerClient, PeerTlsIdentity, RevocationClient}, +}; + +use super::{ + SecretTerminal, + output::{self, CliError, OutputFormat}, +}; + +#[derive(Debug, Args)] +pub struct CredentialArgs { + #[command(subcommand)] + command: CredentialCommand, +} + +#[derive(Debug, Subcommand)] +enum CredentialCommand { + Renew(RenewArgs), +} + +#[derive(Debug, Args)] +struct RenewArgs { + #[arg(long, value_enum, default_value = "human")] + output: OutputFormat, +} + +#[derive(Debug, Args)] +pub struct RevokeArgs { + pub node_id: String, + #[arg(long, value_enum, default_value = "human")] + pub output: OutputFormat, +} + +#[derive(Debug, Args)] +pub struct LeaveArgs { + #[arg(long, value_enum, default_value = "human")] + pub output: OutputFormat, +} + +#[derive(Debug, Args)] +pub struct UninstallArgs { + #[arg(long)] + pub purge: bool, + #[arg(long, value_enum, default_value = "human")] + pub output: OutputFormat, +} + +impl CredentialArgs { + pub fn output(&self) -> OutputFormat { + match &self.command { + CredentialCommand::Renew(args) => args.output, + } + } +} + +#[derive(Serialize)] +struct RenewalResult { + node_id: String, + operation_id: Uuid, + tls_key_rotated: bool, + node_signing_key_rotated: bool, + credential_expires_at_ms: i64, + retired_identity_deleted: bool, + #[serde(skip_serializing_if = "Option::is_none")] + cleanup_warning: Option<&'static str>, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PendingRenewalV1 { + format_version: String, + request: crate::protocol::SignedRenewalRequest, + bundle: Option, + #[serde(default)] + old_pointer: Option, + #[serde(default)] + new_pointer: Option, +} + +#[derive(Serialize)] +struct RevocationResult { + target_node_id: String, + epoch: u64, +} + +#[derive(Serialize)] +struct LeaveResult { + phase: &'static str, + service_stopped: bool, + departure_recorded: bool, + pending_departure: bool, +} + +#[derive(Serialize)] +struct UninstallResult { + service_removed: bool, + binary_removed: bool, + state_retained: bool, + purged_items: Vec<&'static str>, + warnings: Vec<&'static str>, +} + +pub async fn credential(args: CredentialArgs) -> Result<(), CliError> { + match args.command { + CredentialCommand::Renew(args) => renew(args).await, + } +} + +async fn renew(args: RenewArgs) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + if let Some(result) = reconcile_completed_rotation(&paths).await? { + return output::emit( + args.output, + "AgenNet credential rotation was recovered and completed.", + &result, + ); + } + let context = LifecycleContext::load(&paths).await?; + let now = now_ms()?; + if context.cache.decision( + now, + &context.bundle.credential.authority.claims.authority_id, + &context.claims.node_id, + ) != crate::protocol::RevocationDecision::CurrentAndAllowed + { + return Err(CliError::new( + "CredentialRevoked", + "The credential cannot be renewed.", + false, + )); + } + let pending = load_incomplete_rotation(&paths)?; + let (request, private_key) = match pending { + Some(value) => value, + None => { + let csr = NodeTlsCsr::generate().map_err(map_bootstrap)?; + let request = sign_renewal_request( + &RenewalRequestClaims { + format_version: "agenet.credential-renewal.v0.1".to_owned(), + operation_id: Uuid::new_v4(), + node_id: context.claims.node_id.clone(), + requested_bind_ip: context.bundle.config.network.bind_ip, + tls_csr_pem: csr.csr_pem, + requested_at_ms: now, + }, + &context.bundle.signing_key, + ) + .map_err(|_| invalid_state())?; + persist_pending_renewal(&paths, &request, &csr.private_key_pem, None, None, None)?; + (request, csr.private_key_pem) + } + }; + let operation_id = request.claims.operation_id; + let envelope = context + .identity + .seal("credential.renew.v1", &request) + .map_err(|_| invalid_state())?; + let seed = context + .bundle + .config + .directory_seeds + .first() + .ok_or_else(invalid_state)?; + let bundle: RenewalBundle = context + .client + .post_signed_to_peer( + seed.endpoint.as_str(), + &seed.node_id, + "/v0/credentials/renew", + &envelope, + "credential.renewed.v1", + NodeRole::Directory, + ) + .await + .map_err(|error| map_renew_transport(error, operation_id))?; + validate_renewal_bundle(&context, &bundle, &private_key, now)?; + persist_pending_renewal( + &paths, + &request, + &private_key, + Some(bundle.clone()), + None, + None, + )?; + let old_pointer = ensure_active_generation(&paths, &context)?; + let new_pointer = stage_rotation(&paths, &context, &bundle, &private_key)?; + persist_pending_renewal( + &paths, + &request, + &private_key, + Some(bundle.clone()), + Some(old_pointer.clone()), + Some(new_pointer.clone()), + )?; + publish_active_identity(&paths, &new_pointer).map_err(map_bootstrap)?; + if let Err(error) = activate_rotated_runtime(&paths, &context, &bundle, &private_key).await { + if context.claims.expires_at_ms > now_ms()? { + publish_active_identity(&paths, &old_pointer).map_err(map_bootstrap)?; + let _ = super::service_node::restart_for_rotation(&paths); + } + return Err(error); + } + let retired_identity_deleted = cleanup_rotation(&paths, Some(&old_pointer)); + output::emit( + args.output, + "AgenNet credential and TLS identity were renewed.", + &RenewalResult { + node_id: context.claims.node_id.as_str().to_owned(), + operation_id, + tls_key_rotated: true, + node_signing_key_rotated: false, + credential_expires_at_ms: bundle + .credential_chain + .node + .decode_claims_for_cli()? + .expires_at_ms, + retired_identity_deleted, + cleanup_warning: (!retired_identity_deleted).then_some( + "The new identity is active, but retired identity cleanup requires repair.", + ), + }, + ) +} + +pub async fn revoke(args: RevokeArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { + let target = NodeId::new(&args.node_id).map_err(|_| invalid_arguments())?; + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + let context = LifecycleContext::load(&paths).await?; + if !context.claims.allowed_roles.contains(&NodeRole::Directory) + || target == context.claims.node_id + { + return Err(CliError::new( + "RevocationNotAllowed", + "The founding administrative node cannot revoke itself through this command.", + false, + )); + } + let confirmation = terminal + .prompt_text(&format!( + "Type the exact Node ID to revoke ({}): ", + target.as_str() + )) + .map_err(map_bootstrap)?; + if confirmation != target.as_str() { + return Err(CliError::new( + "ConfirmationMismatch", + "Revocation confirmation did not match.", + false, + )); + } + let root = AgeRootKeystore::unlock( + &paths.root_keystore_file, + terminal + .prompt_hidden("AgenNet Domain Root passphrase: ") + .map_err(map_bootstrap)?, + ) + .map_err(map_bootstrap)?; + if root.domain_id != context.bundle.config.domain_id { + return Err(CliError::new( + "RootFingerprintMismatch", + "The Root does not own this Domain.", + false, + )); + } + let expected_epoch = context.cache.epoch().ok_or_else(|| { + CliError::new( + "RevocationStateStale", + "The current revocation epoch is unavailable.", + true, + ) + })?; + let now = now_ms()?; + let authorization: SignedRevocationAuthorization = sign_revocation_authorization( + &RevocationAuthorizationClaims { + format_version: "agenet.revocation-authorization.v0.1".to_owned(), + domain_id: root.domain_id.clone(), + target_node_id: target.clone(), + expected_current_epoch: expected_epoch, + operation_id: Uuid::new_v4(), + issued_at_ms: now, + expires_at_ms: now.saturating_add(60_000), + }, + &root.signing_key, + ) + .map_err(|_| invalid_state())?; + let envelope = context + .identity + .seal("node.revoke.v1", &authorization) + .map_err(|_| invalid_state())?; + let seed = context + .bundle + .config + .directory_seeds + .first() + .ok_or_else(invalid_state)?; + let snapshot: crate::protocol::RevocationSnapshot = context + .client + .post_signed_to_peer( + seed.endpoint.as_str(), + &seed.node_id, + "/v0/nodes/revoke", + &envelope, + "revocation.published.v1", + NodeRole::Directory, + ) + .await + .map_err(|_| { + CliError::new( + "AuthorityUnavailable", + "The revocation Authority is unavailable.", + true, + ) + })?; + output::emit( + args.output, + "The node was revoked.", + &RevocationResult { + target_node_id: target.as_str().to_owned(), + epoch: snapshot.claims.epoch, + }, + ) +} + +pub async fn leave(args: LeaveArgs) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + let phase = BootstrapStateStore::open(&paths.journal_file) + .map_err(map_bootstrap)? + .phase(); + if phase == BootstrapPhase::Left { + return output::emit( + args.output, + "The node has already left AgenNet.", + &LeaveResult { + phase: "left", + service_stopped: true, + departure_recorded: true, + pending_departure: false, + }, + ); + } + let context = LifecycleContext::load(&paths).await?; + let departure = send_departure(&paths, &context).await; + let service_stopped = super::service_node::stop_for_lifecycle(&paths)?; + let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; + state + .apply( + &format!("node-leave-{}", Uuid::new_v4()), + BootstrapTransition::Leave, + ) + .map_err(map_bootstrap)?; + let recorded = departure.is_ok(); + output::emit( + args.output, + if recorded { + "The node left AgenNet." + } else { + "The node stopped locally; Directory departure remains pending." + }, + &LeaveResult { + phase: "left", + service_stopped, + departure_recorded: recorded, + pending_departure: !recorded, + }, + ) +} + +pub fn uninstall(args: UninstallArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + let service_removed = super::service_node::uninstall_for_lifecycle(&paths)?; + let (binary_removed, mut warnings) = remove_managed_binary(&paths)?; + let mut purged_items = vec![]; + if args.purge { + let node_id = read_node_id(&paths)?; + let logical = purge_logical_items(); + output::emit( + args.output, + "Purge will remove the listed node-local identity and audit items.", + &logical, + )?; + if terminal + .prompt_text(&format!("Type the exact Node ID ({}): ", node_id.as_str())) + .map_err(map_bootstrap)? + != node_id.as_str() + || terminal + .prompt_text("Type PURGE to confirm destructive removal: ") + .map_err(map_bootstrap)? + != "PURGE" + { + return Err(CliError::new( + "ConfirmationMismatch", + "Purge confirmation did not match.", + false, + )); + } + purged_items = purge_node_state(&paths)?; + warnings.push("Domain Root and founding administrative material were retained."); + } + output::emit( + args.output, + "AgenNet was uninstalled.", + &UninstallResult { + service_removed, + binary_removed, + state_retained: !args.purge, + purged_items, + warnings, + }, + ) +} + +struct LifecycleContext { + root: VerifyingKey, + bundle: crate::bootstrap::PersistedStartupBundle, + claims: crate::protocol::VerifiedNodeClaims, + identity: NodeIdentity, + cache: RevocationCache, + client: PeerClient, +} + +impl LifecycleContext { + async fn load(paths: &NodePaths) -> Result { + let root = read_root(paths)?; + let credential = read_credential(paths)?; + let now = now_ms()?; + let role = first_role(&root, &credential, now)?; + let bundle = load_startup_bundle(paths, &root, role, now).map_err(map_bootstrap)?; + let claims = + verify_credential_chain(&root, &credential, &bundle.config.domain_id, role, now) + .map_err(|_| { + CliError::new( + "CredentialExpired", + "The current credential is invalid or expired.", + false, + ) + })?; + let cache = RevocationCache::open( + &paths.state_dir, + root, + bundle.config.domain_id.clone(), + bundle.credential.authority.clone(), + ) + .map_err(|_| invalid_state())?; + if cache.decision(now, &claims.authority_id, &claims.node_id) + != crate::protocol::RevocationDecision::CurrentAndAllowed + { + let refresh = RevocationClient::new_with_authority_ca( + &bundle.config.network, + bundle.config.revocation_endpoint.clone(), + &bundle.tls_identity.authority_ca_pem, + Duration::from_secs(2), + Duration::from_secs(5), + ) + .map_err(|_| { + CliError::new( + "AuthorityUnavailable", + "The revocation Authority is unavailable.", + true, + ) + })?; + refresh.refresh(&cache, now).await.map_err(|_| { + CliError::new( + "AuthorityUnavailable", + "The revocation Authority is unavailable.", + true, + ) + })?; + } + let clock: Arc = Arc::new(SystemClock); + let identity = NodeIdentity::new_with_clock( + bundle.signing_key.clone(), + bundle.credential.clone(), + role, + root, + Arc::clone(&clock), + ) + .map_err(|_| invalid_state())?; + let guard = RevocationGuard::new(cache.clone()); + let client = PeerClient::new_mtls_dynamic( + root, + bundle.config.domain_id.clone(), + clock, + bundle.config.network.clone(), + bundle.tls_identity.clone(), + ) + .and_then(|client| client.with_local_policy(identity.clone(), guard)) + .map_err(|_| invalid_state())?; + Ok(Self { + root, + bundle, + claims, + identity, + cache, + client, + }) + } +} + +async fn send_departure( + paths: &NodePaths, + context: &LifecycleContext, +) -> Result { + let request = NodeDepartureRequest { + format_version: "agenet.node-departure.v0.1".to_owned(), + operation_id: Uuid::new_v4(), + node_id: context.claims.node_id.clone(), + requested_at_ms: now_ms()?, + }; + let envelope = context + .identity + .seal("node.departure.v1", &request) + .map_err(|_| invalid_state())?; + atomic_write_owner_only_strict( + &paths.pending_departure_file, + &serde_json::to_vec(&envelope).map_err(|_| invalid_state())?, + true, + ) + .map_err(|_| invalid_state())?; + let seed = context + .bundle + .config + .directory_seeds + .first() + .ok_or_else(invalid_state)?; + let receipt = context + .client + .post_signed_to_peer( + seed.endpoint.as_str(), + &seed.node_id, + "/v0/nodes/depart", + &envelope, + "node.departure.receipt.v1", + NodeRole::Directory, + ) + .await + .map_err(|_| { + CliError::new( + "DirectoryUnavailable", + "The Directory is unavailable.", + true, + ) + })?; + atomic_write_owner_only_strict( + &paths.departure_receipt_file, + &serde_json::to_vec(&receipt).map_err(|_| invalid_state())?, + true, + ) + .map_err(|_| invalid_state())?; + crate::runtime::key_store::remove_owner_only_user_service_file(&paths.pending_departure_file) + .map_err(|_| invalid_state())?; + Ok(receipt) +} + +fn persist_pending_renewal( + paths: &NodePaths, + request: &crate::protocol::SignedRenewalRequest, + key: &str, + bundle: Option, + old_pointer: Option, + new_pointer: Option, +) -> Result<(), CliError> { + let bytes = serde_json::to_vec(&PendingRenewalV1 { + format_version: "agenet.pending-renewal.v0.1".to_owned(), + request: request.clone(), + bundle, + old_pointer, + new_pointer, + }) + .map_err(|_| invalid_state())?; + atomic_write_owner_only_strict(&paths.pending_renewal_file, &bytes, true) + .map_err(|_| invalid_state())?; + atomic_write_owner_only_strict( + &paths.pending_renewal_private_key_file, + key.as_bytes(), + true, + ) + .map_err(|_| invalid_state()) +} + +fn load_pending( + paths: &NodePaths, +) -> Result)>, CliError> { + let request_exists = paths.pending_renewal_file.exists(); + let key_exists = paths.pending_renewal_private_key_file.exists(); + if !request_exists && !key_exists { + return Ok(None); + } + if request_exists != key_exists { + return Err(CliError::new( + "RotationRecoveryRequired", + "Credential rotation state is partial and requires repair.", + false, + )); + } + let bytes = paths + .read_material(&paths.pending_renewal_file, 256 * 1024) + .map_err(map_bootstrap)?; + let pending: PendingRenewalV1 = serde_json::from_slice(&bytes).map_err(|_| invalid_state())?; + if pending.format_version != "agenet.pending-renewal.v0.1" { + return Err(invalid_state()); + } + let key = paths + .read_material(&paths.pending_renewal_private_key_file, 16 * 1024) + .map_err(map_bootstrap)?; + let key = String::from_utf8(key.to_vec()) + .map(zeroize::Zeroizing::new) + .map_err(|_| invalid_state())?; + Ok(Some((pending, key))) +} + +fn load_incomplete_rotation( + paths: &NodePaths, +) -> Result< + Option<( + crate::protocol::SignedRenewalRequest, + zeroize::Zeroizing, + )>, + CliError, +> { + match load_pending(paths)? { + Some((pending, key)) if pending.bundle.is_none() => Ok(Some((pending.request, key))), + Some(_) => Err(CliError::new( + "RotationRecoveryRequired", + "A completed pending rotation must be reconciled before renewal.", + true, + )), + None => Ok(None), + } +} + +async fn reconcile_completed_rotation( + paths: &NodePaths, +) -> Result, CliError> { + let Some((pending, key)) = load_pending(paths)? else { + return Ok(None); + }; + let Some(bundle) = pending.bundle.clone() else { + return Ok(None); + }; + let old_pointer = pending + .old_pointer + .clone() + .or_else(|| load_active_identity_pointer(paths).ok()); + let root = read_root(paths)?; + let config = paths.read_config().map_err(map_bootstrap)?; + let signing_key = match pending + .new_pointer + .as_ref() + .or(pending.old_pointer.as_ref()) + { + Some(pointer) => { + crate::bootstrap::load_identity_generation(paths, pointer) + .map_err(map_bootstrap)? + .signing_key + } + None => crate::runtime::key_store::read_signing_key(&paths.signing_private_key_file) + .map_err(|_| invalid_state())?, + }; + let now = now_ms()?; + let mut verified = None; + for role in [ + NodeRole::Directory, + NodeRole::Requester, + NodeRole::Executor, + NodeRole::Verifier, + ] { + if let Ok(claims) = verify_credential_chain( + &root, + &bundle.credential_chain, + &config.domain_id, + role, + now, + ) { + verified = Some(claims); + break; + } + } + let claims = verified.ok_or_else(invalid_state)?; + if claims.signing_public_key != signing_key.verifying_key() { + return Err(invalid_state()); + } + let ca = match pending + .new_pointer + .as_ref() + .or(pending.old_pointer.as_ref()) + { + Some(pointer) => { + crate::bootstrap::load_identity_generation(paths, pointer) + .map_err(map_bootstrap)? + .tls_identity + .authority_ca_pem + } + None => { + let ca = paths + .read_material(&paths.tls_authority_ca_file, 64 * 1024) + .map_err(map_bootstrap)?; + std::str::from_utf8(&ca) + .map_err(|_| invalid_state())? + .to_owned() + } + }; + let identity = PeerTlsIdentity { + node_id: claims.node_id.clone(), + certificate_chain_pem: zeroize::Zeroizing::new(bundle.tls_peer_certificate_pem.clone()), + private_key_pem: key.clone(), + authority_ca_pem: ca, + }; + crate::transport::validate_persisted_peer_identity(&identity, &config.network, now) + .map_err(|_| invalid_state())?; + let new_pointer = match pending.new_pointer { + Some(pointer) => pointer, + None => write_identity_generation( + paths, + Uuid::new_v4(), + &bundle.credential_chain, + &signing_key, + &identity, + ) + .map_err(map_bootstrap)?, + }; + persist_pending_renewal( + paths, + &pending.request, + &key, + Some(bundle.clone()), + old_pointer.clone(), + Some(new_pointer.clone()), + )?; + publish_active_identity(paths, &new_pointer).map_err(map_bootstrap)?; + let role = claims + .allowed_roles + .iter() + .next() + .copied() + .ok_or_else(invalid_state)?; + let old_bundle = load_startup_bundle(paths, &root, role, now).map_err(map_bootstrap)?; + let clock: Arc = Arc::new(SystemClock); + let recovered_context = LifecycleContext { + root, + claims: claims.clone(), + identity: NodeIdentity::new_with_clock( + signing_key.clone(), + bundle.credential_chain.clone(), + role, + root, + Arc::clone(&clock), + ) + .map_err(|_| invalid_state())?, + cache: RevocationCache::open( + &paths.state_dir, + root, + config.domain_id.clone(), + bundle.credential_chain.authority.clone(), + ) + .map_err(|_| invalid_state())?, + client: PeerClient::new_mtls_dynamic( + root, + config.domain_id.clone(), + clock, + config.network.clone(), + identity.clone(), + ) + .map_err(|_| invalid_state())?, + bundle: old_bundle, + }; + activate_rotated_runtime(paths, &recovered_context, &bundle, &key).await?; + let retired_identity_deleted = cleanup_rotation(paths, old_pointer.as_ref()); + Ok(Some(RenewalResult { + node_id: claims.node_id.as_str().to_owned(), + operation_id: pending.request.claims.operation_id, + tls_key_rotated: true, + node_signing_key_rotated: false, + credential_expires_at_ms: claims.expires_at_ms, + retired_identity_deleted, + cleanup_warning: (!retired_identity_deleted) + .then_some("The new identity is active, but retired identity cleanup requires repair."), + })) +} + +fn validate_renewal_bundle( + context: &LifecycleContext, + bundle: &RenewalBundle, + key: &str, + now: i64, +) -> Result<(), CliError> { + if bundle.format_version != "agenet.credential-renewal-result.v0.1" { + return Err(invalid_state()); + } + let mut verified = None; + for role in &context.claims.allowed_roles { + if let Ok(claims) = verify_credential_chain( + &context.root, + &bundle.credential_chain, + &context.bundle.config.domain_id, + *role, + now, + ) { + verified = Some(claims); + break; + } + } + let claims = verified.ok_or_else(invalid_state)?; + if claims.node_id != context.claims.node_id + || claims.allowed_roles != context.claims.allowed_roles + || claims.capability_ceiling != context.claims.capability_ceiling + || claims.signing_public_key != context.claims.signing_public_key + { + return Err(invalid_state()); + } + let identity = PeerTlsIdentity { + node_id: claims.node_id, + certificate_chain_pem: zeroize::Zeroizing::new(bundle.tls_peer_certificate_pem.clone()), + private_key_pem: zeroize::Zeroizing::new(key.to_owned()), + authority_ca_pem: context.bundle.tls_identity.authority_ca_pem.to_string(), + }; + crate::transport::validate_persisted_peer_identity( + &identity, + &context.bundle.config.network, + now, + ) + .map_err(|_| invalid_state()) +} + +fn ensure_active_generation( + paths: &NodePaths, + context: &LifecycleContext, +) -> Result { + if paths.active_identity_file.exists() { + return load_active_identity_pointer(paths).map_err(map_bootstrap); + } + let pointer = write_identity_generation( + paths, + Uuid::new_v4(), + &context.bundle.credential, + &context.bundle.signing_key, + &context.bundle.tls_identity, + ) + .map_err(map_bootstrap)?; + publish_active_identity(paths, &pointer).map_err(map_bootstrap)?; + Ok(pointer) +} + +fn stage_rotation( + paths: &NodePaths, + context: &LifecycleContext, + bundle: &RenewalBundle, + key: &str, +) -> Result { + let identity = PeerTlsIdentity { + node_id: context.claims.node_id.clone(), + certificate_chain_pem: zeroize::Zeroizing::new(bundle.tls_peer_certificate_pem.clone()), + private_key_pem: zeroize::Zeroizing::new(key.to_owned()), + authority_ca_pem: context.bundle.tls_identity.authority_ca_pem.to_string(), + }; + write_identity_generation( + paths, + Uuid::new_v4(), + &bundle.credential_chain, + &context.bundle.signing_key, + &identity, + ) + .map_err(map_bootstrap) +} + +async fn activate_rotated_runtime( + paths: &NodePaths, + context: &LifecycleContext, + bundle: &RenewalBundle, + key: &str, +) -> Result<(), CliError> { + super::service_node::restart_for_rotation(paths)?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + if super::service_node::runtime_ready_for_lifecycle(paths).unwrap_or(false) { + break; + } + if tokio::time::Instant::now() >= deadline { + return Err(CliError::new( + "RotationReconnectFailed", + "The rotated runtime did not become ready; recovery state was retained.", + true, + )); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let identity = PeerTlsIdentity { + node_id: context.claims.node_id.clone(), + certificate_chain_pem: zeroize::Zeroizing::new(bundle.tls_peer_certificate_pem.clone()), + private_key_pem: zeroize::Zeroizing::new(key.to_owned()), + authority_ca_pem: context.bundle.tls_identity.authority_ca_pem.to_string(), + }; + let seed = context + .bundle + .config + .directory_seeds + .first() + .ok_or_else(invalid_state)?; + let client = crate::transport::build_peer_client( + &identity, + &context.bundle.config.network, + &seed.node_id, + ) + .map_err(|_| invalid_state())?; + let health = seed.endpoint.join("healthz").map_err(|_| invalid_state())?; + let response = client.get(health).send().await.map_err(|_| { + CliError::new( + "RotationReconnectFailed", + "The rotated TLS identity could not reconnect; pending recovery state was retained.", + true, + ) + })?; + if !response.status().is_success() { + return Err(CliError::new( + "RotationReconnectFailed", + "The rotated TLS identity could not reconnect; pending recovery state was retained.", + true, + )); + } + Ok(()) +} + +fn cleanup_rotation(paths: &NodePaths, old: Option<&ActiveIdentityPointerV1>) -> bool { + let pointer_is_current = load_active_identity_pointer(paths) + .ok() + .is_some_and(|active| { + old.is_none_or(|retired| active.generation_id != retired.generation_id) + }); + if !pointer_is_current { + return false; + } + let mut complete = true; + if let Some(old) = old { + complete &= crate::bootstrap::remove_inactive_identity_generation(paths, old).is_ok(); + } + for path in [ + &paths.credential_file, + &paths.signing_private_key_file, + &paths.tls_certificate_file, + &paths.tls_private_key_file, + &paths.tls_authority_ca_file, + &paths.retired_tls_private_key_file, + ] { + complete &= crate::runtime::key_store::remove_owner_only_user_service_file(path).is_ok(); + } + if complete { + for path in [ + &paths.pending_renewal_private_key_file, + &paths.pending_renewal_file, + ] { + complete &= + crate::runtime::key_store::remove_owner_only_user_service_file(path).is_ok(); + } + } + complete +} + +fn remove_managed_binary(paths: &NodePaths) -> Result<(bool, Vec<&'static str>), CliError> { + let bytes = match paths.read_material(&paths.service_metadata_file, 32 * 1024) { + Ok(bytes) => bytes, + Err(_) => { + return Ok(( + false, + vec!["No trusted managed-binary record exists; the executable was retained."], + )); + } + }; + let metadata = match ServiceMetadataV3::parse(&bytes) { + Ok(metadata) => metadata, + Err(_) => { + return Ok(( + false, + vec!["Managed-binary metadata is invalid; the executable was retained."], + )); + } + }; + let Some(binary) = metadata.managed_binary else { + return Ok(( + false, + vec!["The executable is not an AgenNet-managed user binary and was retained."], + )); + }; + let base = directories::BaseDirs::new().ok_or_else(invalid_state)?; + crate::bootstrap::remove_verified_managed_binary(base.home_dir(), &binary).map_err(|_| { + CliError::new( + "ManagedBinaryMismatch", + "The recorded binary no longer matches and was retained.", + false, + ) + })?; + Ok((true, vec![])) +} + +fn purge_logical_items() -> Vec<&'static str> { + vec![ + "node-config", + "identity-generations", + "bootstrap-journal", + "revocation-cache", + "pending-rotation", + "departure-state", + "service-metadata", + ] +} + +fn purge_node_state(paths: &NodePaths) -> Result, CliError> { + crate::bootstrap::purge_identity_generations(paths).map_err(|_| { + CliError::new( + "PurgeIncomplete", + "Purge stopped at an uncertain filesystem boundary; remaining state was retained.", + false, + ) + })?; + let items: [(&str, &Path); 17] = [ + ("node-config", &paths.config_file), + ("node-credential", &paths.credential_file), + ("node-signing-key", &paths.signing_private_key_file), + ("peer-certificate", &paths.tls_certificate_file), + ("peer-private-key", &paths.tls_private_key_file), + ("peer-ca", &paths.tls_authority_ca_file), + ("bootstrap-journal", &paths.journal_file), + ("revocation-cache", &paths.revocation_file), + ("pending-join", &paths.pending_join_file), + ("pending-renewal", &paths.pending_renewal_file), + ( + "pending-renewal-key", + &paths.pending_renewal_private_key_file, + ), + ("retired-tls-key", &paths.retired_tls_private_key_file), + ("active-identity", &paths.active_identity_file), + ("departure-receipt", &paths.departure_receipt_file), + ("pending-departure", &paths.pending_departure_file), + ("bootstrap-lock", &paths.lock_file), + ("service-metadata", &paths.service_metadata_file), + ]; + let mut removed = vec![]; + for (name, path) in items { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => { + return Err(CliError::new( + "PurgeIncomplete", + "Purge stopped at an uncertain filesystem boundary; remaining state was retained.", + false, + )); + } + Ok(_) => {} + } + crate::runtime::key_store::remove_owner_only_user_service_file(path).map_err(|_| { + CliError::new( + "PurgeIncomplete", + "Purge stopped at an uncertain filesystem boundary; remaining state was retained.", + false, + ) + })?; + removed.push(name); + } + Ok(removed) +} + +fn read_root(paths: &NodePaths) -> Result { + let bytes = paths + .read_material(&paths.root_public_key_file, 256) + .map_err(map_bootstrap)?; + let decoded = STANDARD + .decode( + std::str::from_utf8(&bytes) + .map_err(|_| invalid_state())? + .trim(), + ) + .map_err(|_| invalid_state())?; + VerifyingKey::from_bytes(&decoded.try_into().map_err(|_| invalid_state())?) + .map_err(|_| invalid_state()) +} +fn read_credential(paths: &NodePaths) -> Result { + if paths.active_identity_file.exists() { + let pointer = load_active_identity_pointer(paths).map_err(map_bootstrap)?; + return crate::bootstrap::load_identity_generation(paths, &pointer) + .map(|generation| generation.credential) + .map_err(map_bootstrap); + } + serde_json::from_slice( + &paths + .read_material(&paths.credential_file, 64 * 1024) + .map_err(map_bootstrap)?, + ) + .map_err(|_| invalid_state()) +} +fn read_node_id(paths: &NodePaths) -> Result { + let credential = read_credential(paths)?; + credential + .node + .decode_claims_for_cli() + .map(|claims| claims.node_id) +} +fn first_role( + root: &VerifyingKey, + credential: &CredentialChain, + now: i64, +) -> Result { + [ + NodeRole::Directory, + NodeRole::Requester, + NodeRole::Executor, + NodeRole::Verifier, + ] + .into_iter() + .find(|role| { + verify_credential_chain( + root, + credential, + &credential.authority.claims.domain_id, + *role, + now, + ) + .is_ok() + }) + .ok_or_else(invalid_state) +} +fn now_ms() -> Result { + let now = SystemClock.now_ms(); + (now > 0).then_some(now).ok_or_else(invalid_state) +} +fn map_renew_transport(error: crate::transport::TransportError, operation_id: Uuid) -> CliError { + let (code, message) = match error { + crate::transport::TransportError::NonSuccessStatus(409) => ( + "RenewalNotAllowed", + "The credential is not yet eligible for renewal.", + ), + crate::transport::TransportError::NonSuccessStatus(403) => { + ("CredentialRevoked", "The credential is revoked.") + } + _ => ( + "AuthorityUnavailable", + "The renewal Authority is unavailable.", + ), + }; + CliError::new(code, message, code == "AuthorityUnavailable") + .with_operation(operation_id.to_string()) +} +fn map_bootstrap(_: crate::bootstrap::BootstrapError) -> CliError { + invalid_state() +} +fn invalid_state() -> CliError { + CliError::new( + "BootstrapStateInvalid", + "The persisted AgenNet state is invalid.", + false, + ) +} +fn invalid_arguments() -> CliError { + CliError::new( + "InvalidArguments", + "The lifecycle command arguments are invalid.", + false, + ) +} + +trait SignedNodeCredentialCli { + fn decode_claims_for_cli(&self) -> Result; +} +impl SignedNodeCredentialCli for crate::protocol::SignedNodeCredential { + fn decode_claims_for_cli(&self) -> Result { + let bytes = STANDARD + .decode(&self.claims_base64) + .map_err(|_| invalid_state())?; + serde_json::from_slice(&bytes).map_err(|_| invalid_state()) + } +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::{PermissionsExt, symlink}; + + use tempfile::TempDir; + + use super::*; + + fn paths() -> (TempDir, NodePaths) { + let temp = TempDir::new().unwrap(); + let home = temp.path().canonicalize().unwrap(); + std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap(); + let paths = NodePaths::resolve( + crate::bootstrap::UserPlatform::MacOs, + &crate::bootstrap::NodePathEnvironment::new(home, None, None), + ) + .unwrap(); + paths.ensure_secure_layout().unwrap(); + (temp, paths) + } + + fn owner_file(path: &Path, bytes: &[u8]) { + std::fs::write(path, bytes).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + + #[test] + fn purge_removes_only_allowlist_and_retains_root_and_authority_material() { + let (_temp, paths) = paths(); + owner_file(&paths.root_keystore_file, b"root-retained"); + owner_file(&paths.authority_signing_key_file, b"authority-retained"); + owner_file(&paths.credential_file, b"node-credential"); + owner_file(&paths.signing_private_key_file, b"node-key"); + let removed = purge_node_state(&paths).unwrap(); + assert!(removed.contains(&"node-credential")); + assert!(removed.contains(&"node-signing-key")); + assert!(!paths.credential_file.exists()); + assert!(!paths.signing_private_key_file.exists()); + assert_eq!( + std::fs::read(&paths.root_keystore_file).unwrap(), + b"root-retained" + ); + assert_eq!( + std::fs::read(&paths.authority_signing_key_file).unwrap(), + b"authority-retained" + ); + } + + #[test] + fn purge_rejects_symlink_without_deleting_target() { + let (_temp, paths) = paths(); + let target = paths.state_dir.join("outside-sentinel"); + owner_file(&target, b"keep-me"); + symlink(&target, &paths.credential_file).unwrap(); + let error = purge_node_state(&paths).unwrap_err(); + assert_eq!(error.code, "PurgeIncomplete"); + assert_eq!(std::fs::read(target).unwrap(), b"keep-me"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0edce11..ae19833 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,8 @@ +mod doctor; mod domain; mod invite; mod join; +mod lifecycle; mod output; #[path = "node.rs"] mod service_node; @@ -20,6 +22,9 @@ trait SecretTerminal { &self, handoff: &crate::bootstrap::InvitationHandoff, ) -> Result<(), crate::bootstrap::BootstrapError>; + fn prompt_text(&self, _prompt: &str) -> Result { + Err(crate::bootstrap::BootstrapError::HandoffTtyUnavailable) + } } #[cfg(test)] @@ -51,6 +56,26 @@ impl SecretTerminal for ControllingTerminal { ) -> Result<(), crate::bootstrap::BootstrapError> { crate::bootstrap::display_invitation_handoff_to_tty(handoff) } + fn prompt_text(&self, prompt: &str) -> Result { + use std::io::{BufRead, Write}; + let tty = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .map_err(|_| crate::bootstrap::BootstrapError::HandoffTtyUnavailable)?; + let mut writer = tty + .try_clone() + .map_err(|_| crate::bootstrap::BootstrapError::HandoffTtyUnavailable)?; + writer + .write_all(prompt.as_bytes()) + .and_then(|_| writer.flush()) + .map_err(|_| crate::bootstrap::BootstrapError::HandoffTtyUnavailable)?; + let mut line = String::new(); + std::io::BufReader::new(tty) + .read_line(&mut line) + .map_err(|_| crate::bootstrap::BootstrapError::HandoffTtyUnavailable)?; + Ok(line.trim_end_matches(['\r', '\n']).to_owned()) + } } use std::path::PathBuf; @@ -66,6 +91,8 @@ enum Command { Domain(domain::DomainArgs), Invite(invite::InviteArgs), Node(Box), + Credential(lifecycle::CredentialArgs), + Uninstall(lifecycle::UninstallArgs), Demo(DemoArgs), } @@ -107,6 +134,9 @@ enum NodeBootstrapCommand { Start(service_node::ServiceArgs), Stop(service_node::ServiceArgs), Status(service_node::ServiceArgs), + Doctor(doctor::DoctorArgs), + Revoke(lifecycle::RevokeArgs), + Leave(lifecycle::LeaveArgs), #[command(hide = true)] ServiceRun(service_node::ServiceRunArgs), } @@ -195,12 +225,34 @@ async fn run_cli(cli: Cli) -> i32 { let format = service_args.output; (format, service_node::status(service_args)) } + Some(NodeBootstrapCommand::Doctor(doctor_args)) => { + return doctor::execute(doctor_args).await; + } + Some(NodeBootstrapCommand::Revoke(revoke_args)) => { + let format = revoke_args.output; + ( + format, + lifecycle::revoke(revoke_args, &ControllingTerminal).await, + ) + } + Some(NodeBootstrapCommand::Leave(leave_args)) => { + let format = leave_args.output; + (format, lifecycle::leave(leave_args).await) + } Some(NodeBootstrapCommand::ServiceRun(service_args)) => ( OutputFormat::Human, service_node::service_run(service_args).await, ), None => (OutputFormat::Human, run_internal_node(*args).await), }, + Command::Credential(args) => { + let format = args.output(); + (format, lifecycle::credential(args).await) + } + Command::Uninstall(args) => { + let format = args.output; + (format, lifecycle::uninstall(args, &ControllingTerminal)) + } Command::Demo(args) => ( OutputFormat::Human, demo::run(demo::DemoOptions { diff --git a/src/cli/node.rs b/src/cli/node.rs index 2b00f94..eaabe2d 100644 --- a/src/cli/node.rs +++ b/src/cli/node.rs @@ -39,6 +39,7 @@ pub fn start(args: ServiceArgs) -> Result<(), CliError> { let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; let manager = platform_manager(service_spec(&paths)?); start_with(&paths, manager.as_ref())?; + record_managed_binary(&paths)?; emit_status( args.output, &paths, @@ -47,6 +48,33 @@ pub fn start(args: ServiceArgs) -> Result<(), CliError> { ) } +fn record_managed_binary(paths: &NodePaths) -> Result<(), CliError> { + let base = directories::BaseDirs::new().ok_or_else(internal)?; + let executable = std::env::current_exe().map_err(|_| internal())?; + let Some(binary) = crate::bootstrap::reconcile_managed_binary_metadata( + base.home_dir(), + &executable, + env!("CARGO_PKG_VERSION"), + ) + .map_err(|_| internal())? + else { + return Ok(()); + }; + let mut metadata = paths + .read_material(&paths.service_metadata_file, 32 * 1024) + .ok() + .and_then(|bytes| crate::bootstrap::ServiceMetadataV3::parse(&bytes).ok()) + .unwrap_or_else(|| crate::bootstrap::ServiceMetadataV3::empty(None)); + metadata.managed_binary = Some(binary); + let bytes = serde_json::to_vec(&metadata).map_err(|_| internal())?; + crate::runtime::key_store::atomic_write_owner_only_strict( + &paths.service_metadata_file, + &bytes, + true, + ) + .map_err(|_| internal()) +} + pub fn stop(args: ServiceArgs) -> Result<(), CliError> { let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; let manager = platform_manager(service_spec(&paths)?); @@ -201,11 +229,10 @@ fn runtime_ready(paths: &NodePaths, status: &crate::service::ServiceStatus) -> b else { return false; }; - let Ok(value) = serde_json::from_slice::(&bytes) else { + let Ok(value) = crate::bootstrap::ServiceMetadataV3::parse(&bytes) else { return false; }; - value.get("format") == Some(&serde_json::json!("agenet.runtime-ready.v0.2")) - && value.get("runtime_ready") == Some(&serde_json::json!(true)) + value.runtime_ready } fn current_service_phase(paths: &NodePaths) -> Result { @@ -221,6 +248,87 @@ fn current_service_phase(paths: &NodePaths) -> Result .map_err(map_bootstrap) } +pub(crate) fn stop_for_lifecycle(paths: &NodePaths) -> Result { + let phase = current_service_phase(paths)?; + if !matches!( + phase, + BootstrapPhase::ServicePrepared + | BootstrapPhase::Registered + | BootstrapPhase::Healthy + | BootstrapPhase::Left + ) { + return Ok(false); + } + let manager = platform_manager(service_spec(paths)?); + manager.stop().map_err(map_service)?; + let status = manager.status().map_err(map_service)?; + if status.process != crate::service::ServiceProcessState::Stopped { + return Err(CliError::new( + "ServiceStopUncertain", + "The user service stop could not be verified.", + true, + )); + } + Ok(true) +} + +pub(crate) fn uninstall_for_lifecycle(paths: &NodePaths) -> Result { + let manager = platform_manager(service_spec(paths)?); + let before = manager.status().map_err(map_service)?; + if !before.installed && before.process == crate::service::ServiceProcessState::Stopped { + return Ok(false); + } + manager.uninstall().map_err(map_service)?; + let after = manager.status().map_err(map_service)?; + if after.installed + || after.process != crate::service::ServiceProcessState::Stopped + || !service_artifact_absent(&paths.service_definition)? + { + return Err(CliError::new( + "ServiceUninstallUncertain", + "The user service removal could not be verified.", + true, + )); + } + Ok(true) +} + +pub(crate) fn status_for_doctor( + paths: &NodePaths, +) -> Result { + platform_manager(service_spec(paths)?) + .status() + .map_err(map_service) +} + +pub(crate) fn restart_for_rotation(paths: &NodePaths) -> Result<(), CliError> { + let manager = platform_manager(service_spec(paths)?); + manager.stop().map_err(map_service)?; + let stopped = manager.status().map_err(map_service)?; + if stopped.process != crate::service::ServiceProcessState::Stopped { + return Err(CliError::new( + "ServiceRestartUncertain", + "The user service did not reach the stopped state.", + true, + )); + } + manager.start().map_err(map_service)?; + let started = manager.status().map_err(map_service)?; + if started.process != crate::service::ServiceProcessState::Running { + return Err(CliError::new( + "ServiceRestartUncertain", + "The user service did not reach the running state.", + true, + )); + } + Ok(()) +} + +pub(crate) fn runtime_ready_for_lifecycle(paths: &NodePaths) -> Result { + let status = status_for_doctor(paths)?; + Ok(runtime_ready(paths, &status)) +} + fn service_spec(paths: &NodePaths) -> Result { let label = if cfg!(target_os = "linux") { "agenet" diff --git a/src/cli_diagnostics.rs b/src/cli_diagnostics.rs new file mode 100644 index 0000000..7275e77 --- /dev/null +++ b/src/cli_diagnostics.rs @@ -0,0 +1,70 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CheckStatus { + Ok, + Warn, + Error, + Skipped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DoctorCheck { + pub code: &'static str, + pub status: CheckStatus, + pub message: &'static str, + pub remediation: &'static str, +} + +impl DoctorCheck { + pub const fn new( + code: &'static str, + status: CheckStatus, + message: &'static str, + remediation: &'static str, + ) -> Self { + Self { + code, + status, + message, + remediation, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DoctorReport { + pub format: &'static str, + pub checks: Vec, +} + +impl DoctorReport { + pub fn new(mut checks: Vec) -> Self { + checks.sort_by_key(|check| check.code); + Self { + format: "agenet.doctor.v0.1", + checks, + } + } + + pub fn exit_code(&self) -> i32 { + if self + .checks + .iter() + .any(|check| check.status == CheckStatus::Error) + { + 2 + } else if self + .checks + .iter() + .any(|check| check.status == CheckStatus::Warn) + { + 1 + } else { + 0 + } + } +} diff --git a/src/lib.rs b/src/lib.rs index b0223e1..90d001f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod adapters; pub mod bootstrap; pub mod cli; +pub mod cli_diagnostics; pub mod demo; pub mod node; pub mod protocol; diff --git a/src/protocol/error.rs b/src/protocol/error.rs index 1cc0990..3fba99c 100644 --- a/src/protocol/error.rs +++ b/src/protocol/error.rs @@ -23,6 +23,10 @@ pub enum ProtocolError { CredentialIssuerMismatch, InvalidEnrollmentRequest, InvalidEnrollmentSignature, + InvalidRenewalRequest, + InvalidRenewalSignature, + InvalidRevocationAuthorization, + InvalidRevocationAuthorizationSignature, InvalidRevocationSnapshot, InvalidRevocationSignature, InvalidRevocationTimeWindow, @@ -30,6 +34,8 @@ pub enum ProtocolError { RevocationSetTooLarge, UnsupportedRevocationVersion, UnsupportedEnrollmentVersion, + UnsupportedRenewalVersion, + UnsupportedRevocationAuthorizationVersion, UnsupportedNodeCredentialVersion, EnrollmentRequestTooLarge, InvalidEnvelopeSignature, diff --git a/src/protocol/lifecycle.rs b/src/protocol/lifecycle.rs new file mode 100644 index 0000000..a973a0c --- /dev/null +++ b/src/protocol/lifecycle.rs @@ -0,0 +1,194 @@ +use std::net::IpAddr; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{CredentialChain, DomainId, NodeId, ProtocolError}; + +const RENEWAL_VERSION: &str = "agenet.credential-renewal.v0.1"; +const RENEWAL_DOMAIN: &[u8] = b"AGENET\0credential-renewal-v0.1\0"; +const REVOCATION_AUTHORIZATION_VERSION: &str = "agenet.revocation-authorization.v0.1"; +const REVOCATION_AUTHORIZATION_DOMAIN: &[u8] = b"AGENET\0revocation-authorization-v0.1\0"; +const MAX_CSR_BYTES: usize = 16 * 1024; +const MAX_AUTHORIZATION_LIFETIME_MS: i64 = 60_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RenewalRequestClaims { + pub format_version: String, + pub operation_id: Uuid, + pub node_id: NodeId, + pub requested_bind_ip: IpAddr, + pub tls_csr_pem: String, + pub requested_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignedRenewalRequest { + pub claims: RenewalRequestClaims, + pub exact_claims_base64: String, + pub signature_base64: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RenewalBundle { + pub format_version: String, + pub operation_id: Uuid, + pub credential_chain: CredentialChain, + pub tls_peer_certificate_pem: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RevocationAuthorizationClaims { + pub format_version: String, + pub domain_id: DomainId, + pub target_node_id: NodeId, + pub expected_current_epoch: u64, + pub operation_id: Uuid, + pub issued_at_ms: i64, + pub expires_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignedRevocationAuthorization { + pub claims: RevocationAuthorizationClaims, + pub exact_claims_base64: String, + pub signature_base64: String, +} + +pub fn sign_renewal_request( + claims: &RenewalRequestClaims, + signing_key: &SigningKey, +) -> Result { + validate_renewal(claims)?; + let exact = serde_json::to_vec(claims).map_err(|_| ProtocolError::SerializationFailed)?; + let signature = signing_key.sign(&signature_message(RENEWAL_DOMAIN, &exact)); + Ok(SignedRenewalRequest { + claims: claims.clone(), + exact_claims_base64: STANDARD.encode(&exact), + signature_base64: STANDARD.encode(signature.to_bytes()), + }) +} + +pub fn verify_renewal_request( + request: &SignedRenewalRequest, + verifying_key: &VerifyingKey, +) -> Result { + let exact = STANDARD + .decode(&request.exact_claims_base64) + .map_err(|_| ProtocolError::InvalidRenewalRequest)?; + let claims: RenewalRequestClaims = + serde_json::from_slice(&exact).map_err(|_| ProtocolError::InvalidRenewalRequest)?; + if claims != request.claims { + return Err(ProtocolError::InvalidRenewalRequest); + } + validate_renewal(&claims)?; + verify_signature( + verifying_key, + RENEWAL_DOMAIN, + &exact, + &request.signature_base64, + ProtocolError::InvalidRenewalSignature, + )?; + Ok(claims) +} + +pub fn sign_revocation_authorization( + claims: &RevocationAuthorizationClaims, + root: &SigningKey, +) -> Result { + validate_revocation_authorization(claims, claims.issued_at_ms)?; + let exact = serde_json::to_vec(claims).map_err(|_| ProtocolError::SerializationFailed)?; + let signature = root.sign(&signature_message(REVOCATION_AUTHORIZATION_DOMAIN, &exact)); + Ok(SignedRevocationAuthorization { + claims: claims.clone(), + exact_claims_base64: STANDARD.encode(&exact), + signature_base64: STANDARD.encode(signature.to_bytes()), + }) +} + +pub fn verify_revocation_authorization( + authorization: &SignedRevocationAuthorization, + root: &VerifyingKey, + expected_domain: &DomainId, + now_ms: i64, +) -> Result { + let exact = STANDARD + .decode(&authorization.exact_claims_base64) + .map_err(|_| ProtocolError::InvalidRevocationAuthorization)?; + let claims: RevocationAuthorizationClaims = serde_json::from_slice(&exact) + .map_err(|_| ProtocolError::InvalidRevocationAuthorization)?; + if claims != authorization.claims || &claims.domain_id != expected_domain { + return Err(ProtocolError::InvalidRevocationAuthorization); + } + validate_revocation_authorization(&claims, now_ms)?; + verify_signature( + root, + REVOCATION_AUTHORIZATION_DOMAIN, + &exact, + &authorization.signature_base64, + ProtocolError::InvalidRevocationAuthorizationSignature, + )?; + Ok(claims) +} + +fn validate_renewal(claims: &RenewalRequestClaims) -> Result<(), ProtocolError> { + if claims.format_version != RENEWAL_VERSION { + return Err(ProtocolError::UnsupportedRenewalVersion); + } + if claims.operation_id.is_nil() + || claims.requested_at_ms <= 0 + || claims.tls_csr_pem.is_empty() + || claims.tls_csr_pem.len() > MAX_CSR_BYTES + { + return Err(ProtocolError::InvalidRenewalRequest); + } + Ok(()) +} + +fn validate_revocation_authorization( + claims: &RevocationAuthorizationClaims, + now_ms: i64, +) -> Result<(), ProtocolError> { + if claims.format_version != REVOCATION_AUTHORIZATION_VERSION { + return Err(ProtocolError::UnsupportedRevocationAuthorizationVersion); + } + let lifetime = claims.expires_at_ms.checked_sub(claims.issued_at_ms); + if claims.operation_id.is_nil() + || claims.expected_current_epoch == 0 + || claims.issued_at_ms <= 0 + || lifetime.is_none_or(|value| value <= 0 || value > MAX_AUTHORIZATION_LIFETIME_MS) + || now_ms < claims.issued_at_ms.saturating_sub(30_000) + || now_ms >= claims.expires_at_ms + { + return Err(ProtocolError::InvalidRevocationAuthorization); + } + Ok(()) +} + +fn verify_signature( + key: &VerifyingKey, + domain: &[u8], + exact: &[u8], + encoded: &str, + error: ProtocolError, +) -> Result<(), ProtocolError> { + let bytes = STANDARD.decode(encoded).map_err(|_| error.clone())?; + let signature = Signature::from_slice(&bytes).map_err(|_| error.clone())?; + key.verify_strict(&signature_message(domain, exact), &signature) + .map_err(|_| error) +} + +fn signature_message(domain: &[u8], exact: &[u8]) -> Vec { + let mut message = Vec::with_capacity(domain.len() + 8 + exact.len()); + message.extend_from_slice(domain); + message.extend_from_slice(&(exact.len() as u64).to_be_bytes()); + message.extend_from_slice(exact); + message +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 4f993b1..6b2b1ce 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -5,6 +5,7 @@ mod enrollment; mod envelope; mod error; mod identity; +mod lifecycle; mod revocation; mod sealed_contract; mod types; @@ -30,16 +31,23 @@ pub use error::ProtocolError; pub use identity::{ BootstrapProfile, NodeCredentialClaims, SignedNodeCredential, VerifiedNodeClaims, }; +pub use lifecycle::{ + RenewalBundle, RenewalRequestClaims, RevocationAuthorizationClaims, SignedRenewalRequest, + SignedRevocationAuthorization, sign_renewal_request, sign_revocation_authorization, + verify_renewal_request, verify_revocation_authorization, +}; pub use revocation::{ - REVOCATION_FORMAT_VERSION, RevocationClaims, RevocationDecision, RevocationSnapshot, + MAX_REVOKED_IDENTITIES_PER_SET, REVOCATION_FORMAT_VERSION, RevocationClaims, + RevocationDecision, RevocationSnapshot, }; pub use sealed_contract::{ContractOffer, SealedContract}; pub use types::{ AcceptanceProfile, ArtifactId, ArtifactPayload, ArtifactReadRequest, ArtifactRef, CandidateSet, CapabilityId, CapabilityKind, CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractProposeRequest, ContractProposeResponse, ContractQuery, ContractState, DirectorySeed, - DomainId, ErrorEnvelope, EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, NodeId, - NodeRole, RouteQuery, SideEffectProfile, SourceMetrics, + DomainId, ErrorEnvelope, EventKind, EvidenceClaim, Grant, IntentId, IntentProjection, + NodeDepartureReceipt, NodeDepartureRequest, NodeId, NodeRole, RouteQuery, SideEffectProfile, + SourceMetrics, }; pub const KERNEL_VERSION: &str = crate::KERNEL_VERSION_V2; diff --git a/src/protocol/types.rs b/src/protocol/types.rs index 3159a05..258073c 100644 --- a/src/protocol/types.rs +++ b/src/protocol/types.rs @@ -135,6 +135,25 @@ pub struct CandidateSet { pub candidates: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NodeDepartureRequest { + pub format_version: String, + pub operation_id: uuid::Uuid, + pub node_id: NodeId, + pub requested_at_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NodeDepartureReceipt { + pub format_version: String, + pub operation_id: uuid::Uuid, + pub node_id: NodeId, + pub removed_manifests: u64, + pub recorded_at_ms: i64, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct IntentProjection { pub intent_id: IntentId, diff --git a/src/runtime/directory.rs b/src/runtime/directory.rs index c974ff1..f42f701 100644 --- a/src/runtime/directory.rs +++ b/src/runtime/directory.rs @@ -3,8 +3,8 @@ use std::{collections::HashMap, net::IpAddr, sync::Arc}; use tokio::sync::RwLock; use crate::protocol::{ - CandidateSet, CapabilityId, CapabilityKind, CapabilityManifest, NodeId, OpenedEnvelope, - RouteQuery, + CandidateSet, CapabilityId, CapabilityKind, CapabilityManifest, NodeDepartureReceipt, + NodeDepartureRequest, NodeId, OpenedEnvelope, RouteQuery, }; use super::{RevocationGuard, RuntimeError, revocation::VerifiedRevocationSubject}; @@ -18,6 +18,7 @@ struct RegisteredManifest { #[derive(Debug, Clone, Default)] pub struct DirectoryRegistry { manifests: Arc>>, + departures: Arc>>, } impl DirectoryRegistry { @@ -53,6 +54,43 @@ impl DirectoryRegistry { Ok(()) } + pub(crate) async fn unregister_opened( + &self, + opened: OpenedEnvelope, + now_ms: i64, + ) -> Result { + let (request, claims) = opened.into_parts(); + if request.format_version != "agenet.node-departure.v0.1" + || request.operation_id.is_nil() + || request.node_id != claims.node_id + || request.requested_at_ms.abs_diff(now_ms) > 30_000 + { + return Err(RuntimeError::ManifestProviderMismatch); + } + let mut departures = self.departures.write().await; + if let Some(existing) = departures.get(&request.operation_id) { + return if existing.node_id == request.node_id { + Ok(existing.clone()) + } else { + Err(RuntimeError::ManifestProviderMismatch) + }; + } + let mut manifests = self.manifests.write().await; + let before = manifests.len(); + manifests.retain(|_, entry| entry.manifest.provider != request.node_id); + let removed = before.saturating_sub(manifests.len()); + drop(manifests); + let receipt = NodeDepartureReceipt { + format_version: "agenet.node-departure-receipt.v0.1".to_owned(), + operation_id: request.operation_id, + node_id: request.node_id, + removed_manifests: u64::try_from(removed).unwrap_or(u64::MAX), + recorded_at_ms: now_ms, + }; + departures.insert(request.operation_id, receipt.clone()); + Ok(receipt) + } + pub async fn query(&self, query: RouteQuery, now_unix_ms: u64) -> CandidateSet { let mut candidates: Vec<_> = self .manifests diff --git a/src/runtime/host.rs b/src/runtime/host.rs index 9d772a6..de2687b 100644 --- a/src/runtime/host.rs +++ b/src/runtime/host.rs @@ -279,15 +279,22 @@ impl HostRuntime { > { if self.roles.contains(&NodeRole::Directory) { let identity = self.identity(NodeRole::Directory)?; - return Ok(( - directory_router_with_revocation( - DirectoryRegistry::new(), + let guard = RevocationGuard::new(self.revocations.as_ref().clone()); + let mut router = directory_router_with_revocation( + DirectoryRegistry::new(), + identity.clone(), + u64::try_from(self.clock.now_ms()).unwrap_or(u64::MAX), + guard.clone(), + ); + if let Some(founding) = self.founding.as_ref() { + router = router.merge(crate::transport::lifecycle_authority_router( identity, - u64::try_from(self.clock.now_ms()).unwrap_or(u64::MAX), - RevocationGuard::new(self.revocations.as_ref().clone()), - ), - None, - )); + Arc::clone(&founding.enrollment), + Arc::clone(&founding.revocations), + guard, + )); + } + return Ok((router, None)); } let provider_roles: BTreeSet<_> = self .roles @@ -656,18 +663,38 @@ fn write_ready( endpoint: &Url, node_id: &crate::protocol::NodeId, ) -> Result<(), RuntimeError> { - let bytes = serde_json::to_vec(&serde_json::json!({ - "format": "agenet.runtime-ready.v0.2", - "node_id": node_id, - "endpoint": endpoint, - "runtime_ready": true, - }))?; + let managed_binary = match paths.read_material(&paths.service_metadata_file, 32 * 1024) { + Ok(bytes) => crate::bootstrap::ServiceMetadataV3::parse(&bytes) + .ok() + .and_then(|metadata| metadata.managed_binary), + Err(_) => None, + }; + let mut metadata = crate::bootstrap::ServiceMetadataV3::empty(managed_binary); + metadata.runtime_ready = true; + metadata.node_id = Some(node_id.as_str().to_owned()); + metadata.endpoint = Some(endpoint.as_str().to_owned()); + let bytes = serde_json::to_vec(&metadata)?; super::key_store::atomic_write_owner_only_strict(&paths.service_metadata_file, &bytes, true) .map_err(|_| RuntimeError::Io) } fn remove_ready(paths: &NodePaths) -> Result<(), RuntimeError> { - super::key_store::remove_owner_only_user_service_file(&paths.service_metadata_file) + let bytes = match paths.read_material(&paths.service_metadata_file, 32 * 1024) { + Ok(bytes) => bytes, + Err(_) => return Ok(()), + }; + let Ok(mut metadata) = crate::bootstrap::ServiceMetadataV3::parse(&bytes) else { + return super::key_store::remove_owner_only_user_service_file(&paths.service_metadata_file); + }; + metadata.runtime_ready = false; + metadata.node_id = None; + metadata.endpoint = None; + if metadata.managed_binary.is_none() { + return super::key_store::remove_owner_only_user_service_file(&paths.service_metadata_file); + } + let encoded = serde_json::to_vec(&metadata)?; + super::key_store::atomic_write_owner_only_strict(&paths.service_metadata_file, &encoded, true) + .map_err(|_| RuntimeError::Io) } impl From for RuntimeError { diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index 4a331b8..a9cd251 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -200,6 +200,34 @@ pub(crate) fn remove_owner_only_user_service_file(path: &Path) -> Result<(), Run remove_owner_only_user_service_file_with_hook(path, || {}) } +pub(crate) fn remove_owner_only_empty_directory(path: &Path) -> Result<(), RuntimeError> { + let (parent, name) = open_secure_service_parent(path, false)?; + let directory = match openat_directory(&parent, &name) { + Ok(directory) => directory, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(RuntimeError::Io), + }; + let opened = directory.metadata()?; + if opened.uid() != unsafe { libc::geteuid() } || opened.mode() & 0o777 != 0o700 { + return Err(RuntimeError::Io); + } + let mut current = std::mem::MaybeUninit::::uninit(); + cvt(unsafe { + libc::fstatat( + parent.as_raw_fd(), + name.as_ptr(), + current.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + })?; + let current = unsafe { current.assume_init() }; + if opened.dev() != current.st_dev as u64 || opened.ino() != current.st_ino { + return Err(RuntimeError::Io); + } + cvt(unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), libc::AT_REMOVEDIR) })?; + parent.sync_all().map_err(Into::into) +} + fn remove_owner_only_user_service_file_with_hook( path: &Path, after_walk: AfterWalk, diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 5dbb323..2eaa2c8 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -24,6 +24,9 @@ pub use node::serve_peer_tls; pub use provider::ProviderService; pub use recorder::ContractRecorder; pub use requester::{PursuitQuery, PursuitRequest, PursuitResult, RequesterService}; -pub use revocation::{AuthorityRevocationStore, RevocationCache, RevocationGuard}; +pub use revocation::{ + AuthorityRevocationStore, RevocationCache, RevocationCacheInspection, RevocationGuard, + inspect_revocation_cache_read_only, merge_revoked_nodes, +}; pub const MAX_ARTIFACT_BYTES: usize = 64 * 1024; diff --git a/src/runtime/revocation.rs b/src/runtime/revocation.rs index 5666d0b..21e1bb5 100644 --- a/src/runtime/revocation.rs +++ b/src/runtime/revocation.rs @@ -17,9 +17,23 @@ use crate::protocol::{ use super::{RuntimeError, key_store::atomic_write_owner_only}; +pub fn merge_revoked_nodes( + existing: &BTreeSet, + target: NodeId, +) -> Result, RuntimeError> { + if existing.len() >= crate::protocol::MAX_REVOKED_IDENTITIES_PER_SET + && !existing.contains(&target) + { + return Err(RuntimeError::CorruptRevocationState); + } + let mut merged = existing.clone(); + merged.insert(target); + Ok(merged) +} + const AUTHORITY_STATE_FILE: &str = "revocation-authority.json"; const CACHE_FILE: &str = "revocation-cache.json"; -const AUTHORITY_STATE_VERSION: &str = "agenet.revocation-authority-state.v0.2"; +const AUTHORITY_STATE_VERSION: &str = "agenet.revocation-authority-state.v0.3"; const CACHE_STATE_VERSION: &str = "agenet.revocation-cache-state.v0.3"; const MAX_STATE_BYTES: u64 = 256 * 1024; @@ -29,6 +43,15 @@ struct AuthorityState { format_version: String, last_epoch: u64, latest_snapshot: Option, + operations: std::collections::BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RevocationOperation { + target_node_id: NodeId, + expected_epoch: u64, + snapshot: RevocationSnapshot, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,6 +62,41 @@ struct CacheState { snapshot: RevocationSnapshot, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RevocationCacheInspection { + pub epoch: u64, + pub generated_at_ms: i64, + pub next_update_ms: i64, +} + +pub fn inspect_revocation_cache_read_only( + path: &Path, + root: &VerifyingKey, + expected_domain: &DomainId, + trusted_publisher: &SignedAuthorityCredential, +) -> Result { + let state = + read_owner_state::(path)?.ok_or(RuntimeError::CorruptRevocationState)?; + if state.format_version != CACHE_STATE_VERSION { + return Err(RuntimeError::CorruptRevocationState); + } + let publisher = PublisherBinding::verified(root, trusted_publisher)?; + if publisher.domain_id != *expected_domain + || state.publisher != publisher + || !publisher.matches(&state.snapshot.authority_credential) + { + return Err(RuntimeError::RevocationPublisherMismatch); + } + state + .snapshot + .verify(root, expected_domain, state.snapshot.claims.generated_at_ms)?; + Ok(RevocationCacheInspection { + epoch: state.snapshot.claims.epoch, + generated_at_ms: state.snapshot.claims.generated_at_ms, + next_update_ms: state.snapshot.claims.next_update_ms, + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct PublisherBinding { @@ -169,6 +227,7 @@ impl AuthorityRevocationStore { format_version: AUTHORITY_STATE_VERSION.to_owned(), last_epoch: 0, latest_snapshot: None, + operations: std::collections::BTreeMap::new(), }, }; if authority_credential.claims.signing_public_key_base64 @@ -229,6 +288,7 @@ impl AuthorityRevocationStore { format_version: AUTHORITY_STATE_VERSION.to_owned(), last_epoch: epoch, latest_snapshot: Some(snapshot.clone()), + operations: state.value.operations.clone(), }; if persist(self.writer.as_ref(), &self.path, &next).is_err() { state.persistence_unavailable = true; @@ -241,6 +301,87 @@ impl AuthorityRevocationStore { pub fn latest(&self) -> Result, RuntimeError> { Ok(lock(&self.state)?.value.latest_snapshot.clone()) } + + pub fn revoke_authorized( + &self, + authorization: &crate::protocol::SignedRevocationAuthorization, + now_ms: i64, + ) -> Result { + let claims = crate::protocol::verify_revocation_authorization( + authorization, + &self.root_public_key, + &self.authority_credential.claims.domain_id, + now_ms, + )?; + let mut state = lock(&self.state)?; + if state.persistence_unavailable { + return Err(RuntimeError::RevocationPersistenceUnavailable); + } + if let Some(existing) = state.value.operations.get(&claims.operation_id) { + return if existing.target_node_id == claims.target_node_id + && existing.expected_epoch == claims.expected_current_epoch + { + Ok(existing.snapshot.clone()) + } else { + Err(RuntimeError::RevocationEpochConflict) + }; + } + if claims.expected_current_epoch != state.value.last_epoch || state.value.last_epoch == 0 { + return Err(RuntimeError::RevocationEpochConflict); + } + let mut revoked_authorities = BTreeSet::new(); + let mut revoked_nodes = BTreeSet::new(); + if let Some(latest) = state.value.latest_snapshot.as_ref() { + revoked_authorities = latest.claims.revoked_authorities.clone(); + revoked_nodes = latest.claims.revoked_nodes.clone(); + } + revoked_nodes = merge_revoked_nodes(&revoked_nodes, claims.target_node_id.clone())?; + let epoch = state + .value + .last_epoch + .checked_add(1) + .ok_or(RuntimeError::CorruptRevocationState)?; + let snapshot = RevocationSnapshot::sign( + self.authority_credential.clone(), + &self.authority_signing_key, + RevocationClaims { + format_version: REVOCATION_FORMAT_VERSION.to_owned(), + domain_id: self.authority_credential.claims.domain_id.clone(), + issuer_id: self.authority_credential.claims.authority_id.clone(), + epoch, + generated_at_ms: now_ms, + next_update_ms: now_ms.saturating_add(5 * 60 * 1_000), + revoked_authorities, + revoked_nodes, + }, + &self.root_public_key, + now_ms, + )?; + let mut operations = state.value.operations.clone(); + if operations.len() >= 1_024 { + return Err(RuntimeError::CorruptRevocationState); + } + operations.insert( + claims.operation_id, + RevocationOperation { + target_node_id: claims.target_node_id, + expected_epoch: claims.expected_current_epoch, + snapshot: snapshot.clone(), + }, + ); + let next = AuthorityState { + format_version: AUTHORITY_STATE_VERSION.to_owned(), + last_epoch: epoch, + latest_snapshot: Some(snapshot.clone()), + operations, + }; + if persist(self.writer.as_ref(), &self.path, &next).is_err() { + state.persistence_unavailable = true; + return Err(RuntimeError::RevocationPersistenceUnavailable); + } + state.value = next; + Ok(snapshot) + } } #[derive(Clone)] diff --git a/src/service/macos.rs b/src/service/macos.rs index ad82ddb..bec9e03 100644 --- a/src/service/macos.rs +++ b/src/service/macos.rs @@ -129,11 +129,16 @@ impl UserServiceManager for MacOsUserServiceManager { if !result.success { return Err(ServiceError::UserSessionUnavailable); } - let status = self.status()?; - if status.process != ServiceProcessState::Running { - return Err(ServiceError::CommandFailed); + for attempt in 0..20 { + let status = self.status()?; + if status.process == ServiceProcessState::Running { + return Ok(status); + } + if attempt < 19 { + std::thread::sleep(std::time::Duration::from_millis(50)); + } } - Ok(status) + Err(ServiceError::CommandFailed) } fn stop(&self) -> Result { diff --git a/src/transport/directory.rs b/src/transport/directory.rs index 5cbe98d..a7ee8e7 100644 --- a/src/transport/directory.rs +++ b/src/transport/directory.rs @@ -11,7 +11,10 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde_json::json; use crate::{ - protocol::{CapabilityManifest, ErrorEnvelope, RevocationDecision, RouteQuery, WireEnvelope}, + protocol::{ + CapabilityManifest, ErrorEnvelope, NodeDepartureRequest, RevocationDecision, RouteQuery, + WireEnvelope, + }, runtime::{DirectoryRegistry, NodeIdentity, RevocationGuard, RuntimeError}, }; @@ -55,6 +58,7 @@ fn directory_router_inner( .route("/healthz", get(health)) .route("/v0/capabilities/register", post(register)) .route("/v0/routes/query", post(query)) + .route("/v0/nodes/depart", post(depart)) .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) .layer(axum::middleware::from_fn( super::tls::enforce_tls_envelope_binding, @@ -62,6 +66,54 @@ fn directory_router_inner( .with_state(state) } +async fn depart( + State(state): State>, + payload: Result, JsonRejection>, +) -> Response { + let envelope = match envelope_or_error(payload) { + Ok(envelope) => envelope, + Err(response) => return *response, + }; + let now_ms = state.identity.now_ms(); + let mut opened = None; + for role in [ + crate::protocol::NodeRole::Requester, + crate::protocol::NodeRole::Executor, + crate::protocol::NodeRole::Verifier, + ] { + if let Ok(value) = envelope.open_with_verified_claims::( + "node.departure.v1", + state.identity.root(), + state.identity.domain_id(), + role, + now_ms, + ) { + opened = Some(value); + break; + } + } + let Some(opened) = opened else { + return error_response(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"); + }; + if let Some(guard) = &state.revocations + && let Err(error) = guard.effectful_verified_claims(now_ms, opened.claims()) + { + return match error { + RuntimeError::RevocationStateStale => { + error_response(StatusCode::CONFLICT, "RevocationStateStale") + } + _ => error_response(StatusCode::FORBIDDEN, "CredentialRevoked"), + }; + } + match state.registry.unregister_opened(opened, now_ms).await { + Ok(receipt) => match state.identity.seal("node.departure.receipt.v1", &receipt) { + Ok(response) => (StatusCode::OK, Json(response)).into_response(), + Err(_) => error_response(StatusCode::INTERNAL_SERVER_ERROR, "InternalError"), + }, + Err(_) => error_response(StatusCode::FORBIDDEN, "InvalidSignedEnvelope"), + } +} + async fn health(State(state): State>) -> Json { let Some(guard) = &state.revocations else { return Json(json!({"status": "ok"})); diff --git a/src/transport/lifecycle.rs b/src/transport/lifecycle.rs new file mode 100644 index 0000000..20472d9 --- /dev/null +++ b/src/transport/lifecycle.rs @@ -0,0 +1,160 @@ +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, State, rejection::JsonRejection}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::post, +}; + +use crate::{ + bootstrap::EnrollmentAuthority, + protocol::{NodeRole, SignedRenewalRequest, SignedRevocationAuthorization, WireEnvelope}, + runtime::{AuthorityRevocationStore, NodeIdentity, RevocationGuard}, +}; + +use super::MAX_JSON_BODY_BYTES; + +struct LifecycleAuthorityState { + identity: NodeIdentity, + enrollment: Arc, + revocations: Arc, + guard: RevocationGuard, +} + +pub(crate) fn lifecycle_authority_router( + identity: NodeIdentity, + enrollment: Arc, + revocations: Arc, + guard: RevocationGuard, +) -> Router { + Router::new() + .route("/v0/credentials/renew", post(renew)) + .route("/v0/nodes/revoke", post(revoke)) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .layer(axum::middleware::from_fn( + super::tls::enforce_tls_envelope_binding, + )) + .with_state(Arc::new(LifecycleAuthorityState { + identity, + enrollment, + revocations, + guard, + })) +} + +async fn renew( + State(state): State>, + payload: Result, JsonRejection>, +) -> Response { + let Ok(Json(envelope)) = payload else { + return error(StatusCode::UNAUTHORIZED, "SignedEnvelopeRequired"); + }; + let now = state.identity.now_ms(); + let Some(opened) = open_for_any_role::( + &envelope, + "credential.renew.v1", + &state.identity, + now, + ) else { + return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"); + }; + if state + .guard + .effectful_verified_claims(now, opened.claims()) + .is_err() + { + return error(StatusCode::FORBIDDEN, "CredentialRevoked"); + } + let (request, claims) = opened.into_parts(); + match state.enrollment.renew(&claims, &request, now) { + Ok(bundle) => signed(&state.identity, "credential.renewed.v1", &bundle), + Err(crate::bootstrap::EnrollmentError::PolicyRejected) => { + error(StatusCode::CONFLICT, "RenewalNotAllowed") + } + Err(_) => error(StatusCode::SERVICE_UNAVAILABLE, "RenewalFailed"), + } +} + +async fn revoke( + State(state): State>, + payload: Result, JsonRejection>, +) -> Response { + let Ok(Json(envelope)) = payload else { + return error(StatusCode::UNAUTHORIZED, "SignedEnvelopeRequired"); + }; + let now = state.identity.now_ms(); + let Ok(opened) = envelope.open_with_verified_claims::( + "node.revoke.v1", + state.identity.root(), + state.identity.domain_id(), + NodeRole::Directory, + now, + ) else { + return error(StatusCode::UNAUTHORIZED, "InvalidSignedEnvelope"); + }; + if opened.claims().node_id != *state.identity.node_id() + || opened.payload().claims.target_node_id == *state.identity.node_id() + || opened.payload().claims.target_node_id == opened.claims().authority_id + || state + .guard + .effectful_verified_claims(now, opened.claims()) + .is_err() + { + return error(StatusCode::FORBIDDEN, "RevocationNotAllowed"); + } + let (authorization, _) = opened.into_parts(); + match state.revocations.revoke_authorized(&authorization, now) { + Ok(snapshot) => signed(&state.identity, "revocation.published.v1", &snapshot), + Err(crate::runtime::RuntimeError::RevocationEpochConflict) => { + error(StatusCode::CONFLICT, "RevocationEpochConflict") + } + Err(_) => error(StatusCode::SERVICE_UNAVAILABLE, "RevocationFailed"), + } +} + +fn open_for_any_role( + envelope: &WireEnvelope, + object_type: &str, + identity: &NodeIdentity, + now: i64, +) -> Option> { + for role in [ + NodeRole::Requester, + NodeRole::Executor, + NodeRole::Verifier, + NodeRole::Directory, + ] { + if let Ok(opened) = envelope.open_with_verified_claims( + object_type, + identity.root(), + identity.domain_id(), + role, + now, + ) { + return Some(opened); + } + } + None +} + +fn signed(identity: &NodeIdentity, object_type: &str, value: &T) -> Response { + match identity.seal(object_type, value) { + Ok(envelope) => (StatusCode::OK, Json(envelope)).into_response(), + Err(_) => error(StatusCode::INTERNAL_SERVER_ERROR, "InternalError"), + } +} + +fn error(status: StatusCode, code: &'static str) -> Response { + ( + status, + Json(crate::protocol::ErrorEnvelope { + code: code.to_owned(), + message: "The lifecycle operation was rejected.".to_owned(), + retryable: status == StatusCode::SERVICE_UNAVAILABLE, + operation_id: "lifecycle:unavailable".to_owned(), + }), + ) + .into_response() +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index b2faecc..0fe52d1 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -1,6 +1,7 @@ mod client; mod directory; mod enrollment; +mod lifecycle; mod node; mod revocation; pub(crate) mod tls; @@ -10,11 +11,13 @@ pub use directory::{directory_router, directory_router_with_revocation}; pub use enrollment::{ EnrollmentClient, EnrollmentTransportError, enrollment_router, enrollment_tls_config, }; +pub(crate) use lifecycle::lifecycle_authority_router; pub use node::{ artifact_router, artifact_router_with_revocation, base_router_with_revocation, provider_router, provider_router_with_revocation, requester_router, requester_router_with_revocation, }; pub use revocation::{RevocationClient, RevocationTransportError, authority_revocation_router}; +pub(crate) use tls::validate_persisted_peer_identity; pub use tls::{ PeerTlsIdentity, build_peer_client, build_peer_server_config, validate_peer_endpoint_transport, }; diff --git a/tests/bootstrap_config.rs b/tests/bootstrap_config.rs index 8aeace0..af4c41d 100644 --- a/tests/bootstrap_config.rs +++ b/tests/bootstrap_config.rs @@ -5,6 +5,7 @@ use agenet::{ AuthorityPki, BootstrapError, NodeConfigV1, NodePathEnvironment, NodePaths, NodeTlsCsr, UserPlatform, load_startup_bundle, network::{NetworkBoundary, OverlayKind}, + publish_active_identity, remove_inactive_identity_generation, write_identity_generation, }, protocol::{BootstrapProfile, DirectorySeed, DomainId, NodeId, NodeRole}, transport::PeerTlsIdentity, @@ -424,7 +425,7 @@ fn startup_loader_verifies_credential_role_time_domain_and_tls_identity() { let other_ca = AuthorityPki::generate(now - 10_000, now + 120_000).unwrap(); let mismatched_ca_identity = PeerTlsIdentity { authority_ca_pem: other_ca.ca_cert_pem.to_string(), - ..identity + ..identity.clone() }; paths .write_startup_material(&chain, &node, &mismatched_ca_identity) @@ -433,4 +434,43 @@ fn startup_loader_verifies_credential_role_time_domain_and_tls_identity() { load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), Err(BootstrapError::InvalidPki) )); + + paths + .write_startup_material(&chain, &node, &identity) + .unwrap(); + let old = + write_identity_generation(&paths, uuid::Uuid::new_v4(), &chain, &node, &identity).unwrap(); + publish_active_identity(&paths, &old).unwrap(); + let new = + write_identity_generation(&paths, uuid::Uuid::new_v4(), &chain, &node, &identity).unwrap(); + publish_active_identity(&paths, &new).unwrap(); + std::fs::write( + paths + .identity_generations_dir + .join(&old.generation_id) + .join("attacker-file"), + b"retain", + ) + .unwrap(); + assert!(remove_inactive_identity_generation(&paths, &old).is_err()); + assert_eq!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now) + .unwrap() + .tls_identity + .node_id + .as_str(), + "node:startup" + ); + std::fs::write( + paths + .identity_generations_dir + .join(&new.generation_id) + .join("peer-private-key-v1.pem"), + b"tampered", + ) + .unwrap(); + assert!(matches!( + load_startup_bundle(&paths, &root.verifying_key(), NodeRole::Requester, now), + Err(BootstrapError::InvalidConfig) + )); } diff --git a/tests/bootstrap_identity_generation.rs b/tests/bootstrap_identity_generation.rs new file mode 100644 index 0000000..c1dd958 --- /dev/null +++ b/tests/bootstrap_identity_generation.rs @@ -0,0 +1,68 @@ +use std::os::unix::fs::PermissionsExt; + +use agenet::bootstrap::{ + ActiveIdentityPointerV1, NodePathEnvironment, NodePaths, UserPlatform, + load_active_identity_pointer, +}; +use tempfile::TempDir; + +fn paths() -> (TempDir, NodePaths) { + let temp = TempDir::new().unwrap(); + let home = temp.path().canonicalize().unwrap(); + std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap(); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(home, None, None), + ) + .unwrap(); + paths.ensure_secure_layout().unwrap(); + (temp, paths) +} + +#[test] +fn active_pointer_rejects_traversal_and_symlink_generation() { + let (_temp, paths) = paths(); + let pointer = ActiveIdentityPointerV1::for_test("../outside"); + std::fs::write( + &paths.active_identity_file, + serde_json::to_vec(&pointer).unwrap(), + ) + .unwrap(); + std::fs::set_permissions( + &paths.active_identity_file, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + assert!(load_active_identity_pointer(&paths).is_err()); +} + +#[test] +fn incomplete_unreferenced_generation_cannot_change_active_identity() { + let (_temp, paths) = paths(); + let old = uuid::Uuid::new_v4(); + let pointer = ActiveIdentityPointerV1::for_test(old.to_string()); + std::fs::write( + &paths.active_identity_file, + serde_json::to_vec(&pointer).unwrap(), + ) + .unwrap(); + std::fs::set_permissions( + &paths.active_identity_file, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let incomplete = paths + .identity_generations_dir + .join(uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(&incomplete).unwrap(); + std::fs::set_permissions( + &paths.identity_generations_dir, + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + std::fs::set_permissions(&incomplete, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + load_active_identity_pointer(&paths).unwrap().generation_id, + old.to_string() + ); +} diff --git a/tests/cli_bootstrap.rs b/tests/cli_bootstrap.rs index 64afad7..1369685 100644 --- a/tests/cli_bootstrap.rs +++ b/tests/cli_bootstrap.rs @@ -16,6 +16,11 @@ fn bootstrap_help_exposes_only_tty_secret_commands() { (&["domain", "--help"][..], "init"), (&["invite", "--help"][..], "create"), (&["node", "join", "--help"][..], "--output"), + (&["credential", "renew", "--help"][..], "--output"), + (&["node", "revoke", "--help"][..], ""), + (&["node", "leave", "--help"][..], "--output"), + (&["node", "doctor", "--help"][..], "text"), + (&["uninstall", "--help"][..], "--purge"), ] { let output = agenet().args(arguments).output().expect("CLI executes"); assert!(output.status.success(), "{arguments:?}"); diff --git a/tests/doctor_cli.rs b/tests/doctor_cli.rs new file mode 100644 index 0000000..bdbbccc --- /dev/null +++ b/tests/doctor_cli.rs @@ -0,0 +1,64 @@ +use agenet::cli_diagnostics::{CheckStatus, DoctorCheck, DoctorReport}; +use std::{os::unix::fs::PermissionsExt, process::Command}; + +#[test] +fn doctor_json_is_deterministic_and_sanitized() { + let report = DoctorReport::new(vec![ + DoctorCheck::new( + "config.schema", + CheckStatus::Ok, + "Configuration schema is supported.", + "https://docs.agenet.dev/errors/config-schema", + ), + DoctorCheck::new( + "authority.reachability", + CheckStatus::Warn, + "Authority is currently unreachable.", + "https://docs.agenet.dev/errors/authority-unavailable", + ), + ]); + let first = serde_json::to_string(&report).unwrap(); + let second = serde_json::to_string(&report).unwrap(); + assert_eq!(first, second); + for forbidden in ["/Users/", "127.0.0.1", "BEGIN PRIVATE KEY", "secret"] { + assert!(!first.contains(forbidden)); + } + assert_eq!(report.exit_code(), 1); +} + +#[test] +fn doctor_error_has_stable_exit_code() { + let report = DoctorReport::new(vec![DoctorCheck::new( + "credential.chain", + CheckStatus::Error, + "Credential validation failed.", + "https://docs.agenet.dev/errors/credential-invalid", + )]); + assert_eq!(report.exit_code(), 2); +} + +#[test] +fn doctor_process_is_read_only_bounded_and_redacted() { + let home = tempfile::TempDir::new().unwrap(); + std::fs::set_permissions(home.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let sentinel = "DOCTOR-PRIVATE-SENTINEL"; + let output = Command::new(env!("CARGO_BIN_EXE_agenet")) + .args(["node", "doctor", "--output", "json"]) + .env("HOME", home.path()) + .env("AGENET_PRIVATE_SENTINEL", sentinel) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["format"], "agenet.doctor.v0.1"); + let encoded = String::from_utf8(output.stdout).unwrap(); + assert!(!encoded.contains(sentinel)); + assert!(!encoded.contains(home.path().to_string_lossy().as_ref())); + assert!(!encoded.contains("127.0.0.1")); + assert!( + !home + .path() + .join("Library/Application Support/AgenNet") + .exists() + ); +} diff --git a/tests/fixtures/service_probe.c b/tests/fixtures/service_probe.c new file mode 100644 index 0000000..f82a584 --- /dev/null +++ b/tests/fixtures/service_probe.c @@ -0,0 +1,18 @@ +#include +#include + +static volatile sig_atomic_t running = 1; + +static void stop_probe(int signal_number) { + (void)signal_number; + running = 0; +} + +int main(void) { + signal(SIGTERM, stop_probe); + signal(SIGINT, stop_probe); + while (running) { + pause(); + } + return 0; +} diff --git a/tests/http_directory.rs b/tests/http_directory.rs index a3d5d8f..6a49c70 100644 --- a/tests/http_directory.rs +++ b/tests/http_directory.rs @@ -2,9 +2,9 @@ mod common; use agenet::{ protocol::{ - CandidateSet, CapabilityId, CapabilityManifest, CredentialChain, NodeRole, RouteQuery, - SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, SOURCE_METRICS_VERIFIER_CAPABILITY_ID, - SideEffectProfile, WireEnvelope, + CandidateSet, CapabilityId, CapabilityManifest, CredentialChain, NodeDepartureReceipt, + NodeDepartureRequest, NodeRole, RouteQuery, SOURCE_METRICS_EXECUTOR_CAPABILITY_ID, + SOURCE_METRICS_VERIFIER_CAPABILITY_ID, SideEffectProfile, WireEnvelope, }, runtime::{Clock, DirectoryRegistry, NodeIdentity}, transport::{MAX_JSON_BODY_BYTES, directory_router}, @@ -24,6 +24,7 @@ use std::{ }, }; use tower::ServiceExt; +use uuid::Uuid; const NOW: u64 = 1_800_000_000; @@ -388,6 +389,98 @@ async fn missing_or_nonloopback_connection_identity_cannot_register() { assert!(candidates.candidates.is_empty()); } +#[tokio::test] +async fn signed_departure_removes_only_issuer_manifests_and_replays_exact_receipt() { + let root = signing_key(80); + let directory = identity( + &root, + signing_key(81), + "node:directory-departure", + NodeRole::Directory, + ); + let executor = identity( + &root, + signing_key(82), + "node:executor-departure", + NodeRole::Executor, + ); + let registry = DirectoryRegistry::new(); + let app = directory_router(registry.clone(), directory.clone(), NOW); + let manifest = CapabilityManifest { + capability_id: CapabilityId::new(SOURCE_METRICS_EXECUTOR_CAPABILITY_ID).unwrap(), + provider: executor.node_id().clone(), + kind: "source.metrics".to_owned(), + version: "v1".to_owned(), + description: "departure target".to_owned(), + input_profile: "artifact.source.utf8.v1".to_owned(), + output_profile: "source.metrics.v1".to_owned(), + side_effect: SideEffectProfile::ReadOnly, + endpoint: "http://127.0.0.1:41419".to_owned(), + evidence_types: vec![], + expires_at_unix_ms: NOW + 60_000, + }; + let registered = app + .clone() + .oneshot(loopback_request(envelope_request( + "/v0/capabilities/register", + &executor.seal("capability.manifest.v1", &manifest).unwrap(), + ))) + .await + .unwrap(); + assert_eq!(registered.status(), StatusCode::OK); + + let request = NodeDepartureRequest { + format_version: "agenet.node-departure.v0.1".to_owned(), + operation_id: Uuid::from_u128(801), + node_id: executor.node_id().clone(), + requested_at_ms: NOW as i64, + }; + let envelope = executor.seal("node.departure.v1", &request).unwrap(); + let first = departure_receipt(&app, &directory, &envelope).await; + let replay = departure_receipt(&app, &directory, &envelope).await; + assert_eq!(first, replay); + assert_eq!(first.removed_manifests, 1); + assert!( + registry + .query( + RouteQuery { + required_capability: "source.metrics.v1".to_owned(), + }, + NOW, + ) + .await + .candidates + .is_empty() + ); +} + +async fn departure_receipt( + app: &axum::Router, + directory: &NodeIdentity, + envelope: &WireEnvelope, +) -> NodeDepartureReceipt { + let response = app + .clone() + .oneshot(loopback_request(envelope_request( + "/v0/nodes/depart", + envelope, + ))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let envelope: WireEnvelope = serde_json::from_slice(&bytes).unwrap(); + envelope + .open( + "node.departure.receipt.v1", + directory.root(), + directory.domain_id(), + NodeRole::Directory, + NOW as i64, + ) + .unwrap() +} + fn envelope_request(path: &str, envelope: &WireEnvelope) -> Request { Request::post(path) .header("content-type", "application/json") diff --git a/tests/lifecycle_cli.rs b/tests/lifecycle_cli.rs new file mode 100644 index 0000000..26c761e --- /dev/null +++ b/tests/lifecycle_cli.rs @@ -0,0 +1,158 @@ +use std::collections::BTreeSet; + +use agenet::{ + bootstrap::{ + BootstrapPhase, BootstrapStateStore, BootstrapTransition, ManagedBinaryMetadataV1, + reconcile_managed_binary_metadata, remove_verified_managed_binary, + }, + protocol::{ + DomainId, NodeId, RenewalRequestClaims, RevocationAuthorizationClaims, + sign_renewal_request, sign_revocation_authorization, verify_renewal_request, + verify_revocation_authorization, + }, +}; +use ed25519_dalek::SigningKey; +use tempfile::tempdir; +use uuid::Uuid; + +#[test] +fn renewal_request_binds_new_csr_node_and_operation() { + let key = SigningKey::from_bytes(&[71; 32]); + let claims = RenewalRequestClaims { + format_version: "agenet.credential-renewal.v0.1".to_owned(), + operation_id: Uuid::from_u128(17), + node_id: NodeId::new("node-renewal").unwrap(), + requested_bind_ip: "127.0.0.2".parse().unwrap(), + tls_csr_pem: "fresh-csr".to_owned(), + requested_at_ms: 2_000_000_000_000, + }; + let signed = sign_renewal_request(&claims, &key).unwrap(); + assert_eq!( + verify_renewal_request(&signed, &key.verifying_key()).unwrap(), + claims + ); + + let mut changed = signed.clone(); + changed.claims.tls_csr_pem = "reused-csr".to_owned(); + assert!(verify_renewal_request(&changed, &key.verifying_key()).is_err()); +} + +#[test] +fn root_authorized_revocation_binds_epoch_target_and_operation() { + let root = SigningKey::from_bytes(&[72; 32]); + let claims = RevocationAuthorizationClaims { + format_version: "agenet.revocation-authorization.v0.1".to_owned(), + domain_id: DomainId::new("domain-lifecycle").unwrap(), + target_node_id: NodeId::new("node-target").unwrap(), + expected_current_epoch: 4, + operation_id: Uuid::from_u128(18), + issued_at_ms: 2_000_000_000_000, + expires_at_ms: 2_000_000_030_000, + }; + let signed = sign_revocation_authorization(&claims, &root).unwrap(); + assert_eq!( + verify_revocation_authorization( + &signed, + &root.verifying_key(), + &claims.domain_id, + claims.issued_at_ms, + ) + .unwrap(), + claims + ); + let mut changed = signed; + changed.claims.target_node_id = NodeId::new("node-other").unwrap(); + assert!( + verify_revocation_authorization( + &changed, + &root.verifying_key(), + &claims.domain_id, + claims.issued_at_ms, + ) + .is_err() + ); +} + +#[test] +fn leave_from_real_service_phase_is_durable_and_idempotent() { + let temp = tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + std::fs::set_permissions(&root, std::os::unix::fs::PermissionsExt::from_mode(0o700)).unwrap(); + let journal = root.join("bootstrap-state-v1.jsonl"); + let mut state = BootstrapStateStore::open(&journal).unwrap(); + for (operation, phase) in [ + ("binary", BootstrapPhase::BinaryInstalled), + ("ready", BootstrapPhase::ReadyForEnrollment), + ("credential", BootstrapPhase::CredentialIssued), + ("service", BootstrapPhase::ServicePrepared), + ] { + state + .apply(operation, BootstrapTransition::Advance(phase)) + .unwrap(); + } + state.apply("leave-17", BootstrapTransition::Leave).unwrap(); + state.apply("leave-17", BootstrapTransition::Leave).unwrap(); + assert_eq!(state.phase(), BootstrapPhase::Left); + drop(state); + assert_eq!( + BootstrapStateStore::open(&journal).unwrap().phase(), + BootstrapPhase::Left + ); +} + +#[test] +fn revocation_sets_are_monotonic_inputs() { + let prior = BTreeSet::from([NodeId::new("node-existing").unwrap()]); + let target = NodeId::new("node-target").unwrap(); + let next = agenet::runtime::merge_revoked_nodes(&prior, target.clone()).unwrap(); + assert!(next.contains(&target)); + assert!(next.is_superset(&prior)); +} + +#[test] +fn managed_binary_requires_exact_user_bin_identity_and_hash() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempdir().unwrap(); + let home = temp.path().canonicalize().unwrap(); + let bin = home.join(".local/bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o700)).unwrap(); + let executable = bin.join("agenet"); + std::fs::write(&executable, b"managed-binary-v1").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let metadata = reconcile_managed_binary_metadata(&home, &executable, "0.2.0") + .unwrap() + .expect("approved binary is recorded"); + assert_eq!(metadata.format, "agenet.managed-binary.v0.1"); + + std::fs::write(&executable, b"replaced-binary").unwrap(); + assert!(remove_verified_managed_binary(&home, &metadata).is_err()); + assert!(executable.exists()); +} + +#[test] +fn unmanaged_binary_is_never_recorded_for_removal() { + let temp = tempdir().unwrap(); + let home = temp.path().canonicalize().unwrap(); + let arbitrary = home.join("target/debug/agenet"); + std::fs::create_dir_all(arbitrary.parent().unwrap()).unwrap(); + std::fs::write(&arbitrary, b"developer-binary").unwrap(); + assert!( + reconcile_managed_binary_metadata(&home, &arbitrary, "0.2.0") + .unwrap() + .is_none() + ); + + let fake = ManagedBinaryMetadataV1 { + format: "agenet.managed-binary.v0.1".to_owned(), + path: arbitrary, + sha256: "00".repeat(32), + size: 16, + device: 1, + inode: 1, + owner: unsafe { libc::geteuid() }, + version: "0.2.0".to_owned(), + }; + assert!(remove_verified_managed_binary(&home, &fake).is_err()); +} diff --git a/tests/revocation.rs b/tests/revocation.rs index 3af7998..204283b 100644 --- a/tests/revocation.rs +++ b/tests/revocation.rs @@ -7,13 +7,15 @@ use std::{ use agenet::{ protocol::{ AuthorityClaims, AuthorityScope, BootstrapProfile, DomainId, NodeId, ProtocolError, - RevocationClaims, RevocationDecision, RevocationSnapshot, SignedAuthorityCredential, + RevocationAuthorizationClaims, RevocationClaims, RevocationDecision, RevocationSnapshot, + SignedAuthorityCredential, sign_revocation_authorization, }, runtime::{AuthorityRevocationStore, RevocationCache, RuntimeError}, }; use base64::{Engine, engine::general_purpose::STANDARD}; use ed25519_dalek::SigningKey; use tempfile::tempdir; +use uuid::Uuid; const NOW: i64 = 1_800_000_000_000; @@ -83,6 +85,66 @@ fn signed_snapshot( .expect("signed snapshot") } +#[test] +fn root_authorized_revoke_is_epoch_checked_monotonic_and_idempotent() { + let temp = tempdir().expect("tempdir"); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)).expect("chmod"); + let root = key(90); + let authority = key(91); + let store = AuthorityRevocationStore::open( + temp.path(), + root.verifying_key(), + authority_credential(&root, &authority), + authority, + ) + .expect("store"); + store + .publish(NOW, BTreeSet::new(), BTreeSet::new()) + .expect("initial epoch"); + let target = NodeId::new("node:revoked-by-root").expect("node"); + let authorization = sign_revocation_authorization( + &RevocationAuthorizationClaims { + format_version: "agenet.revocation-authorization.v0.1".to_owned(), + domain_id: domain(), + target_node_id: target.clone(), + expected_current_epoch: 1, + operation_id: Uuid::from_u128(900), + issued_at_ms: NOW + 1, + expires_at_ms: NOW + 30_001, + }, + &root, + ) + .expect("authorization"); + let first = store + .revoke_authorized(&authorization, NOW + 1) + .expect("revoke"); + let replay = store + .revoke_authorized(&authorization, NOW + 1) + .expect("exact replay"); + assert_eq!(first, replay); + assert_eq!(first.claims.epoch, 2); + assert!(first.claims.revoked_nodes.contains(&target)); + + let stale = sign_revocation_authorization( + &RevocationAuthorizationClaims { + format_version: "agenet.revocation-authorization.v0.1".to_owned(), + domain_id: domain(), + target_node_id: NodeId::new("node:other-target").expect("node"), + expected_current_epoch: 1, + operation_id: Uuid::from_u128(901), + issued_at_ms: NOW + 2, + expires_at_ms: NOW + 30_002, + }, + &root, + ) + .expect("authorization"); + assert_eq!( + store.revoke_authorized(&stale, NOW + 2), + Err(RuntimeError::RevocationEpochConflict) + ); + assert_eq!(store.latest().unwrap().unwrap().claims.epoch, 2); +} + #[test] fn verifies_exact_signed_snapshot_and_rejects_tampering() { let root = key(1); From 740c4f9905615a10ee9057edf2e39bbd6b524dc7 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 07:37:58 +0800 Subject: [PATCH 39/67] [bug] Fix lifecycle recovery integrity Root cause: Identity generations were reopened across validation and the Node signing key was decoded outside the manifest-checked read. Departure completion did not preserve an exact signed request and receipt relation. Solution: Pin generation directories and material reads, verify exact file sets and hashes, anchor cleanup by inode, and persist exact departure requests and signed receipts for crash-safe retry after Left. Risks: Native service adoption remains macOS-only; physical overlay and Linux native lifecycle evidence remain deferred to Task 14. Dependency: Task 13 baseline 74f35c4f27557a515170f3c9d9511aa92d678f1b. Links: plan/01-v3-multi-host-node-bootstrap.md Post-mortem: Multi-file security bundles need pinned descriptors, exact set validation, and explicit crash publication ordering before implementation. --- README.md | 4 + ROADMAP.md | 9 + docs/design/agenet-v0.1.md | 13 + plan/01-v3-multi-host-node-bootstrap.md | 20 +- src/bootstrap/identity_generation.rs | 418 +++++++++++---- src/bootstrap/mod.rs | 3 + src/cli/lifecycle.rs | 656 ++++++++++++++++++++++-- src/runtime/directory.rs | 3 +- src/runtime/key_store.rs | 58 +-- src/transport/client.rs | 54 +- tests/bootstrap_identity_generation.rs | 325 ++++++++++-- tests/doctor_cli.rs | 184 ++++++- tests/http_directory.rs | 39 ++ 13 files changed, 1548 insertions(+), 238 deletions(-) diff --git a/README.md b/README.md index 55e8b17..290c4cb 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,10 @@ warning rather than claiming deletion. a controlling TTY, exact NodeId confirmation, and hidden Domain Root unlock. `node leave` stops the service but retains identity, config, credentials, journals, and audit state; Directory outage leaves a durable pending departure. +The pending record contains one exact signed request and operation ID reused by +later `node leave` invocations, including after local `Left`. A Directory-signed +receipt is durably validated and stored before pending state is cleared; missing +or invalid receipt state is never reported as a recorded departure. Default uninstall retains all state and removes a binary only when its recorded path, owner, mode, device, inode, size, hash, version, basename, and approved per-user installation root still match. `--purge` is destructive, requires both diff --git a/ROADMAP.md b/ROADMAP.md index b79ff21..422b1fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Task 13 review: pin lifecycle recovery artifacts + +- **Change**: Hardened identity generation load/cleanup and made Directory departure recovery exact and durable across offline `Left` and receipt-write crashes. +- **Files**: identity-generation/key-store persistence, lifecycle and peer client, Directory departure policy, doctor/lifecycle/filesystem tests, README/design, and the v3 plan amendment. +- **Root cause / classification**: **技术盲区**. The first generation loader did not hash-check the Node signing key and used separate pathname reads, while retired cleanup re-opened a generation after validation. Leave also treated `Left` as complete without retaining the exact signed Directory request/receipt relationship. +- **Solution**: A pinned owner-only parent/generation descriptor now supplies the manifest and every bounded material read, exact manifest/directory sets are enforced, and cleanup compares device/inode before fd-relative directory removal. Leave persists one exact signed request before networking, reuses it after `Left`, durably stores the exact Directory-signed receipt, and reconciles pending-plus-receipt state. +- **Prevention**: Security bundles require per-material tamper/missing/symlink tests plus replacement races and sync-fault seams. Recoverable network effects require an exact persisted request, signed response binding, publication order, and crash-state matrix before CLI completion semantics are approved. +- **Boundary**: These tests use owner-controlled temporary filesystems and loopback protocol handlers. They do not establish physical-overlay behavior or Linux native service-manager parity; Task 14 remains deferred. + ## 2026-08-15 — Task 13 lifecycle, renewal generations, and diagnostics - **Change**: Added mTLS credential renewal, Root-authorized revocation, signed recoverable leave, conservative uninstall/purge, trusted managed-binary metadata, and deterministic read-only doctor checks. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index c26c971..12ce771 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -139,6 +139,19 @@ mode, device, inode, size, hash, and version. Purge is controlling-TTY-only, lists logical items first, requires exact NodeId plus `PURGE`, uses fixed allowlists, and never deletes Root or founding Authority/admin material. +Departure recovery reuses the exact persisted signed request and operation ID; +it does not mint a new request after local `Left`. The signed receipt binds the +Directory NodeId, request envelope, operation ID, and departing NodeId. Receipt +storage is synced before pending removal, so the pending-plus-receipt crash state +reconciles deterministically and tampered or cross-request receipts fail closed. + +Identity generation loading and cleanup use pinned directory descriptors. The +manifest key set and directory entry set must exactly equal the versioned +allowlist, and all five materials are bounded, hash checked, then decoded from +the same descriptor-relative bytes. Cleanup verifies the parent entry still +names the pinned device/inode before removing the directory; a rename or +attacker substitute is retained with a cleanup warning. + Doctor never creates, refreshes, repairs, or rewrites state. It bounds reads and network time, disables proxy and redirects, and checks owner/mode/type/symlink, config schema, full credential/TLS/key binding and expiry, bind ownership, diff --git a/plan/01-v3-multi-host-node-bootstrap.md b/plan/01-v3-multi-host-node-bootstrap.md index b3d80db..61e6eb4 100644 --- a/plan/01-v3-multi-host-node-bootstrap.md +++ b/plan/01-v3-multi-host-node-bootstrap.md @@ -29,11 +29,19 @@ new credential/TLS files, and lifecycle cleanup must remain recoverable. plus a new-identity mTLS health request. Roll back the pointer and restart the old runtime only while the old credential is still valid. 5. After confirmed adoption, delete only an inactive, manifest-validated exact - generation allowlist through fd-relative operations. Unknown entries or any - durability uncertainty retain the generation and emit a repair warning. + generation allowlist through one pinned parent/generation descriptor pair. + Manifest names and directory entries must equal the exact required set; + every material, including the Ed25519 key, is decoded from its bounded, + hash-checked descriptor-relative read. Cleanup compares the parent entry's + device/inode before `unlinkat(AT_REMOVEDIR)`. Unknown entries, replacement, + or durability uncertainty retain the generation and emit a repair warning. 6. Apply the same exact allowlist to destructive purge; always retain Domain Root, founding Authority, invitation/audit administration, and revocation authority material. +7. Persist the exact signed departure request before its first network attempt. + A later `node leave` reuses its operation ID and envelope even after the + local phase is `Left`. Persist and sync the Directory-signed receipt before + clearing pending state; pending plus receipt is a recoverable crash state. ## Acceptance criteria @@ -41,7 +49,13 @@ new credential/TLS files, and lifecycle cleanup must remain recoverable. every crash after publication loads the complete new identity; no loader path can return a mixed bundle. - Traversal, symlink, foreign owner/mode, hash mismatch, unknown cleanup entry, - active-generation selection, and pointer uncertainty fail closed. + active-generation selection, directory replacement, and pointer or sync + uncertainty fail closed. Tests replace each material independently and race + cleanup against an attacker substitute. +- Offline departure reaches local `Left` with `recorded=false,pending=true`; + a later online retry sends the identical signed request, validates the exact + Directory signer and request binding, persists the receipt, and clears the + pending record. Missing or tampered receipt state is never reported recorded. - Successful renewal proves that the platform service adopted the new generation. Cleanup uncertainty is a warning and never a false deletion claim. diff --git a/src/bootstrap/identity_generation.rs b/src/bootstrap/identity_generation.rs index ae6e066..c5a09b8 100644 --- a/src/bootstrap/identity_generation.rs +++ b/src/bootstrap/identity_generation.rs @@ -1,6 +1,12 @@ use std::{ - collections::BTreeMap, - fs::File, + collections::{BTreeMap, BTreeSet}, + ffi::{CStr, CString}, + fs::{File, OpenOptions}, + io::Read, + os::unix::{ + fs::{MetadataExt, OpenOptionsExt}, + io::{AsRawFd, FromRawFd}, + }, path::{Path, PathBuf}, }; @@ -26,6 +32,21 @@ const SIGNING_KEY: &str = "node-signing-key-v1.key"; const TLS_CERTIFICATE: &str = "peer-certificate-v1.pem"; const TLS_PRIVATE_KEY: &str = "peer-private-key-v1.pem"; const AUTHORITY_CA: &str = "authority-ca-v1.pem"; +const MATERIAL_NAMES: [&str; 5] = [ + CREDENTIAL, + SIGNING_KEY, + TLS_CERTIFICATE, + TLS_PRIVATE_KEY, + AUTHORITY_CA, +]; +const GENERATION_NAMES: [&str; 6] = [ + MANIFEST, + CREDENTIAL, + SIGNING_KEY, + TLS_CERTIFICATE, + TLS_PRIVATE_KEY, + AUTHORITY_CA, +]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -143,12 +164,15 @@ pub fn load_identity_generation( pointer: &ActiveIdentityPointerV1, ) -> Result { validate_pointer(pointer)?; - let generation_id = - Uuid::parse_str(&pointer.generation_id).map_err(|_| BootstrapError::InvalidConfig)?; - let directory = generation_directory(paths, generation_id)?; - ensure_existing_owner_directory(&directory)?; - let manifest_bytes = read_owner_only(&directory.join(MANIFEST), 64 * 1024) - .map_err(|_| BootstrapError::UnsafeStatePath)?; + let pinned = PinnedIdentityGeneration::open(paths, &pointer.generation_id)?; + decode_pinned_generation(&pinned, pointer) +} + +fn decode_pinned_generation( + pinned: &PinnedIdentityGeneration, + pointer: &ActiveIdentityPointerV1, +) -> Result { + let manifest_bytes = pinned.read_owner_file(MANIFEST, 64 * 1024)?; if hash(&manifest_bytes) != pointer.manifest_sha256 { return Err(BootstrapError::InvalidConfig); } @@ -156,13 +180,18 @@ pub fn load_identity_generation( serde_json::from_slice(&manifest_bytes).map_err(|_| BootstrapError::InvalidConfig)?; if manifest.format_version != "agenet.identity-generation.v0.1" || manifest.generation_id != pointer.generation_id - || manifest.file_sha256.len() != 5 + || manifest + .file_sha256 + .keys() + .map(String::as_str) + .collect::>() + != MATERIAL_NAMES.into_iter().collect::>() { return Err(BootstrapError::InvalidConfig); } - let read = |name: &str, limit| -> Result, BootstrapError> { - let bytes = read_owner_only(&directory.join(name), limit) - .map_err(|_| BootstrapError::UnsafeStatePath)?; + pinned.require_exact_entries()?; + let read = |name: &str, limit| -> Result>, BootstrapError> { + let bytes = pinned.read_owner_file(name, limit)?; if manifest.file_sha256.get(name) != Some(&hash(&bytes)) { return Err(BootstrapError::InvalidConfig); } @@ -171,20 +200,23 @@ pub fn load_identity_generation( let credential: CredentialChain = serde_json::from_slice(&read(CREDENTIAL, 64 * 1024)?) .map_err(|_| BootstrapError::InvalidConfig)?; let signing_key = - crate::runtime::key_store::read_signing_key_hardened(&directory.join(SIGNING_KEY)) - .map_err(|_| BootstrapError::UnsafeStatePath)?; - let certificate = utf8(read(TLS_CERTIFICATE, 64 * 1024)?)?; - let private_key = utf8(read(TLS_PRIVATE_KEY, 16 * 1024)?)?; - let authority_ca = utf8(read(AUTHORITY_CA, 64 * 1024)?)?; + crate::runtime::key_store::decode_signing_key_hardened_bytes(read(SIGNING_KEY, 4096)?) + .map_err(|_| BootstrapError::InvalidConfig)?; + let certificate = utf8_zeroizing(read(TLS_CERTIFICATE, 64 * 1024)?)?; + let private_key = utf8_zeroizing(read(TLS_PRIVATE_KEY, 16 * 1024)?)?; + let authority_ca = utf8_zeroizing(read(AUTHORITY_CA, 64 * 1024)?)?; + // An attacker cannot make a transient unknown entry disappear unnoticed + // between the first directory audit and completion of material decoding. + pinned.require_exact_entries()?; Ok(LoadedIdentityGeneration { - generation_id, + generation_id: pinned.generation_id, credential, signing_key, tls_identity: PeerTlsIdentity { node_id: manifest.node_id, - certificate_chain_pem: zeroize::Zeroizing::new(certificate), - private_key_pem: zeroize::Zeroizing::new(private_key), - authority_ca_pem: authority_ca, + certificate_chain_pem: certificate, + private_key_pem: private_key, + authority_ca_pem: authority_ca.to_string(), }, }) } @@ -192,93 +224,293 @@ pub fn load_identity_generation( pub fn remove_inactive_identity_generation( paths: &NodePaths, retired: &ActiveIdentityPointerV1, +) -> Result<(), BootstrapError> { + remove_inactive_identity_generation_inner(paths, retired, || Ok(()), &mut |_| Ok(())) +} + +fn remove_inactive_identity_generation_inner( + paths: &NodePaths, + retired: &ActiveIdentityPointerV1, + before_cleanup: impl FnOnce() -> Result<(), BootstrapError>, + sync_fault: &mut dyn FnMut(&str) -> Result<(), BootstrapError>, ) -> Result<(), BootstrapError> { let active = load_active_identity_pointer(paths)?; if active.generation_id == retired.generation_id { return Err(BootstrapError::InvalidConfig); } - load_identity_generation(paths, retired)?; - let generation_id = - Uuid::parse_str(&retired.generation_id).map_err(|_| BootstrapError::InvalidConfig)?; - let directory = generation_directory(paths, generation_id)?; - let allowed = [ - MANIFEST, - CREDENTIAL, - SIGNING_KEY, - TLS_CERTIFICATE, - TLS_PRIVATE_KEY, - AUTHORITY_CA, - ]; - let entries = std::fs::read_dir(&directory).map_err(|_| BootstrapError::UnsafeStatePath)?; - for entry in entries { - let entry = entry.map_err(|_| BootstrapError::UnsafeStatePath)?; - let name = entry.file_name(); - let name = name.to_str().ok_or(BootstrapError::UnsafeStatePath)?; - if !allowed.contains(&name) { + let pinned = PinnedIdentityGeneration::open(paths, &retired.generation_id)?; + decode_pinned_generation(&pinned, retired)?; + before_cleanup()?; + pinned.remove_exact(sync_fault) +} + +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub fn remove_inactive_identity_generation_with_faults( + paths: &NodePaths, + retired: &ActiveIdentityPointerV1, + before_cleanup: impl FnOnce() -> Result<(), BootstrapError>, + mut after_unlink: impl FnMut(&str) -> Result<(), BootstrapError>, +) -> Result<(), BootstrapError> { + remove_inactive_identity_generation_inner(paths, retired, before_cleanup, &mut after_unlink) +} + +pub fn purge_identity_generations(paths: &NodePaths) -> Result<(), BootstrapError> { + match std::fs::symlink_metadata(&paths.identity_generations_dir) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(BootstrapError::UnsafeStatePath), + Ok(_) => ensure_existing_owner_directory(&paths.identity_generations_dir)?, + } + let parent = open_owner_directory(&paths.identity_generations_dir)?; + let names = directory_names(&parent)?; + for name in names { + Uuid::parse_str(&name).map_err(|_| BootstrapError::UnsafeStatePath)?; + let pinned = PinnedIdentityGeneration::open_from_parent( + parent + .try_clone() + .map_err(|_| BootstrapError::UnsafeStatePath)?, + &name, + )?; + pinned.require_exact_entries()?; + pinned.remove_exact(&mut |_| Ok(()))?; + } + // Keep the secure empty container. Removing it by pathname after the scan + // would reintroduce a parent replacement race and the normal layout already + // treats this directory as managed state. + parent + .sync_all() + .map_err(|_| BootstrapError::PersistenceUnavailable) +} + +struct PinnedIdentityGeneration { + parent: File, + directory: File, + generation_id: Uuid, + device: u64, + inode: u64, +} + +impl PinnedIdentityGeneration { + fn open(paths: &NodePaths, generation: &str) -> Result { + let parent = open_owner_directory(&paths.identity_generations_dir)?; + Self::open_from_parent(parent, generation) + } + + fn open_from_parent(parent: File, generation: &str) -> Result { + let generation_id = + Uuid::parse_str(generation).map_err(|_| BootstrapError::InvalidConfig)?; + let name = CString::new(generation_id.to_string()) + .map_err(|_| BootstrapError::InvalidStatePath)?; + let raw = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if raw < 0 { + return Err(BootstrapError::UnsafeStatePath); + } + let directory = unsafe { File::from_raw_fd(raw) }; + let metadata = directory + .metadata() + .map_err(|_| BootstrapError::UnsafeStatePath)?; + validate_owner_directory_metadata(&metadata)?; + Ok(Self { + parent, + directory, + generation_id, + device: metadata.dev(), + inode: metadata.ino(), + }) + } + + fn read_owner_file( + &self, + name: &str, + limit: usize, + ) -> Result>, BootstrapError> { + let name = CString::new(name).map_err(|_| BootstrapError::UnsafeStatePath)?; + let raw = unsafe { + libc::openat( + self.directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC, + ) + }; + if raw < 0 { return Err(BootstrapError::UnsafeStatePath); } - let metadata = entry - .file_type() + let mut file = unsafe { File::from_raw_fd(raw) }; + let metadata = file + .metadata() .map_err(|_| BootstrapError::UnsafeStatePath)?; - if !metadata.is_file() || metadata.is_symlink() { + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.uid() != current_euid() + || metadata.mode() & 0o777 != 0o600 + { return Err(BootstrapError::UnsafeStatePath); } + let mut bytes = zeroize::Zeroizing::new(Vec::new()); + (&mut file) + .take((limit + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + if bytes.len() > limit { + return Err(BootstrapError::ResourceLimitExceeded); + } + Ok(bytes) } - for name in allowed { - crate::runtime::key_store::remove_owner_only_user_service_file(&directory.join(name)) - .map_err(|_| BootstrapError::PersistenceUnavailable)?; + + fn require_exact_entries(&self) -> Result<(), BootstrapError> { + let entries = directory_names(&self.directory)?; + let expected = GENERATION_NAMES + .into_iter() + .map(str::to_owned) + .collect::>(); + if entries != expected { + return Err(BootstrapError::UnsafeStatePath); + } + Ok(()) } - crate::runtime::key_store::remove_owner_only_empty_directory(&directory) - .map_err(|_| BootstrapError::PersistenceUnavailable) -} -pub fn purge_identity_generations(paths: &NodePaths) -> Result<(), BootstrapError> { - match std::fs::symlink_metadata(&paths.identity_generations_dir) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(_) => return Err(BootstrapError::UnsafeStatePath), - Ok(_) => ensure_existing_owner_directory(&paths.identity_generations_dir)?, + fn remove_exact( + &self, + after_unlink: &mut dyn FnMut(&str) -> Result<(), BootstrapError>, + ) -> Result<(), BootstrapError> { + for name in GENERATION_NAMES { + let name = CString::new(name).map_err(|_| BootstrapError::UnsafeStatePath)?; + if unsafe { libc::unlinkat(self.directory.as_raw_fd(), name.as_ptr(), 0) } != 0 { + return Err(BootstrapError::PersistenceUnavailable); + } + after_unlink(name.to_str().map_err(|_| BootstrapError::UnsafeStatePath)?)?; + self.directory + .sync_all() + .map_err(|_| BootstrapError::PersistenceUnavailable)?; + } + let name = CString::new(self.generation_id.to_string()) + .map_err(|_| BootstrapError::UnsafeStatePath)?; + let mut current = std::mem::MaybeUninit::::uninit(); + if unsafe { + libc::fstatat( + self.parent.as_raw_fd(), + name.as_ptr(), + current.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + return Err(BootstrapError::PersistenceUnavailable); + } + let current = unsafe { current.assume_init() }; + if current.st_dev as u64 != self.device || current.st_ino != self.inode { + return Err(BootstrapError::UnsafeStatePath); + } + if unsafe { libc::unlinkat(self.parent.as_raw_fd(), name.as_ptr(), libc::AT_REMOVEDIR) } + != 0 + { + return Err(BootstrapError::PersistenceUnavailable); + } + self.parent + .sync_all() + .map_err(|_| BootstrapError::PersistenceUnavailable) } - let allowed = [ - MANIFEST, - CREDENTIAL, - SIGNING_KEY, - TLS_CERTIFICATE, - TLS_PRIVATE_KEY, - AUTHORITY_CA, - ]; - let entries = std::fs::read_dir(&paths.identity_generations_dir) +} + +fn open_owner_directory(path: &Path) -> Result { + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) .map_err(|_| BootstrapError::UnsafeStatePath)?; - for entry in entries { - let entry = entry.map_err(|_| BootstrapError::UnsafeStatePath)?; - let id = entry - .file_name() - .to_str() - .and_then(|value| Uuid::parse_str(value).ok()) - .ok_or(BootstrapError::UnsafeStatePath)?; - let directory = generation_directory(paths, id)?; - ensure_existing_owner_directory(&directory)?; - for child in std::fs::read_dir(&directory).map_err(|_| BootstrapError::UnsafeStatePath)? { - let child = child.map_err(|_| BootstrapError::UnsafeStatePath)?; - let name = child - .file_name() - .to_str() - .map(str::to_owned) - .ok_or(BootstrapError::UnsafeStatePath)?; - if !allowed.contains(&name.as_str()) - || !child.file_type().is_ok_and(|kind| kind.is_file()) - { + validate_owner_directory_metadata( + &directory + .metadata() + .map_err(|_| BootstrapError::UnsafeStatePath)?, + )?; + Ok(directory) +} + +fn validate_owner_directory_metadata(metadata: &std::fs::Metadata) -> Result<(), BootstrapError> { + if !metadata.is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != current_euid() + || metadata.mode() & 0o777 != 0o700 + { + return Err(BootstrapError::UnsafeStatePath); + } + Ok(()) +} + +fn directory_names(directory: &File) -> Result, BootstrapError> { + let current = c"."; + let scan_fd = unsafe { + libc::openat( + directory.as_raw_fd(), + current.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if scan_fd < 0 { + return Err(BootstrapError::UnsafeStatePath); + } + let raw_stream = unsafe { libc::fdopendir(scan_fd) }; + if raw_stream.is_null() { + unsafe { libc::close(scan_fd) }; + return Err(BootstrapError::UnsafeStatePath); + } + let stream = DirectoryStream(raw_stream); + let mut names = BTreeSet::new(); + loop { + clear_errno(); + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + if current_errno() != 0 { return Err(BootstrapError::UnsafeStatePath); } + break; } - for name in allowed { - crate::runtime::key_store::remove_owner_only_user_service_file(&directory.join(name)) - .map_err(|_| BootstrapError::PersistenceUnavailable)?; + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) } + .to_str() + .map_err(|_| BootstrapError::UnsafeStatePath)?; + if name != "." && name != ".." { + names.insert(name.to_owned()); } - crate::runtime::key_store::remove_owner_only_empty_directory(&directory) - .map_err(|_| BootstrapError::PersistenceUnavailable)?; } - crate::runtime::key_store::remove_owner_only_empty_directory(&paths.identity_generations_dir) - .map_err(|_| BootstrapError::PersistenceUnavailable) + Ok(names) +} + +struct DirectoryStream(*mut libc::DIR); + +impl Drop for DirectoryStream { + fn drop(&mut self) { + unsafe { + libc::closedir(self.0); + } + } +} + +#[cfg(target_os = "macos")] +fn errno_location() -> *mut libc::c_int { + unsafe { libc::__error() } +} + +#[cfg(target_os = "linux")] +fn errno_location() -> *mut libc::c_int { + unsafe { libc::__errno_location() } +} + +fn clear_errno() { + unsafe { *errno_location() = 0 }; +} + +fn current_errno() -> libc::c_int { + unsafe { *errno_location() } +} + +fn current_euid() -> u32 { + unsafe { libc::geteuid() } } fn validate_pointer(pointer: &ActiveIdentityPointerV1) -> Result<(), BootstrapError> { @@ -323,11 +555,11 @@ fn sync_directory(path: &Path) -> Result<(), BootstrapError> { .map_err(|_| BootstrapError::PersistenceUnavailable) } -fn utf8(bytes: Vec) -> Result { - String::from_utf8(bytes).map_err(|error| { - let _rejected = zeroize::Zeroizing::new(error.into_bytes()); - BootstrapError::InvalidPki - }) +fn utf8_zeroizing( + bytes: zeroize::Zeroizing>, +) -> Result, BootstrapError> { + let value = std::str::from_utf8(&bytes).map_err(|_| BootstrapError::InvalidPki)?; + Ok(zeroize::Zeroizing::new(value.to_owned())) } fn hash(bytes: &[u8]) -> String { diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index a78975b..016a3dd 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -20,6 +20,9 @@ pub use enrollment::{ pub(crate) use enrollment::{ EnrollmentWireRequest, EnrollmentWireResponse, serialize_enrollment_request, }; +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub use identity_generation::remove_inactive_identity_generation_with_faults; pub use identity_generation::{ ActiveIdentityPointerV1, IdentityGenerationManifestV1, LoadedIdentityGeneration, load_active_identity_pointer, load_identity_generation, publish_active_identity, diff --git a/src/cli/lifecycle.rs b/src/cli/lifecycle.rs index 393f2f6..4b4df2f 100644 --- a/src/cli/lifecycle.rs +++ b/src/cli/lifecycle.rs @@ -14,10 +14,10 @@ use crate::{ write_identity_generation, }, protocol::{ - CredentialChain, NodeDepartureReceipt, NodeDepartureRequest, NodeId, NodeRole, + CredentialChain, DomainId, NodeDepartureReceipt, NodeDepartureRequest, NodeId, NodeRole, RenewalBundle, RenewalRequestClaims, RevocationAuthorizationClaims, - SignedRevocationAuthorization, sign_renewal_request, sign_revocation_authorization, - verify_credential_chain, + SignedRevocationAuthorization, WireEnvelope, sign_renewal_request, + sign_revocation_authorization, verify_credential_chain, }, runtime::{ Clock, NodeIdentity, RevocationCache, RevocationGuard, SystemClock, @@ -113,6 +113,30 @@ struct LeaveResult { service_stopped: bool, departure_recorded: bool, pending_departure: bool, + departure_status: &'static str, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PendingDepartureV1 { + format_version: String, + directory_node_id: NodeId, + request_envelope: WireEnvelope, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DepartureReceiptStateV1 { + format_version: String, + directory_node_id: NodeId, + request_envelope: WireEnvelope, + receipt_envelope: WireEnvelope, +} + +enum DepartureLocalState { + Missing, + Pending(Box), + Receipt(NodeDepartureReceipt), } #[derive(Serialize)] @@ -350,43 +374,66 @@ pub async fn leave(args: LeaveArgs) -> Result<(), CliError> { let phase = BootstrapStateStore::open(&paths.journal_file) .map_err(map_bootstrap)? .phase(); - if phase == BootstrapPhase::Left { - return output::emit( + let root = read_root(&paths)?; + let config = paths.read_config().map_err(map_bootstrap)?; + let node_id = read_node_id(&paths)?; + let seed = config.directory_seeds.first().ok_or_else(invalid_state)?; + match load_departure_state(&paths, &root, &config.domain_id, &node_id, &seed.node_id)? { + DepartureLocalState::Receipt(receipt) => { + reconcile_pending_after_receipt(&paths)?; + finish_leave( + &paths, + phase, + receipt.operation_id, + true, + false, + args.output, + ) + } + DepartureLocalState::Missing if phase == BootstrapPhase::Left => emit_leave( args.output, - "The node has already left AgenNet.", - &LeaveResult { - phase: "left", - service_stopped: true, - departure_recorded: true, - pending_departure: false, - }, - ); + true, + false, + false, + "unknown", + "The node is locally Left, but no verifiable Directory departure record exists.", + ), + local => { + let context = LifecycleContext::load(&paths).await?; + let pending = match local { + DepartureLocalState::Pending(pending) => pending, + DepartureLocalState::Missing => Box::new(persist_new_departure(&paths, &context)?), + DepartureLocalState::Receipt(_) => unreachable!(), + }; + let request = validate_pending_departure( + &pending, + &root, + &config.domain_id, + &node_id, + &seed.node_id, + )?; + let recorded = send_pending_departure(&paths, &context, &pending, &request).await; + match recorded { + Ok(receipt) => finish_leave( + &paths, + phase, + receipt.operation_id, + true, + false, + args.output, + ), + Err(error) if error.code == "DirectoryUnavailable" => finish_leave( + &paths, + phase, + request.operation_id, + false, + true, + args.output, + ), + Err(error) => Err(error), + } + } } - let context = LifecycleContext::load(&paths).await?; - let departure = send_departure(&paths, &context).await; - let service_stopped = super::service_node::stop_for_lifecycle(&paths)?; - let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; - state - .apply( - &format!("node-leave-{}", Uuid::new_v4()), - BootstrapTransition::Leave, - ) - .map_err(map_bootstrap)?; - let recorded = departure.is_ok(); - output::emit( - args.output, - if recorded { - "The node left AgenNet." - } else { - "The node stopped locally; Directory departure remains pending." - }, - &LeaveResult { - phase: "left", - service_stopped, - departure_recorded: recorded, - pending_departure: !recorded, - }, - ) } pub fn uninstall(args: UninstallArgs, terminal: &impl SecretTerminal) -> Result<(), CliError> { @@ -520,10 +567,10 @@ impl LifecycleContext { } } -async fn send_departure( +fn persist_new_departure( paths: &NodePaths, context: &LifecycleContext, -) -> Result { +) -> Result { let request = NodeDepartureRequest { format_version: "agenet.node-departure.v0.1".to_owned(), operation_id: Uuid::new_v4(), @@ -534,25 +581,48 @@ async fn send_departure( .identity .seal("node.departure.v1", &request) .map_err(|_| invalid_state())?; + let seed = context + .bundle + .config + .directory_seeds + .first() + .ok_or_else(invalid_state)?; + let pending = PendingDepartureV1 { + format_version: "agenet.pending-departure.v0.1".to_owned(), + directory_node_id: seed.node_id.clone(), + request_envelope: envelope, + }; atomic_write_owner_only_strict( &paths.pending_departure_file, - &serde_json::to_vec(&envelope).map_err(|_| invalid_state())?, + &serde_json::to_vec(&pending).map_err(|_| invalid_state())?, true, ) .map_err(|_| invalid_state())?; + Ok(pending) +} + +async fn send_pending_departure( + paths: &NodePaths, + context: &LifecycleContext, + pending: &PendingDepartureV1, + request: &NodeDepartureRequest, +) -> Result { let seed = context .bundle .config .directory_seeds .first() .ok_or_else(invalid_state)?; - let receipt = context + if seed.node_id != pending.directory_node_id { + return Err(invalid_state()); + } + let (receipt, receipt_envelope) = context .client - .post_signed_to_peer( + .post_signed_envelope_to_peer( seed.endpoint.as_str(), &seed.node_id, "/v0/nodes/depart", - &envelope, + &pending.request_envelope, "node.departure.receipt.v1", NodeRole::Directory, ) @@ -564,9 +634,23 @@ async fn send_departure( true, ) })?; + validate_receipt_binding( + request, + &receipt, + &receipt_envelope, + context.root, + &context.bundle.config.domain_id, + &seed.node_id, + )?; + let state = DepartureReceiptStateV1 { + format_version: "agenet.departure-receipt-state.v0.1".to_owned(), + directory_node_id: seed.node_id.clone(), + request_envelope: pending.request_envelope.clone(), + receipt_envelope, + }; atomic_write_owner_only_strict( &paths.departure_receipt_file, - &serde_json::to_vec(&receipt).map_err(|_| invalid_state())?, + &serde_json::to_vec(&state).map_err(|_| invalid_state())?, true, ) .map_err(|_| invalid_state())?; @@ -575,6 +659,222 @@ async fn send_departure( Ok(receipt) } +fn load_departure_state( + paths: &NodePaths, + root: &VerifyingKey, + domain: &DomainId, + node_id: &NodeId, + directory: &NodeId, +) -> Result { + let pending = read_optional_owner_json::(&paths.pending_departure_file)?; + let receipt = + read_optional_owner_json::(&paths.departure_receipt_file)?; + let pending_request = pending + .as_ref() + .map(|value| validate_pending_departure(value, root, domain, node_id, directory)) + .transpose()?; + if let Some(receipt_state) = receipt { + if receipt_state.format_version != "agenet.departure-receipt-state.v0.1" + || receipt_state.directory_node_id != *directory + { + return Err(invalid_state()); + } + let receipt_request = + open_departure_request(&receipt_state.request_envelope, root, domain, node_id)?; + if let Some(pending) = &pending + && pending.request_envelope != receipt_state.request_envelope + { + return Err(invalid_state()); + } + let receipt = + open_departure_receipt(&receipt_state.receipt_envelope, root, domain, directory)?; + validate_receipt_fields(&receipt_request, &receipt)?; + return Ok(DepartureLocalState::Receipt(receipt)); + } + Ok(match (pending, pending_request) { + (Some(pending), Some(_)) => DepartureLocalState::Pending(Box::new(pending)), + (None, None) => DepartureLocalState::Missing, + _ => return Err(invalid_state()), + }) +} + +fn validate_pending_departure( + pending: &PendingDepartureV1, + root: &VerifyingKey, + domain: &DomainId, + node_id: &NodeId, + directory: &NodeId, +) -> Result { + if pending.format_version != "agenet.pending-departure.v0.1" + || pending.directory_node_id != *directory + { + return Err(invalid_state()); + } + open_departure_request(&pending.request_envelope, root, domain, node_id) +} + +fn open_departure_request( + envelope: &WireEnvelope, + root: &VerifyingKey, + domain: &DomainId, + node_id: &NodeId, +) -> Result { + let claims = envelope.credential_chain.node.decode_claims_for_cli()?; + for role in [NodeRole::Requester, NodeRole::Executor, NodeRole::Verifier] { + if let Ok(opened) = envelope.open_with_verified_claims::( + "node.departure.v1", + root, + domain, + role, + claims.issued_at_ms, + ) { + let (request, verified) = opened.into_parts(); + if request.format_version == "agenet.node-departure.v0.1" + && !request.operation_id.is_nil() + && request.node_id == *node_id + && verified.node_id == *node_id + && request.requested_at_ms >= verified.issued_at_ms + && request.requested_at_ms < verified.expires_at_ms + { + return Ok(request); + } + } + } + Err(invalid_state()) +} + +fn open_departure_receipt( + envelope: &WireEnvelope, + root: &VerifyingKey, + domain: &DomainId, + directory: &NodeId, +) -> Result { + let claims = envelope.credential_chain.node.decode_claims_for_cli()?; + let opened = envelope + .open_with_verified_claims::( + "node.departure.receipt.v1", + root, + domain, + NodeRole::Directory, + claims.issued_at_ms, + ) + .map_err(|_| invalid_state())?; + let (receipt, verified) = opened.into_parts(); + if verified.node_id != *directory + || receipt.recorded_at_ms < verified.issued_at_ms + || receipt.recorded_at_ms >= verified.expires_at_ms + { + return Err(invalid_state()); + } + Ok(receipt) +} + +fn validate_receipt_binding( + request: &NodeDepartureRequest, + receipt: &NodeDepartureReceipt, + envelope: &WireEnvelope, + root: VerifyingKey, + domain: &DomainId, + directory: &NodeId, +) -> Result<(), CliError> { + let opened = open_departure_receipt(envelope, &root, domain, directory)?; + if &opened != receipt { + return Err(invalid_state()); + } + validate_receipt_fields(request, receipt) +} + +fn validate_receipt_fields( + request: &NodeDepartureRequest, + receipt: &NodeDepartureReceipt, +) -> Result<(), CliError> { + if receipt.format_version != "agenet.node-departure-receipt.v0.1" + || receipt.operation_id != request.operation_id + || receipt.node_id != request.node_id + { + return Err(invalid_state()); + } + Ok(()) +} + +fn read_optional_owner_json( + path: &Path, +) -> Result, CliError> { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(invalid_state()), + Ok(_) => { + let bytes = crate::runtime::key_store::read_owner_only(path, 256 * 1024) + .map_err(|_| invalid_state())?; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| invalid_state()) + } + } +} + +fn reconcile_pending_after_receipt(paths: &NodePaths) -> Result<(), CliError> { + crate::runtime::key_store::remove_owner_only_user_service_file(&paths.pending_departure_file) + .map_err(|_| invalid_state()) +} + +fn finish_leave( + paths: &NodePaths, + phase: BootstrapPhase, + operation_id: Uuid, + recorded: bool, + pending: bool, + format: OutputFormat, +) -> Result<(), CliError> { + let service_stopped = if phase == BootstrapPhase::Left { + true + } else { + super::service_node::stop_for_lifecycle(paths)? + }; + if phase != BootstrapPhase::Left { + let mut state = BootstrapStateStore::open(&paths.journal_file).map_err(map_bootstrap)?; + state + .apply( + &format!("node-leave-{operation_id}"), + BootstrapTransition::Leave, + ) + .map_err(map_bootstrap)?; + } + emit_leave( + format, + service_stopped, + recorded, + pending, + if recorded { "recorded" } else { "pending" }, + if recorded { + "The node left AgenNet with a verified Directory receipt." + } else { + "The node stopped locally; Directory departure remains pending." + }, + ) +} + +fn emit_leave( + format: OutputFormat, + service_stopped: bool, + recorded: bool, + pending: bool, + status: &'static str, + human: &'static str, +) -> Result<(), CliError> { + output::emit( + format, + human, + &LeaveResult { + phase: "left", + service_stopped, + departure_recorded: recorded, + pending_departure: pending, + departure_status: status, + }, + ) +} + fn persist_pending_renewal( paths: &NodePaths, request: &crate::protocol::SignedRenewalRequest, @@ -1193,8 +1493,13 @@ impl SignedNodeCredentialCli for crate::protocol::SignedNodeCredential { #[cfg(test)] mod tests { - use std::os::unix::fs::{PermissionsExt, symlink}; + use std::{ + collections::BTreeSet, + os::unix::fs::{PermissionsExt, symlink}, + }; + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use ed25519_dalek::SigningKey; use tempfile::TempDir; use super::*; @@ -1217,6 +1522,265 @@ mod tests { std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); } + fn identity( + root: &SigningKey, + node_key: SigningKey, + node_id: &str, + role: NodeRole, + now: i64, + ) -> NodeIdentity { + use crate::protocol::{ + AuthorityClaims, AuthorityScope, BootstrapProfile, NodeCredentialClaims, + SignedAuthorityCredential, + }; + let authority_key = SigningKey::from_bytes(&[111; 32]); + let authority = SignedAuthorityCredential::issue( + root, + AuthorityClaims { + domain_id: DomainId::new("domain:departure-state").unwrap(), + authority_id: NodeId::new("authority:departure-state").unwrap(), + signing_public_key_base64: STANDARD + .encode(authority_key.verifying_key().to_bytes()), + tls_ca_sha256: "ab".repeat(32), + scopes: BTreeSet::from([ + AuthorityScope::IssueNodeCredential, + AuthorityScope::IssueFoundingDirectoryCredential, + ]), + allowed_profiles: BTreeSet::from([BootstrapProfile::Base]), + maximum_node_lifetime_ms: 120_000, + issued_at_ms: now - 10_000, + expires_at_ms: now + 120_000, + }, + ) + .unwrap(); + let claims = NodeCredentialClaims { + format_version: "agenet.node-credential.v0.3".to_owned(), + domain_id: authority.claims.domain_id.clone(), + authority_id: authority.claims.authority_id.clone(), + node_id: NodeId::new(node_id).unwrap(), + signing_public_key_base64: STANDARD.encode(node_key.verifying_key().to_bytes()), + bootstrap_profile: BootstrapProfile::Base, + allowed_roles: BTreeSet::from([role]), + capability_ceiling: BTreeSet::new(), + issued_at_ms: now - 1_000, + expires_at_ms: now + 60_000, + }; + let node = if role == NodeRole::Directory { + authority.issue_founding_directory_credential( + &root.verifying_key(), + &authority_key, + claims, + now, + ) + } else { + authority.issue_node_credential(&root.verifying_key(), &authority_key, claims, now) + } + .unwrap(); + NodeIdentity::new( + node_key, + CredentialChain { authority, node }, + role, + root.verifying_key(), + now as u64, + ) + .unwrap() + } + + #[test] + fn pending_and_receipt_crash_state_reconciles_exact_request() { + let (_temp, paths) = paths(); + let now = 1_800_000_000_000_i64; + let root = SigningKey::from_bytes(&[112; 32]); + let node = identity( + &root, + SigningKey::from_bytes(&[113; 32]), + "node:leaver", + NodeRole::Requester, + now, + ); + let directory = identity( + &root, + SigningKey::from_bytes(&[114; 32]), + "node:directory-leave", + NodeRole::Directory, + now, + ); + let request = NodeDepartureRequest { + format_version: "agenet.node-departure.v0.1".to_owned(), + operation_id: Uuid::from_u128(900), + node_id: node.node_id().clone(), + requested_at_ms: now, + }; + let request_envelope = node.seal("node.departure.v1", &request).unwrap(); + let pending = PendingDepartureV1 { + format_version: "agenet.pending-departure.v0.1".to_owned(), + directory_node_id: directory.node_id().clone(), + request_envelope: request_envelope.clone(), + }; + owner_file( + &paths.pending_departure_file, + &serde_json::to_vec(&pending).unwrap(), + ); + assert!(matches!( + load_departure_state( + &paths, + &root.verifying_key(), + node.domain_id(), + node.node_id(), + directory.node_id(), + ) + .unwrap(), + DepartureLocalState::Pending(_) + )); + let receipt = NodeDepartureReceipt { + format_version: "agenet.node-departure-receipt.v0.1".to_owned(), + operation_id: request.operation_id, + node_id: request.node_id.clone(), + removed_manifests: 1, + recorded_at_ms: now, + }; + let receipt_state = DepartureReceiptStateV1 { + format_version: "agenet.departure-receipt-state.v0.1".to_owned(), + directory_node_id: directory.node_id().clone(), + request_envelope, + receipt_envelope: directory + .seal("node.departure.receipt.v1", &receipt) + .unwrap(), + }; + owner_file( + &paths.departure_receipt_file, + &serde_json::to_vec(&receipt_state).unwrap(), + ); + assert!(matches!( + load_departure_state( + &paths, + &root.verifying_key(), + node.domain_id(), + node.node_id(), + directory.node_id(), + ) + .unwrap(), + DepartureLocalState::Receipt(value) if value == receipt + )); + reconcile_pending_after_receipt(&paths).unwrap(); + assert!(!paths.pending_departure_file.exists()); + } + + #[test] + fn departure_receipt_wrong_signer_or_cross_request_fails_closed() { + let (_temp, paths) = paths(); + let now = 1_800_000_000_000_i64; + let root = SigningKey::from_bytes(&[115; 32]); + let node = identity( + &root, + SigningKey::from_bytes(&[116; 32]), + "node:leaver-two", + NodeRole::Requester, + now, + ); + let directory = identity( + &root, + SigningKey::from_bytes(&[117; 32]), + "node:directory-two", + NodeRole::Directory, + now, + ); + let wrong_directory = identity( + &root, + SigningKey::from_bytes(&[118; 32]), + "node:wrong-directory", + NodeRole::Directory, + now, + ); + let request = NodeDepartureRequest { + format_version: "agenet.node-departure.v0.1".to_owned(), + operation_id: Uuid::from_u128(901), + node_id: node.node_id().clone(), + requested_at_ms: now, + }; + let request_envelope = node.seal("node.departure.v1", &request).unwrap(); + let matching_receipt = NodeDepartureReceipt { + format_version: "agenet.node-departure-receipt.v0.1".to_owned(), + operation_id: request.operation_id, + node_id: request.node_id.clone(), + removed_manifests: 0, + recorded_at_ms: now, + }; + let wrong_signer = DepartureReceiptStateV1 { + format_version: "agenet.departure-receipt-state.v0.1".to_owned(), + directory_node_id: directory.node_id().clone(), + request_envelope: request_envelope.clone(), + receipt_envelope: wrong_directory + .seal("node.departure.receipt.v1", &matching_receipt) + .unwrap(), + }; + owner_file( + &paths.departure_receipt_file, + &serde_json::to_vec(&wrong_signer).unwrap(), + ); + assert!( + load_departure_state( + &paths, + &root.verifying_key(), + node.domain_id(), + node.node_id(), + directory.node_id(), + ) + .is_err() + ); + + let mut tampered_receipt = DepartureReceiptStateV1 { + format_version: "agenet.departure-receipt-state.v0.1".to_owned(), + directory_node_id: directory.node_id().clone(), + request_envelope: node.seal("node.departure.v1", &request).unwrap(), + receipt_envelope: directory + .seal("node.departure.receipt.v1", &matching_receipt) + .unwrap(), + }; + tampered_receipt.receipt_envelope.signature_base64 = "AA==".to_owned(); + owner_file( + &paths.departure_receipt_file, + &serde_json::to_vec(&tampered_receipt).unwrap(), + ); + assert!( + load_departure_state( + &paths, + &root.verifying_key(), + node.domain_id(), + node.node_id(), + directory.node_id(), + ) + .is_err() + ); + + let cross_request_receipt = NodeDepartureReceipt { + operation_id: Uuid::from_u128(902), + ..matching_receipt + }; + let cross_request = DepartureReceiptStateV1 { + format_version: "agenet.departure-receipt-state.v0.1".to_owned(), + directory_node_id: directory.node_id().clone(), + request_envelope, + receipt_envelope: directory + .seal("node.departure.receipt.v1", &cross_request_receipt) + .unwrap(), + }; + owner_file( + &paths.departure_receipt_file, + &serde_json::to_vec(&cross_request).unwrap(), + ); + assert!( + load_departure_state( + &paths, + &root.verifying_key(), + node.domain_id(), + node.node_id(), + directory.node_id(), + ) + .is_err() + ); + } + #[test] fn purge_removes_only_allowlist_and_retains_root_and_authority_material() { let (_temp, paths) = paths(); diff --git a/src/runtime/directory.rs b/src/runtime/directory.rs index f42f701..3cb7f60 100644 --- a/src/runtime/directory.rs +++ b/src/runtime/directory.rs @@ -63,7 +63,8 @@ impl DirectoryRegistry { if request.format_version != "agenet.node-departure.v0.1" || request.operation_id.is_nil() || request.node_id != claims.node_id - || request.requested_at_ms.abs_diff(now_ms) > 30_000 + || request.requested_at_ms <= 0 + || request.requested_at_ms > now_ms.saturating_add(30_000) { return Err(RuntimeError::ManifestProviderMismatch); } diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index a9cd251..4fcebd2 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -34,24 +34,28 @@ fn write_signing_key_with_policy( } pub fn read_signing_key(path: &Path) -> Result { - decode_signing_key(read_owner_only_final(path, 4096)?) + decode_signing_key_hardened_bytes(Zeroizing::new(read_owner_only_final(path, 4096)?)) } pub(crate) fn read_signing_key_hardened(path: &Path) -> Result { - decode_signing_key(read_owner_only(path, 4096)?) -} - -fn decode_signing_key(encoded_bytes: Vec) -> Result { - let encoded = match String::from_utf8(encoded_bytes) { - Ok(value) => Zeroizing::new(value), - Err(error) => { - let _rejected = Zeroizing::new(error.into_bytes()); - return Err(RuntimeError::InvalidPrivateKey); - } - }; + decode_signing_key_hardened_bytes(Zeroizing::new(read_owner_only(path, 4096)?)) +} + +pub(crate) fn decode_signing_key_hardened_bytes( + encoded_bytes: Zeroizing>, +) -> Result { + let start = encoded_bytes + .iter() + .position(|byte| !byte.is_ascii_whitespace()) + .ok_or(RuntimeError::InvalidPrivateKey)?; + let end = encoded_bytes + .iter() + .rposition(|byte| !byte.is_ascii_whitespace()) + .map(|index| index + 1) + .ok_or(RuntimeError::InvalidPrivateKey)?; let bytes = Zeroizing::new( STANDARD - .decode(encoded.trim()) + .decode(&encoded_bytes[start..end]) .map_err(|_| RuntimeError::InvalidPrivateKey)?, ); let secret = Zeroizing::new( @@ -200,34 +204,6 @@ pub(crate) fn remove_owner_only_user_service_file(path: &Path) -> Result<(), Run remove_owner_only_user_service_file_with_hook(path, || {}) } -pub(crate) fn remove_owner_only_empty_directory(path: &Path) -> Result<(), RuntimeError> { - let (parent, name) = open_secure_service_parent(path, false)?; - let directory = match openat_directory(&parent, &name) { - Ok(directory) => directory, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(_) => return Err(RuntimeError::Io), - }; - let opened = directory.metadata()?; - if opened.uid() != unsafe { libc::geteuid() } || opened.mode() & 0o777 != 0o700 { - return Err(RuntimeError::Io); - } - let mut current = std::mem::MaybeUninit::::uninit(); - cvt(unsafe { - libc::fstatat( - parent.as_raw_fd(), - name.as_ptr(), - current.as_mut_ptr(), - libc::AT_SYMLINK_NOFOLLOW, - ) - })?; - let current = unsafe { current.assume_init() }; - if opened.dev() != current.st_dev as u64 || opened.ino() != current.st_ino { - return Err(RuntimeError::Io); - } - cvt(unsafe { libc::unlinkat(parent.as_raw_fd(), name.as_ptr(), libc::AT_REMOVEDIR) })?; - parent.sync_all().map_err(Into::into) -} - fn remove_owner_only_user_service_file_with_hook( path: &Path, after_walk: AfterWalk, diff --git a/src/transport/client.rs b/src/transport/client.rs index c6d97e3..2597f9d 100644 --- a/src/transport/client.rs +++ b/src/transport/client.rs @@ -255,6 +255,39 @@ impl PeerClient { .await } + pub async fn post_signed_envelope_to_peer( + &self, + endpoint: &str, + expected_peer: &NodeId, + path: &str, + envelope: &WireEnvelope, + expected_object_type: &str, + expected_response_role: NodeRole, + ) -> Result<(T, WireEnvelope), TransportError> { + self.validate_local_policy()?; + let identity = self + .dynamic_identity + .as_ref() + .ok_or(TransportError::TlsIdentityMismatch)?; + let bound = Self::new_mtls_with_clock( + self.root, + self.domain_id.clone(), + Arc::clone(&self.clock), + self.boundary.clone(), + identity, + expected_peer.clone(), + )?; + bound + .post_signed_envelope( + endpoint, + path, + envelope, + expected_object_type, + expected_response_role, + ) + .await + } + pub async fn post_signed_peer_or_loopback( &self, endpoint: &str, @@ -302,6 +335,25 @@ impl PeerClient { expected_object_type: &str, expected_response_role: NodeRole, ) -> Result { + self.post_signed_envelope( + endpoint, + path, + envelope, + expected_object_type, + expected_response_role, + ) + .await + .map(|(payload, _)| payload) + } + + async fn post_signed_envelope( + &self, + endpoint: &str, + path: &str, + envelope: &WireEnvelope, + expected_object_type: &str, + expected_response_role: NodeRole, + ) -> Result<(T, WireEnvelope), TransportError> { self.validate_local_policy()?; let url = endpoint_url(&self.boundary, endpoint, path)?; if !url_host_is_loopback(&url) && self.expected_tls_peer.is_none() { @@ -358,7 +410,7 @@ impl PeerClient { { return Err(TransportError::TlsIdentityMismatch); } - Ok(payload) + Ok((payload, response_envelope)) } pub async fn post_signed_read( diff --git a/tests/bootstrap_identity_generation.rs b/tests/bootstrap_identity_generation.rs index c1dd958..1cbfc42 100644 --- a/tests/bootstrap_identity_generation.rs +++ b/tests/bootstrap_identity_generation.rs @@ -1,68 +1,289 @@ -use std::os::unix::fs::PermissionsExt; +mod common; -use agenet::bootstrap::{ - ActiveIdentityPointerV1, NodePathEnvironment, NodePaths, UserPlatform, - load_active_identity_pointer, +use std::os::unix::fs::{PermissionsExt, symlink}; + +use agenet::{ + bootstrap::network::NetworkBoundary, + bootstrap::{ + ActiveIdentityPointerV1, AuthorityPki, IdentityGenerationManifestV1, NodeConfigV1, + NodePathEnvironment, NodePaths, NodeTlsCsr, UserPlatform, load_identity_generation, + publish_active_identity, remove_inactive_identity_generation_with_faults, + write_identity_generation, + }, + protocol::{BootstrapProfile, DirectorySeed, NodeId, NodeRole}, + runtime::write_signing_key, + transport::PeerTlsIdentity, }; +use ed25519_dalek::SigningKey; +use sha2::{Digest, Sha256}; use tempfile::TempDir; +use url::Url; +use zeroize::Zeroizing; -fn paths() -> (TempDir, NodePaths) { - let temp = TempDir::new().unwrap(); - let home = temp.path().canonicalize().unwrap(); - std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap(); - let paths = NodePaths::resolve( - UserPlatform::MacOs, - &NodePathEnvironment::new(home, None, None), - ) - .unwrap(); - paths.ensure_secure_layout().unwrap(); - (temp, paths) +const NOW: i64 = 1_800_000_000_000; + +struct Fixture { + _temp: TempDir, + paths: NodePaths, + pointer: ActiveIdentityPointerV1, +} + +impl Fixture { + fn new() -> Self { + let temp = TempDir::new().unwrap(); + let home = temp.path().canonicalize().unwrap(); + std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700)).unwrap(); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(home, None, None), + ) + .unwrap(); + paths.ensure_secure_layout().unwrap(); + paths + .write_config(&NodeConfigV1 { + format: "agenet.node-config".to_owned(), + schema_version: 2, + domain_id: common::domain_id(), + profile: BootstrapProfile::Base, + network: NetworkBoundary::loopback_ipv4(), + peer_port: 7443, + directory_seeds: vec![DirectorySeed { + endpoint: Url::parse("https://127.0.0.1:7443/").unwrap(), + node_id: NodeId::new("node:directory-generation").unwrap(), + }], + authority_endpoint: Url::parse("https://127.0.0.1:7444/").unwrap(), + revocation_endpoint: Url::parse("https://127.0.0.1:7444/").unwrap(), + }) + .unwrap(); + let root = SigningKey::from_bytes(&[91; 32]); + let node = SigningKey::from_bytes(&[92; 32]); + let chain = common::credential_chain( + &root, + &node, + "node:generation", + NodeRole::Requester, + NOW as u64, + ); + let ca = AuthorityPki::generate(NOW - 10_000, NOW + 120_000).unwrap(); + let csr = NodeTlsCsr::generate().unwrap(); + let certificate = ca + .issue_peer( + &csr.csr_pem, + &NodeId::new("node:generation").unwrap(), + "127.0.0.1".parse().unwrap(), + NOW - 1_000, + NOW + 60_000, + ) + .unwrap(); + let identity = PeerTlsIdentity { + node_id: NodeId::new("node:generation").unwrap(), + certificate_chain_pem: Zeroizing::new(certificate.cert_pem), + private_key_pem: csr.private_key_pem, + authority_ca_pem: ca.ca_cert_pem.to_string(), + }; + let pointer = + write_identity_generation(&paths, uuid::Uuid::new_v4(), &chain, &node, &identity) + .unwrap(); + publish_active_identity(&paths, &pointer).unwrap(); + Self { + _temp: temp, + paths, + pointer, + } + } + + fn generation(&self) -> std::path::PathBuf { + self.paths + .identity_generations_dir + .join(&self.pointer.generation_id) + } + + fn rewrite_manifest(&mut self, mutate: impl FnOnce(&mut IdentityGenerationManifestV1)) { + let path = self.generation().join("identity-manifest-v1.json"); + let mut manifest: IdentityGenerationManifestV1 = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + mutate(&mut manifest); + let bytes = serde_json::to_vec(&manifest).unwrap(); + std::fs::write(&path, &bytes).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + self.pointer.manifest_sha256 = sha256(&bytes); + let pointer = serde_json::to_vec(&self.pointer).unwrap(); + std::fs::write(&self.paths.active_identity_file, pointer).unwrap(); + std::fs::set_permissions( + &self.paths.active_identity_file, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + + fn make_active_replacement(&self) { + let loaded = load_identity_generation(&self.paths, &self.pointer).unwrap(); + let replacement = write_identity_generation( + &self.paths, + uuid::Uuid::new_v4(), + &loaded.credential, + &loaded.signing_key, + &loaded.tls_identity, + ) + .unwrap(); + publish_active_identity(&self.paths, &replacement).unwrap(); + } +} + +fn sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let encoded = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("sha256:{encoded}") } #[test] -fn active_pointer_rejects_traversal_and_symlink_generation() { - let (_temp, paths) = paths(); - let pointer = ActiveIdentityPointerV1::for_test("../outside"); - std::fs::write( - &paths.active_identity_file, - serde_json::to_vec(&pointer).unwrap(), - ) - .unwrap(); - std::fs::set_permissions( - &paths.active_identity_file, - std::fs::Permissions::from_mode(0o600), - ) - .unwrap(); - assert!(load_active_identity_pointer(&paths).is_err()); +fn production_generation_round_trip_is_complete() { + let fixture = Fixture::new(); + let loaded = load_identity_generation(&fixture.paths, &fixture.pointer).unwrap(); + assert_eq!( + loaded.generation_id.to_string(), + fixture.pointer.generation_id + ); + assert_eq!(loaded.tls_identity.node_id.as_str(), "node:generation"); } #[test] -fn incomplete_unreferenced_generation_cannot_change_active_identity() { - let (_temp, paths) = paths(); - let old = uuid::Uuid::new_v4(); - let pointer = ActiveIdentityPointerV1::for_test(old.to_string()); - std::fs::write( - &paths.active_identity_file, - serde_json::to_vec(&pointer).unwrap(), - ) - .unwrap(); - std::fs::set_permissions( - &paths.active_identity_file, - std::fs::Permissions::from_mode(0o600), +fn signing_key_replacement_without_manifest_change_is_rejected() { + let fixture = Fixture::new(); + write_signing_key( + &fixture.generation().join("node-signing-key-v1.key"), + &SigningKey::from_bytes(&[93; 32]), ) .unwrap(); - let incomplete = paths + assert!(load_identity_generation(&fixture.paths, &fixture.pointer).is_err()); +} + +#[test] +fn manifest_with_unknown_fifth_key_and_missing_signing_key_is_rejected() { + let mut fixture = Fixture::new(); + fixture.rewrite_manifest(|manifest| { + manifest.file_sha256.remove("node-signing-key-v1.key"); + manifest + .file_sha256 + .insert("attacker-material".to_owned(), "sha256:00".to_owned()); + }); + assert!(load_identity_generation(&fixture.paths, &fixture.pointer).is_err()); +} + +#[test] +fn unknown_generation_entry_is_rejected() { + let fixture = Fixture::new(); + let unknown = fixture.generation().join("attacker-material"); + std::fs::write(&unknown, b"not part of the generation").unwrap(); + std::fs::set_permissions(&unknown, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(load_identity_generation(&fixture.paths, &fixture.pointer).is_err()); +} + +#[test] +fn every_material_tamper_and_missing_file_is_rejected() { + const MATERIALS: [&str; 5] = [ + "node-credential-v1.json", + "node-signing-key-v1.key", + "peer-certificate-v1.pem", + "peer-private-key-v1.pem", + "authority-ca-v1.pem", + ]; + for material in MATERIALS { + let fixture = Fixture::new(); + let path = fixture.generation().join(material); + std::fs::write(&path, b"tampered").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(load_identity_generation(&fixture.paths, &fixture.pointer).is_err()); + + let fixture = Fixture::new(); + std::fs::remove_file(fixture.generation().join(material)).unwrap(); + assert!(load_identity_generation(&fixture.paths, &fixture.pointer).is_err()); + } +} + +#[test] +fn symlinked_generation_material_is_rejected() { + let fixture = Fixture::new(); + let material = fixture.generation().join("peer-private-key-v1.pem"); + let outside = fixture.paths.state_dir.join("outside-key"); + std::fs::write(&outside, b"outside").unwrap(); + std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::fs::remove_file(&material).unwrap(); + symlink(&outside, &material).unwrap(); + assert!(load_identity_generation(&fixture.paths, &fixture.pointer).is_err()); + assert_eq!(std::fs::read(outside).unwrap(), b"outside"); +} + +#[test] +fn cleanup_is_anchored_to_pinned_directory_and_preserves_substitute() { + let fixture = Fixture::new(); + fixture.make_active_replacement(); + let original = fixture.generation(); + let moved = fixture + .paths .identity_generations_dir - .join(uuid::Uuid::new_v4().to_string()); - std::fs::create_dir_all(&incomplete).unwrap(); - std::fs::set_permissions( - &paths.identity_generations_dir, - std::fs::Permissions::from_mode(0o700), - ) - .unwrap(); - std::fs::set_permissions(&incomplete, std::fs::Permissions::from_mode(0o700)).unwrap(); + .join(format!("{}.retired", fixture.pointer.generation_id)); + let substitute = original.clone(); + let result = remove_inactive_identity_generation_with_faults( + &fixture.paths, + &fixture.pointer, + || { + std::fs::rename(&original, &moved).unwrap(); + std::fs::create_dir(&substitute).unwrap(); + std::fs::set_permissions(&substitute, std::fs::Permissions::from_mode(0o700)).unwrap(); + let marker = substitute.join("attacker-marker"); + std::fs::write(&marker, b"preserve").unwrap(); + std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).unwrap(); + Ok(()) + }, + |_| Ok(()), + ); + assert!(result.is_err()); assert_eq!( - load_active_identity_pointer(&paths).unwrap().generation_id, - old.to_string() + std::fs::read(substitute.join("attacker-marker")).unwrap(), + b"preserve" + ); +} + +#[test] +fn post_unlink_sync_fault_returns_uncertain_without_continuing_cleanup() { + let fixture = Fixture::new(); + fixture.make_active_replacement(); + let mut injected = false; + let result = remove_inactive_identity_generation_with_faults( + &fixture.paths, + &fixture.pointer, + || Ok(()), + |_| { + if !injected { + injected = true; + return Err(agenet::bootstrap::BootstrapError::PersistenceUnavailable); + } + Ok(()) + }, ); + assert!(result.is_err()); + assert!( + !fixture + .generation() + .join("identity-manifest-v1.json") + .exists() + ); + assert!( + fixture + .generation() + .join("node-credential-v1.json") + .exists() + ); +} + +#[test] +fn pointer_traversal_is_rejected() { + let fixture = Fixture::new(); + let mut pointer = fixture.pointer; + pointer.generation_id = "../outside".to_owned(); + assert!(load_identity_generation(&fixture.paths, &pointer).is_err()); } diff --git a/tests/doctor_cli.rs b/tests/doctor_cli.rs index bdbbccc..5fc8271 100644 --- a/tests/doctor_cli.rs +++ b/tests/doctor_cli.rs @@ -1,5 +1,24 @@ use agenet::cli_diagnostics::{CheckStatus, DoctorCheck, DoctorReport}; -use std::{os::unix::fs::PermissionsExt, process::Command}; +use std::{ + ffi::CString, + os::unix::fs::{MetadataExt, PermissionsExt, symlink}, + process::Command, +}; + +use agenet::{ + bootstrap::{ + AuthorityPki, NodeConfigV1, NodePathEnvironment, NodePaths, NodeTlsCsr, UserPlatform, + network::NetworkBoundary, + }, + protocol::{BootstrapProfile, DirectorySeed, NodeId, NodeRole}, + transport::PeerTlsIdentity, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::SigningKey; +use url::Url; +use zeroize::Zeroizing; + +mod common; #[test] fn doctor_json_is_deterministic_and_sanitized() { @@ -62,3 +81,166 @@ fn doctor_process_is_read_only_bounded_and_redacted() { .exists() ); } + +#[test] +fn doctor_rejects_unsafe_config_inputs_without_mutation_or_disclosure() { + for case in ["symlink", "fifo", "oversize", "bad-mode"] { + let home = tempfile::TempDir::new().unwrap(); + std::fs::set_permissions(home.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let canonical_home = home.path().canonicalize().unwrap(); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(canonical_home.clone(), None, None), + ) + .unwrap(); + paths.ensure_secure_layout().unwrap(); + let sentinel = "DOCTOR-UNSAFE-INPUT-SENTINEL"; + let outside = canonical_home.join("outside-doctor-input"); + match case { + "symlink" => { + std::fs::write(&outside, sentinel).unwrap(); + symlink(&outside, &paths.config_file).unwrap(); + } + "fifo" => { + let path = CString::new(paths.config_file.as_os_str().as_encoded_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + } + "oversize" => { + let mut bytes = vec![b'x'; 256 * 1024 + 1]; + bytes[..sentinel.len()].copy_from_slice(sentinel.as_bytes()); + std::fs::write(&paths.config_file, bytes).unwrap(); + std::fs::set_permissions( + &paths.config_file, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + "bad-mode" => { + std::fs::write(&paths.config_file, sentinel).unwrap(); + std::fs::set_permissions( + &paths.config_file, + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + } + _ => unreachable!(), + } + let before = std::fs::symlink_metadata(&paths.config_file).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_agenet")) + .args(["node", "doctor", "--output", "json"]) + .env("HOME", &canonical_home) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "case={case}"); + let encoded = String::from_utf8(output.stdout).unwrap(); + let report: serde_json::Value = serde_json::from_str(&encoded).unwrap(); + assert_eq!(report["format"], "agenet.doctor.v0.1"); + assert!(!encoded.contains(sentinel), "case={case}"); + assert!(!encoded.contains(canonical_home.to_string_lossy().as_ref())); + assert!(!encoded.contains("127.0.0.1")); + let after = std::fs::symlink_metadata(&paths.config_file).unwrap(); + assert_eq!(before.ino(), after.ino(), "case={case}"); + assert_eq!(before.mode(), after.mode(), "case={case}"); + assert_eq!(before.len(), after.len(), "case={case}"); + if case == "symlink" { + assert_eq!(std::fs::read_to_string(&outside).unwrap(), sentinel); + } + } +} + +#[test] +fn doctor_reports_credential_tls_key_mismatch_without_disclosing_material() { + let home = tempfile::TempDir::new().unwrap(); + std::fs::set_permissions(home.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let canonical_home = home.path().canonicalize().unwrap(); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(canonical_home.clone(), None, None), + ) + .unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + paths + .write_config(&NodeConfigV1 { + format: "agenet.node-config".to_owned(), + schema_version: 2, + domain_id: common::domain_id(), + profile: BootstrapProfile::Base, + network: NetworkBoundary::loopback_ipv4(), + peer_port: 7443, + directory_seeds: vec![DirectorySeed { + endpoint: Url::parse("https://127.0.0.1:7443/").unwrap(), + node_id: NodeId::new("node:directory-doctor").unwrap(), + }], + authority_endpoint: Url::parse("https://127.0.0.1:7444/").unwrap(), + revocation_endpoint: Url::parse("https://127.0.0.1:7444/").unwrap(), + }) + .unwrap(); + let root = SigningKey::from_bytes(&[121; 32]); + let node = SigningKey::from_bytes(&[122; 32]); + let chain = common::credential_chain( + &root, + &node, + "node:doctor-mismatch", + NodeRole::Requester, + now as u64, + ); + let ca = AuthorityPki::generate(now - 10_000, now + 120_000).unwrap(); + let certificate_key = NodeTlsCsr::generate().unwrap(); + let unrelated_key = NodeTlsCsr::generate().unwrap(); + let certificate = ca + .issue_peer( + &certificate_key.csr_pem, + &NodeId::new("node:doctor-mismatch").unwrap(), + "127.0.0.1".parse().unwrap(), + now - 1_000, + now + 60_000, + ) + .unwrap(); + paths + .write_startup_material( + &chain, + &node, + &PeerTlsIdentity { + node_id: NodeId::new("node:doctor-mismatch").unwrap(), + certificate_chain_pem: Zeroizing::new(certificate.cert_pem), + private_key_pem: unrelated_key.private_key_pem, + authority_ca_pem: ca.ca_cert_pem.to_string(), + }, + ) + .unwrap(); + std::fs::write( + &paths.root_public_key_file, + STANDARD.encode(root.verifying_key().to_bytes()), + ) + .unwrap(); + std::fs::set_permissions( + &paths.root_public_key_file, + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let private_key_before = std::fs::read(&paths.tls_private_key_file).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_agenet")) + .args(["node", "doctor", "--output", "json"]) + .env("HOME", &canonical_home) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let encoded = String::from_utf8(output.stdout).unwrap(); + let report: serde_json::Value = serde_json::from_str(&encoded).unwrap(); + assert!( + report["checks"] + .as_array() + .unwrap() + .iter() + .any(|check| { check["code"] == "credential.chain" && check["status"] == "error" }) + ); + assert!(!encoded.contains("PRIVATE KEY")); + assert!(!encoded.contains(canonical_home.to_string_lossy().as_ref())); + assert_eq!( + std::fs::read(&paths.tls_private_key_file).unwrap(), + private_key_before + ); +} diff --git a/tests/http_directory.rs b/tests/http_directory.rs index 6a49c70..624a529 100644 --- a/tests/http_directory.rs +++ b/tests/http_directory.rs @@ -454,6 +454,45 @@ async fn signed_departure_removes_only_issuer_manifests_and_replays_exact_receip ); } +#[tokio::test] +async fn durable_departure_request_remains_retriable_after_initial_directory_outage() { + let root = signing_key(83); + let clock = Arc::new(TestClock(AtomicI64::new(NOW as i64))); + let directory_key = signing_key(84); + let directory = NodeIdentity::new_with_clock( + directory_key.clone(), + credential( + &root, + &directory_key, + "node:directory-departure-retry", + NodeRole::Directory, + ), + NodeRole::Directory, + root.verifying_key(), + clock.clone(), + ) + .unwrap(); + let executor = identity( + &root, + signing_key(85), + "node:executor-departure-retry", + NodeRole::Executor, + ); + let app = directory_router(DirectoryRegistry::new(), directory.clone(), NOW); + let request = NodeDepartureRequest { + format_version: "agenet.node-departure.v0.1".to_owned(), + operation_id: Uuid::from_u128(802), + node_id: executor.node_id().clone(), + requested_at_ms: NOW as i64, + }; + let envelope = executor.seal("node.departure.v1", &request).unwrap(); + + clock.0.store(NOW as i64 + 31_000, Ordering::SeqCst); + let receipt = departure_receipt(&app, &directory, &envelope).await; + assert_eq!(receipt.operation_id, request.operation_id); + assert_eq!(receipt.node_id, request.node_id); +} + async fn departure_receipt( app: &axum::Router, directory: &NodeIdentity, From ff8e09734dc00d1c4cde0b37e723e9138857ccaf Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 08:06:20 +0800 Subject: [PATCH 40/67] [bug] Serialize identity generation changes Root cause: Default tests imported an all-feature-only fault seam. Identity readers, writers, and cleanup lacked a concurrency boundary. Solution: Keep production security tests enabled by default. Serialize renew and purge with an owner-only operation lock. Protect active loads, publish, rollback, cleanup, and purge with shared/exclusive advisory locks on the pinned generation directory. Risks: Advisory locks do not stop malicious same-euid processes that ignore them. Linux native service adoption and physical overlay validation remain deferred. Dependency: 740c4f9905615a10ee9057edf2e39bbd6b524dc7 Links: plan/01-v3-multi-host-node-bootstrap.md Post-mortem: Run default and all-feature gates for every gated test. Prove selector-cleanup races with real thread and process locks. --- README.md | 10 + ROADMAP.md | 9 + docs/design/agenet-v0.1.md | 17 ++ plan/01-v3-multi-host-node-bootstrap.md | 20 ++ src/bootstrap/config.rs | 7 +- src/bootstrap/identity_generation.rs | 129 ++++++++- src/bootstrap/mod.rs | 11 +- src/bootstrap/paths.rs | 2 + src/cli/doctor.rs | 5 +- src/cli/lifecycle.rs | 24 +- src/runtime/key_store.rs | 25 +- tests/bootstrap_identity_generation.rs | 370 ++++++++++++++++++++---- 12 files changed, 549 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 290c4cb..e19c56b 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,16 @@ the actual user service and requires its readiness plus new-identity mTLS health before retired-key cleanup. Cleanup uncertainty retains old material and emits a warning rather than claiming deletion. +Identity lifecycle mutations are serialized at two levels. Credential renewal +and destructive purge hold one owner-only `identity-operation-v1.lock` across +the entire workflow. Startup readers take a short shared advisory lock on the +owner-only identity-generation directory, while pointer publish/rollback, +inactive cleanup, and purge take it exclusively. The exclusive pointer lock is +released before restarting the user service, so HostRuntime can load the newly +selected generation without deadlocking. These locks coordinate honest AgenNet +processes under one account; a malicious same-euid process that ignores +advisory locks remains outside the v0.2 threat boundary. + `node revoke` is available only on the founding administrative host and requires a controlling TTY, exact NodeId confirmation, and hidden Domain Root unlock. `node leave` stops the service but retains identity, config, credentials, diff --git a/ROADMAP.md b/ROADMAP.md index 422b1fb..2d66942 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Task 13 review: serialize identity generation changes + +- **Change**: Restored default-feature generation security tests and added two-level advisory locking for lifecycle operations and active identity generations. +- **Files**: identity generation/key-store/path persistence, startup/doctor/lifecycle loaders, generation concurrency tests, README/design, and v3 plan amendment. +- **Root cause / classification**: **规则违反 / 技术盲区**. Fault-only test imports were feature-gated at the library boundary but imported unconditionally by a default test target, breaking `cargo test --all-targets`. Generation validation and fd-relative cleanup also lacked coordination with concurrent pointer publication, so a retired generation could become active after cleanup's initial check. +- **Solution**: Core production roundtrip/tamper/purge tests now compile under default features, while only narrow fault seams remain feature-gated. A full-workflow operation lock serializes renew/purge, and shared/exclusive `flock` on the pinned identity-generation directory serializes load against publish/rollback/cleanup/purge. Cleanup re-reads active under exclusive ownership and retains the lock through unlink and sync. +- **Prevention**: Every feature-gated test import must be exercised by both default and all-feature compile gates. Security cleanup designs must identify the concurrent selector writer and prove reader/writer blocking with real OS locks, including cross-process and abrupt-exit recovery. +- **Boundary**: Advisory locks coordinate AgenNet processes but do not defend against a malicious same-euid process that ignores them. Physical overlay and Linux native service adoption remain Task 14 gaps. + ## 2026-08-15 — Task 13 review: pin lifecycle recovery artifacts - **Change**: Hardened identity generation load/cleanup and made Directory departure recovery exact and durable across offline `Left` and receipt-write crashes. diff --git a/docs/design/agenet-v0.1.md b/docs/design/agenet-v0.1.md index 12ce771..f5ba8a8 100644 --- a/docs/design/agenet-v0.1.md +++ b/docs/design/agenet-v0.1.md @@ -152,6 +152,23 @@ the same descriptor-relative bytes. Cleanup verifies the parent entry still names the pinned device/inode before removing the directory; a rename or attacker substitute is retained with a cleanup warning. +Identity mutation has two lock scopes. The owner-only +`identity-operation-v1.lock` is exclusive and nonblocking across a full renew or +destructive purge workflow. The `0700` identity-generation directory descriptor +is the equivalent active-pointer lock: readers use shared `flock`, while +pointer publish/rollback, cleanup, and purge use exclusive `flock`. Cleanup +re-reads the pointer only after acquiring exclusive ownership and retains that +lock through child unlink, parent inode comparison, and sync. Pointer writers +release it before service restart, allowing HostRuntime to load under a shared +lock while the higher lifecycle operation remains serialized. + +These are advisory same-euid coordination locks, not a defense against a +malicious process already executing as the node account. The filesystem +boundary still requires `0700` generation directories and descriptor-relative +`O_NOFOLLOW` regular owner/mode-checked child access. Same-directory leaf +replacement by a same-euid attacker is documented outside the v0.2 threat +model; physical host compromise is not represented as prevented. + Doctor never creates, refreshes, repairs, or rewrites state. It bounds reads and network time, disables proxy and redirects, and checks owner/mode/type/symlink, config schema, full credential/TLS/key binding and expiry, bind ownership, diff --git a/plan/01-v3-multi-host-node-bootstrap.md b/plan/01-v3-multi-host-node-bootstrap.md index 61e6eb4..a01d6a5 100644 --- a/plan/01-v3-multi-host-node-bootstrap.md +++ b/plan/01-v3-multi-host-node-bootstrap.md @@ -42,6 +42,16 @@ new credential/TLS files, and lifecycle cleanup must remain recoverable. A later `node leave` reuses its operation ID and envelope even after the local phase is `Left`. Persist and sync the Directory-signed receipt before clearing pending state; pending plus receipt is a recoverable crash state. +8. Serialize credential renewal and destructive purge with one owner-only, + nonblocking `identity-operation-v1.lock` held across the complete lifecycle + workflow, including service adoption and retired cleanup. HostRuntime never + takes this high-level lock. +9. Use the owner-only `identity-generations/` directory descriptor as the + equivalent active-pointer read/write lock: startup/load holds a short shared + `flock`; pointer publish/rollback, inactive cleanup, and purge hold an + exclusive lock. Cleanup re-reads the active pointer after locking and keeps + the lock through fd-relative unlink and sync. Service restart starts only + after the pointer writer releases the exclusive lock. ## Acceptance criteria @@ -56,6 +66,11 @@ new credential/TLS files, and lifecycle cleanup must remain recoverable. a later online retry sends the identical signed request, validates the exact Directory signer and request binding, persists the receipt, and clears the pending record. Missing or tampered receipt state is never reported recorded. +- Concurrent retired publication blocks behind cleanup and then fails because + the retired generation no longer exists; a shared reader blocks cleanup until + its complete generation is decoded. A second renewal gets a stable busy + result, including across processes, and an abruptly exited lock holder does + not poison restart. - Successful renewal proves that the platform service adopted the new generation. Cleanup uncertainty is a warning and never a false deletion claim. @@ -70,3 +85,8 @@ new credential/TLS files, and lifecycle cleanup must remain recoverable. does not add a second registration mechanism to the lifecycle CLI. - Physical overlay verification remains Task 14 and cannot be inferred from loopback mTLS evidence. +- `flock` is an advisory same-user coordination boundary. Generation + directories remain `0700`, and every child access is `openat`/`unlinkat` + relative with `O_NOFOLLOW`, regular-file, owner, and mode checks. A malicious + process already running as the same effective UID is outside the v0.2 threat + boundary and can ignore advisory locks or replace leaf entries. diff --git a/src/bootstrap/config.rs b/src/bootstrap/config.rs index 738491c..e3e083e 100644 --- a/src/bootstrap/config.rs +++ b/src/bootstrap/config.rs @@ -15,7 +15,7 @@ use crate::{ use zeroize::Zeroizing; use super::{ - BootstrapError, NodePaths, load_active_identity_pointer, load_identity_generation, + BootstrapError, NodePaths, load_active_identity_generation, network::{NetworkBoundary, OverlayKind}, }; @@ -66,10 +66,7 @@ pub fn load_startup_bundle( ) -> Result { let config = paths.read_config()?; let active = match std::fs::symlink_metadata(&paths.active_identity_file) { - Ok(_) => Some(load_identity_generation( - paths, - &load_active_identity_pointer(paths)?, - )?), + Ok(_) => Some(load_active_identity_generation(paths)?), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(_) => return Err(BootstrapError::UnsafeStatePath), }; diff --git a/src/bootstrap/identity_generation.rs b/src/bootstrap/identity_generation.rs index c5a09b8..b60838b 100644 --- a/src/bootstrap/identity_generation.rs +++ b/src/bootstrap/identity_generation.rs @@ -1,6 +1,6 @@ use std::{ collections::{BTreeMap, BTreeSet}, - ffi::{CStr, CString}, + ffi::{CStr, CString, OsStr}, fs::{File, OpenOptions}, io::Read, os::unix::{ @@ -18,8 +18,8 @@ use uuid::Uuid; use crate::{ protocol::{CredentialChain, NodeId}, runtime::key_store::{ - atomic_write_owner_only_strict, ensure_owner_only_dir, read_owner_only, - write_signing_key_strict, + OwnerOnlyLockError, atomic_write_owner_only_strict, ensure_owner_only_dir, + open_owner_only_lock_at, read_owner_only, write_signing_key_strict, }, transport::PeerTlsIdentity, }; @@ -84,6 +84,25 @@ pub struct LoadedIdentityGeneration { pub tls_identity: PeerTlsIdentity, } +pub(crate) struct IdentityOperationLock { + _file: File, +} + +impl IdentityOperationLock { + pub(crate) fn acquire(paths: &NodePaths) -> Result { + let parent = open_owner_directory(&paths.state_dir)?; + if paths.identity_operation_lock_file.parent() != Some(paths.state_dir.as_path()) { + return Err(BootstrapError::InvalidStatePath); + } + let name = paths + .identity_operation_lock_file + .file_name() + .ok_or(BootstrapError::InvalidStatePath)?; + let file = open_owner_only_lock_at(&parent, OsStr::new(name)).map_err(map_lock_error)?; + Ok(Self { _file: file }) + } +} + pub fn write_identity_generation( paths: &NodePaths, generation_id: Uuid, @@ -139,7 +158,8 @@ pub fn publish_active_identity( pointer: &ActiveIdentityPointerV1, ) -> Result<(), BootstrapError> { validate_pointer(pointer)?; - let generation = load_identity_generation(paths, pointer)?; + let _lock = ActiveIdentityLock::exclusive(paths)?; + let generation = load_identity_generation_unlocked(paths, pointer, || {})?; if generation.generation_id.to_string() != pointer.generation_id { return Err(BootstrapError::InvalidConfig); } @@ -150,6 +170,13 @@ pub fn publish_active_identity( pub fn load_active_identity_pointer( paths: &NodePaths, +) -> Result { + let _lock = ActiveIdentityLock::shared(paths)?; + load_active_identity_pointer_unlocked(paths) +} + +fn load_active_identity_pointer_unlocked( + paths: &NodePaths, ) -> Result { let bytes = read_owner_only(&paths.active_identity_file, 4096) .map_err(|_| BootstrapError::UnsafeStatePath)?; @@ -162,8 +189,26 @@ pub fn load_active_identity_pointer( pub fn load_identity_generation( paths: &NodePaths, pointer: &ActiveIdentityPointerV1, +) -> Result { + let _lock = ActiveIdentityLock::shared(paths)?; + load_identity_generation_unlocked(paths, pointer, || {}) +} + +pub(crate) fn load_active_identity_generation( + paths: &NodePaths, +) -> Result { + let _lock = ActiveIdentityLock::shared(paths)?; + let pointer = load_active_identity_pointer_unlocked(paths)?; + load_identity_generation_unlocked(paths, &pointer, || {}) +} + +fn load_identity_generation_unlocked( + paths: &NodePaths, + pointer: &ActiveIdentityPointerV1, + after_lock: impl FnOnce(), ) -> Result { validate_pointer(pointer)?; + after_lock(); let pinned = PinnedIdentityGeneration::open(paths, &pointer.generation_id)?; decode_pinned_generation(&pinned, pointer) } @@ -234,7 +279,8 @@ fn remove_inactive_identity_generation_inner( before_cleanup: impl FnOnce() -> Result<(), BootstrapError>, sync_fault: &mut dyn FnMut(&str) -> Result<(), BootstrapError>, ) -> Result<(), BootstrapError> { - let active = load_active_identity_pointer(paths)?; + let _lock = ActiveIdentityLock::exclusive(paths)?; + let active = load_active_identity_pointer_unlocked(paths)?; if active.generation_id == retired.generation_id { return Err(BootstrapError::InvalidConfig); } @@ -261,6 +307,7 @@ pub fn purge_identity_generations(paths: &NodePaths) -> Result<(), BootstrapErro Err(_) => return Err(BootstrapError::UnsafeStatePath), Ok(_) => ensure_existing_owner_directory(&paths.identity_generations_dir)?, } + let _lock = ActiveIdentityLock::exclusive(paths)?; let parent = open_owner_directory(&paths.identity_generations_dir)?; let names = directory_names(&parent)?; for name in names { @@ -274,6 +321,8 @@ pub fn purge_identity_generations(paths: &NodePaths) -> Result<(), BootstrapErro pinned.require_exact_entries()?; pinned.remove_exact(&mut |_| Ok(()))?; } + crate::runtime::key_store::remove_owner_only_user_service_file(&paths.active_identity_file) + .map_err(|_| BootstrapError::PersistenceUnavailable)?; // Keep the secure empty container. Removing it by pathname after the scan // would reintroduce a parent replacement race and the normal layout already // treats this directory as managed state. @@ -282,6 +331,76 @@ pub fn purge_identity_generations(paths: &NodePaths) -> Result<(), BootstrapErro .map_err(|_| BootstrapError::PersistenceUnavailable) } +struct ActiveIdentityLock { + _directory: File, +} + +impl ActiveIdentityLock { + fn shared(paths: &NodePaths) -> Result { + Self::acquire(paths, libc::LOCK_SH) + } + + fn exclusive(paths: &NodePaths) -> Result { + Self::acquire(paths, libc::LOCK_EX) + } + + fn acquire(paths: &NodePaths, operation: libc::c_int) -> Result { + let directory = open_owner_directory(&paths.identity_generations_dir)?; + loop { + if unsafe { libc::flock(directory.as_raw_fd(), operation) } == 0 { + return Ok(Self { + _directory: directory, + }); + } + if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted { + return Err(BootstrapError::UnsafeStatePath); + } + } + } +} + +fn map_lock_error(error: OwnerOnlyLockError) -> BootstrapError { + match error { + OwnerOnlyLockError::Locked => BootstrapError::StateLocked, + OwnerOnlyLockError::Unsafe => BootstrapError::UnsafeStatePath, + } +} + +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub struct IdentityOperationTestGuard(#[allow(dead_code)] IdentityOperationLock); + +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub struct ActiveIdentityTestGuard(#[allow(dead_code)] ActiveIdentityLock); + +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub fn acquire_identity_operation_for_test( + paths: &NodePaths, +) -> Result { + IdentityOperationLock::acquire(paths).map(IdentityOperationTestGuard) +} + +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub fn acquire_active_identity_shared_for_test( + paths: &NodePaths, +) -> Result { + ActiveIdentityLock::shared(paths).map(ActiveIdentityTestGuard) +} + +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub fn load_identity_generation_with_hook( + paths: &NodePaths, + pointer: &ActiveIdentityPointerV1, + after_lock: impl FnOnce(), +) -> Result { + let _lock = ActiveIdentityLock::shared(paths)?; + load_identity_generation_unlocked(paths, pointer, after_lock) +} + struct PinnedIdentityGeneration { parent: File, directory: File, diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 016a3dd..fbcc9f7 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -20,14 +20,19 @@ pub use enrollment::{ pub(crate) use enrollment::{ EnrollmentWireRequest, EnrollmentWireResponse, serialize_enrollment_request, }; -#[cfg(feature = "cli-test-fixture")] -#[doc(hidden)] -pub use identity_generation::remove_inactive_identity_generation_with_faults; pub use identity_generation::{ ActiveIdentityPointerV1, IdentityGenerationManifestV1, LoadedIdentityGeneration, load_active_identity_pointer, load_identity_generation, publish_active_identity, purge_identity_generations, remove_inactive_identity_generation, write_identity_generation, }; +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub use identity_generation::{ + ActiveIdentityTestGuard, IdentityOperationTestGuard, acquire_active_identity_shared_for_test, + acquire_identity_operation_for_test, load_identity_generation_with_hook, + remove_inactive_identity_generation_with_faults, +}; +pub(crate) use identity_generation::{IdentityOperationLock, load_active_identity_generation}; pub use invitation::{ ConsumptionResult, InvitationAuthentication, InvitationHandoff, InvitationPublicClaims, InvitationRecord, InvitationSpec, InvitationState, InvitationStore, ReservationStatus, diff --git a/src/bootstrap/paths.rs b/src/bootstrap/paths.rs index 75121a3..d752e1d 100644 --- a/src/bootstrap/paths.rs +++ b/src/bootstrap/paths.rs @@ -66,6 +66,7 @@ pub struct NodePaths { pub retired_tls_private_key_file: PathBuf, pub identity_generations_dir: PathBuf, pub active_identity_file: PathBuf, + pub identity_operation_lock_file: PathBuf, pub departure_receipt_file: PathBuf, pub pending_departure_file: PathBuf, } @@ -151,6 +152,7 @@ impl NodePaths { retired_tls_private_key_file: state_dir.join("retired-peer-private-key-v1.pem"), identity_generations_dir: state_dir.join("identity-generations"), active_identity_file: state_dir.join("active-identity-v1.json"), + identity_operation_lock_file: state_dir.join("identity-operation-v1.lock"), departure_receipt_file: state_dir.join("departure-receipt-v1.json"), pending_departure_file: state_dir.join("pending-departure-v1.json"), config_dir, diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 8380067..056144a 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -6,7 +6,7 @@ use ed25519_dalek::VerifyingKey; use crate::{ bootstrap::{ - NodePaths, ServiceMetadataV3, load_active_identity_pointer, load_startup_bundle, + NodePaths, ServiceMetadataV3, load_active_identity_generation, load_startup_bundle, reconcile_managed_binary_metadata, }, cli_diagnostics::{CheckStatus, DoctorCheck, DoctorReport}, @@ -452,8 +452,7 @@ fn read_root(paths: &NodePaths) -> Result { } fn read_credential(paths: &NodePaths) -> Result { if paths.active_identity_file.exists() { - let pointer = load_active_identity_pointer(paths).map_err(|_| invalid())?; - return crate::bootstrap::load_identity_generation(paths, &pointer) + return load_active_identity_generation(paths) .map(|generation| generation.credential) .map_err(|_| invalid()); } diff --git a/src/cli/lifecycle.rs b/src/cli/lifecycle.rs index 4b4df2f..28559f8 100644 --- a/src/cli/lifecycle.rs +++ b/src/cli/lifecycle.rs @@ -9,9 +9,9 @@ use uuid::Uuid; use crate::{ bootstrap::{ ActiveIdentityPointerV1, AgeRootKeystore, BootstrapPhase, BootstrapStateStore, - BootstrapTransition, NodePaths, NodeTlsCsr, RootKeystore, ServiceMetadataV3, - load_active_identity_pointer, load_startup_bundle, publish_active_identity, - write_identity_generation, + BootstrapTransition, IdentityOperationLock, NodePaths, NodeTlsCsr, RootKeystore, + ServiceMetadataV3, load_active_identity_generation, load_active_identity_pointer, + load_startup_bundle, publish_active_identity, write_identity_generation, }, protocol::{ CredentialChain, DomainId, NodeDepartureReceipt, NodeDepartureRequest, NodeId, NodeRole, @@ -156,6 +156,9 @@ pub async fn credential(args: CredentialArgs) -> Result<(), CliError> { async fn renew(args: RenewArgs) -> Result<(), CliError> { let paths = NodePaths::for_current_user().map_err(map_bootstrap)?; + // Renewal owns the operation lock across Authority I/O and service adoption + // so another workflow cannot select or retire an intermediate generation. + let _operation = IdentityOperationLock::acquire(&paths).map_err(map_identity_operation)?; if let Some(result) = reconcile_completed_rotation(&paths).await? { return output::emit( args.output, @@ -464,6 +467,8 @@ pub fn uninstall(args: UninstallArgs, terminal: &impl SecretTerminal) -> Result< false, )); } + // Purge must not overlap a renewal even after operator confirmation. + let _operation = IdentityOperationLock::acquire(&paths).map_err(map_identity_operation)?; purged_items = purge_node_state(&paths)?; warnings.push("Domain Root and founding administrative material were retained."); } @@ -1397,8 +1402,7 @@ fn read_root(paths: &NodePaths) -> Result { } fn read_credential(paths: &NodePaths) -> Result { if paths.active_identity_file.exists() { - let pointer = load_active_identity_pointer(paths).map_err(map_bootstrap)?; - return crate::bootstrap::load_identity_generation(paths, &pointer) + return load_active_identity_generation(paths) .map(|generation| generation.credential) .map_err(map_bootstrap); } @@ -1464,6 +1468,16 @@ fn map_renew_transport(error: crate::transport::TransportError, operation_id: Uu fn map_bootstrap(_: crate::bootstrap::BootstrapError) -> CliError { invalid_state() } +fn map_identity_operation(error: crate::bootstrap::BootstrapError) -> CliError { + if error == crate::bootstrap::BootstrapError::StateLocked { + return CliError::new( + "IdentityOperationLocked", + "Another identity lifecycle operation is already running.", + true, + ); + } + invalid_state() +} fn invalid_state() -> CliError { CliError::new( "BootstrapStateInvalid", diff --git a/src/runtime/key_store.rs b/src/runtime/key_store.rs index 4fcebd2..ed8ee0d 100644 --- a/src/runtime/key_store.rs +++ b/src/runtime/key_store.rs @@ -663,6 +663,15 @@ pub(crate) fn open_owner_only_append_at( pub(crate) fn open_owner_only_lock_at( parent: &File, name: &std::ffi::OsStr, +) -> Result { + open_owner_only_flock_at(parent, name, libc::LOCK_EX, true) +} + +fn open_owner_only_flock_at( + parent: &File, + name: &std::ffi::OsStr, + operation: libc::c_int, + nonblocking: bool, ) -> Result { let name = c_name(name).map_err(|_| OwnerOnlyLockError::Unsafe)?; for _ in 0..2 { @@ -677,9 +686,19 @@ pub(crate) fn open_owner_only_lock_at( } Err(_) => return Err(OwnerOnlyLockError::Unsafe), }; - let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; - if result != 0 { - return Err(OwnerOnlyLockError::Locked); + let flags = operation | if nonblocking { libc::LOCK_NB } else { 0 }; + loop { + if unsafe { libc::flock(file.as_raw_fd(), flags) } == 0 { + break; + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + if nonblocking && error.kind() == std::io::ErrorKind::WouldBlock { + return Err(OwnerOnlyLockError::Locked); + } + return Err(OwnerOnlyLockError::Unsafe); } if created { file.sync_data().map_err(|_| OwnerOnlyLockError::Unsafe)?; diff --git a/tests/bootstrap_identity_generation.rs b/tests/bootstrap_identity_generation.rs index 1cbfc42..ea3be10 100644 --- a/tests/bootstrap_identity_generation.rs +++ b/tests/bootstrap_identity_generation.rs @@ -7,8 +7,7 @@ use agenet::{ bootstrap::{ ActiveIdentityPointerV1, AuthorityPki, IdentityGenerationManifestV1, NodeConfigV1, NodePathEnvironment, NodePaths, NodeTlsCsr, UserPlatform, load_identity_generation, - publish_active_identity, remove_inactive_identity_generation_with_faults, - write_identity_generation, + publish_active_identity, purge_identity_generations, write_identity_generation, }, protocol::{BootstrapProfile, DirectorySeed, NodeId, NodeRole}, runtime::write_signing_key, @@ -116,6 +115,7 @@ impl Fixture { .unwrap(); } + #[cfg(feature = "cli-test-fixture")] fn make_active_replacement(&self) { let loaded = load_identity_generation(&self.paths, &self.pointer).unwrap(); let replacement = write_identity_generation( @@ -218,66 +218,324 @@ fn symlinked_generation_material_is_rejected() { } #[test] -fn cleanup_is_anchored_to_pinned_directory_and_preserves_substitute() { +fn purge_removes_pointer_and_generations_as_one_locked_identity_change() { let fixture = Fixture::new(); - fixture.make_active_replacement(); - let original = fixture.generation(); - let moved = fixture - .paths - .identity_generations_dir - .join(format!("{}.retired", fixture.pointer.generation_id)); - let substitute = original.clone(); - let result = remove_inactive_identity_generation_with_faults( - &fixture.paths, - &fixture.pointer, - || { - std::fs::rename(&original, &moved).unwrap(); - std::fs::create_dir(&substitute).unwrap(); - std::fs::set_permissions(&substitute, std::fs::Permissions::from_mode(0o700)).unwrap(); - let marker = substitute.join("attacker-marker"); - std::fs::write(&marker, b"preserve").unwrap(); - std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).unwrap(); - Ok(()) - }, - |_| Ok(()), - ); - assert!(result.is_err()); + purge_identity_generations(&fixture.paths).unwrap(); + assert!(!fixture.paths.active_identity_file.exists()); assert_eq!( - std::fs::read(substitute.join("attacker-marker")).unwrap(), - b"preserve" + std::fs::read_dir(&fixture.paths.identity_generations_dir) + .unwrap() + .count(), + 0 ); } -#[test] -fn post_unlink_sync_fault_returns_uncertain_without_continuing_cleanup() { - let fixture = Fixture::new(); - fixture.make_active_replacement(); - let mut injected = false; - let result = remove_inactive_identity_generation_with_faults( - &fixture.paths, - &fixture.pointer, - || Ok(()), - |_| { - if !injected { - injected = true; - return Err(agenet::bootstrap::BootstrapError::PersistenceUnavailable); +#[cfg(feature = "cli-test-fixture")] +mod fault_tests { + use super::*; + use agenet::bootstrap::{ + acquire_active_identity_shared_for_test, acquire_identity_operation_for_test, + load_active_identity_pointer, load_identity_generation_with_hook, + remove_inactive_identity_generation_with_faults, + }; + use std::{ + process::Command, + sync::{Arc, Barrier, mpsc}, + time::Duration, + }; + + #[test] + fn cleanup_is_anchored_to_pinned_directory_and_preserves_substitute() { + let fixture = Fixture::new(); + fixture.make_active_replacement(); + let original = fixture.generation(); + let moved = fixture + .paths + .identity_generations_dir + .join(format!("{}.retired", fixture.pointer.generation_id)); + let substitute = original.clone(); + let result = remove_inactive_identity_generation_with_faults( + &fixture.paths, + &fixture.pointer, + || { + std::fs::rename(&original, &moved).unwrap(); + std::fs::create_dir(&substitute).unwrap(); + std::fs::set_permissions(&substitute, std::fs::Permissions::from_mode(0o700)) + .unwrap(); + let marker = substitute.join("attacker-marker"); + std::fs::write(&marker, b"preserve").unwrap(); + std::fs::set_permissions(&marker, std::fs::Permissions::from_mode(0o600)).unwrap(); + Ok(()) + }, + |_| Ok(()), + ); + assert!(result.is_err()); + assert_eq!( + std::fs::read(substitute.join("attacker-marker")).unwrap(), + b"preserve" + ); + } + + #[test] + fn post_unlink_sync_fault_returns_uncertain_without_continuing_cleanup() { + let fixture = Fixture::new(); + fixture.make_active_replacement(); + let mut injected = false; + let result = remove_inactive_identity_generation_with_faults( + &fixture.paths, + &fixture.pointer, + || Ok(()), + |_| { + if !injected { + injected = true; + return Err(agenet::bootstrap::BootstrapError::PersistenceUnavailable); + } + Ok(()) + }, + ); + assert!(result.is_err()); + assert!( + !fixture + .generation() + .join("identity-manifest-v1.json") + .exists() + ); + assert!( + fixture + .generation() + .join("node-credential-v1.json") + .exists() + ); + } + + #[test] + fn cleanup_exclusive_lock_blocks_retired_publish_and_never_deletes_active() { + let fixture = Fixture::new(); + fixture.make_active_replacement(); + let active = load_active_identity_pointer(&fixture.paths).unwrap(); + let paths = fixture.paths.clone(); + let retired = fixture.pointer.clone(); + let entered = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let cleanup_entered = Arc::clone(&entered); + let cleanup_release = Arc::clone(&release); + let cleanup_paths = paths.clone(); + let cleanup_retired = retired.clone(); + let cleanup = std::thread::spawn(move || { + remove_inactive_identity_generation_with_faults( + &cleanup_paths, + &cleanup_retired, + || { + cleanup_entered.wait(); + cleanup_release.wait(); + Ok(()) + }, + |_| Ok(()), + ) + }); + entered.wait(); + let (published, observed) = mpsc::channel(); + let publish_paths = paths.clone(); + let publisher = std::thread::spawn(move || { + let result = publish_active_identity(&publish_paths, &retired); + published.send(result).unwrap(); + }); + assert!(observed.recv_timeout(Duration::from_millis(100)).is_err()); + release.wait(); + cleanup.join().unwrap().unwrap(); + assert!( + observed + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .is_err() + ); + publisher.join().unwrap(); + assert_eq!(load_active_identity_pointer(&paths).unwrap(), active); + assert!( + paths + .identity_generations_dir + .join(active.generation_id) + .exists() + ); + } + + #[test] + fn reader_shared_lock_blocks_cleanup_until_complete_bundle_is_loaded() { + let fixture = Fixture::new(); + fixture.make_active_replacement(); + let paths = fixture.paths.clone(); + let retired = fixture.pointer.clone(); + let entered = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let reader_entered = Arc::clone(&entered); + let reader_release = Arc::clone(&release); + let read_paths = paths.clone(); + let read_retired = retired.clone(); + let reader = std::thread::spawn(move || { + load_identity_generation_with_hook(&read_paths, &read_retired, || { + reader_entered.wait(); + reader_release.wait(); + }) + }); + entered.wait(); + let (cleaned, observed) = mpsc::channel(); + let cleanup_paths = paths.clone(); + let cleanup_retired = retired.clone(); + let cleanup = std::thread::spawn(move || { + let result = remove_inactive_identity_generation_with_faults( + &cleanup_paths, + &cleanup_retired, + || Ok(()), + |_| Ok(()), + ); + cleaned.send(result).unwrap(); + }); + assert!(observed.recv_timeout(Duration::from_millis(100)).is_err()); + release.wait(); + assert_eq!( + reader.join().unwrap().unwrap().generation_id.to_string(), + retired.generation_id + ); + observed + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .unwrap(); + cleanup.join().unwrap(); + } + + #[test] + fn identity_operation_lock_allows_only_one_workflow_and_reopens_after_drop() { + let fixture = Fixture::new(); + let guard = acquire_identity_operation_for_test(&fixture.paths).unwrap(); + let paths = fixture.paths.clone(); + let contender = std::thread::spawn(move || acquire_identity_operation_for_test(&paths)); + assert!(matches!( + contender.join().unwrap(), + Err(agenet::bootstrap::BootstrapError::StateLocked) + )); + let child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "fault_tests::identity_operation_lock_child", + "--ignored", + ]) + .env( + "AGENET_IDENTITY_LOCK_CHILD_HOME", + fixture._temp.path().canonicalize().unwrap(), + ) + .output() + .unwrap(); + assert!( + child.status.success(), + "{}", + String::from_utf8_lossy(&child.stderr) + ); + drop(guard); + let crashed = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "fault_tests::identity_operation_lock_crash_child", + "--ignored", + ]) + .env( + "AGENET_IDENTITY_LOCK_CHILD_HOME", + fixture._temp.path().canonicalize().unwrap(), + ) + .status() + .unwrap(); + assert_eq!(crashed.code(), Some(23)); + assert!(acquire_identity_operation_for_test(&fixture.paths).is_ok()); + assert!(load_identity_generation(&fixture.paths, &fixture.pointer).is_ok()); + } + + #[test] + #[ignore = "spawned explicitly by the cross-process operation-lock test"] + fn identity_operation_lock_child() { + let home = + std::path::PathBuf::from(std::env::var_os("AGENET_IDENTITY_LOCK_CHILD_HOME").unwrap()); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(home, None, None), + ) + .unwrap(); + assert!(matches!( + acquire_identity_operation_for_test(&paths), + Err(agenet::bootstrap::BootstrapError::StateLocked) + )); + } + + #[test] + #[ignore = "spawned explicitly by the cross-process operation-lock test"] + fn identity_operation_lock_crash_child() { + let home = + std::path::PathBuf::from(std::env::var_os("AGENET_IDENTITY_LOCK_CHILD_HOME").unwrap()); + let paths = NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(home, None, None), + ) + .unwrap(); + let _guard = acquire_identity_operation_for_test(&paths).unwrap(); + std::process::exit(23); + } + + #[test] + fn identity_locks_reject_symlink_fifo_and_unsafe_mode() { + for case in ["symlink", "fifo", "mode"] { + let fixture = Fixture::new(); + let lock = fixture.paths.identity_operation_lock_file.clone(); + match case { + "symlink" => { + let target = fixture.paths.state_dir.join("outside-lock"); + std::fs::write(&target, b"retain").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)) + .unwrap(); + std::os::unix::fs::symlink(target, lock).unwrap(); + } + "fifo" => { + let name = std::ffi::CString::new(lock.as_os_str().as_encoded_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + } + "mode" => { + std::fs::write(&lock, b"").unwrap(); + std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o644)) + .unwrap(); + } + _ => unreachable!(), } - Ok(()) - }, - ); - assert!(result.is_err()); - assert!( - !fixture - .generation() - .join("identity-manifest-v1.json") - .exists() - ); - assert!( - fixture - .generation() - .join("node-credential-v1.json") - .exists() - ); + assert!(matches!( + acquire_identity_operation_for_test(&fixture.paths), + Err(agenet::bootstrap::BootstrapError::UnsafeStatePath) + )); + } + + for case in ["symlink", "fifo", "mode"] { + let fixture = Fixture::new(); + let directory = fixture.paths.identity_generations_dir.clone(); + match case { + "symlink" => { + let moved = fixture.paths.state_dir.join("identity-generations-moved"); + std::fs::rename(&directory, &moved).unwrap(); + std::os::unix::fs::symlink(moved, &directory).unwrap(); + } + "fifo" => { + let moved = fixture.paths.state_dir.join("identity-generations-moved"); + std::fs::rename(&directory, moved).unwrap(); + let name = + std::ffi::CString::new(directory.as_os_str().as_encoded_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + } + "mode" => { + std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o755)) + .unwrap(); + } + _ => unreachable!(), + } + assert!(matches!( + acquire_active_identity_shared_for_test(&fixture.paths), + Err(agenet::bootstrap::BootstrapError::UnsafeStatePath) + )); + } + } } #[test] From 41a60c67bc5127c70fe327439b995874401c92e4 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 09:55:03 +0800 Subject: [PATCH 41/67] [feat][Bootstrap] Prepare physical acceptance Root cause: NA Solution: Add strict evidence tooling, production pursuit, and live invitation coordination for the physical gate. Risks: Physical evidence, Linux parity, and private-overlay validation remain pending. Dependency: Task 13 at ff8e09734dc00d1c4cde0b37e723e9138857ccaf Links: docs/testing/two-device-acceptance.md --- README.md | 33 +- ROADMAP.md | 27 + docs/testing/two-device-acceptance.md | 197 +++++ plan/01-v4-multi-host-node-bootstrap.md | 69 ++ scripts/verify-two-device-evidence.sh | 20 + src/bootstrap/invitation.rs | 201 ++++- src/bootstrap/journal.rs | 42 +- src/bootstrap/managed_binary.rs | 3 + src/bootstrap/mod.rs | 3 + src/bootstrap/paths.rs | 21 + src/cli/domain.rs | 34 +- src/cli/evidence.rs | 265 ++++++ src/cli/invite.rs | 46 +- src/cli/join.rs | 8 +- src/cli/mod.rs | 312 +++++++ src/cli/pursuit.rs | 273 +++++++ src/evidence.rs | 767 ++++++++++++++++++ src/lib.rs | 1 + src/protocol/authority.rs | 6 +- src/runtime/host.rs | 439 +++++++++- src/runtime/local_control.rs | 156 ++++ src/runtime/mod.rs | 11 +- src/runtime/requester.rs | 223 ++++- src/transport/mod.rs | 3 +- src/transport/node.rs | 155 +++- .../fixtures/two-device-evidence.schema.json | 279 +++++++ ...wo-device-evidence.synthetic-template.json | 145 ++++ tests/invitation_store.rs | 167 ++++ tests/local_control.rs | 77 ++ tests/two_device_evidence.rs | 214 +++++ 30 files changed, 4134 insertions(+), 63 deletions(-) create mode 100644 docs/testing/two-device-acceptance.md create mode 100644 plan/01-v4-multi-host-node-bootstrap.md create mode 100755 scripts/verify-two-device-evidence.sh create mode 100644 src/cli/evidence.rs create mode 100644 src/cli/pursuit.rs create mode 100644 src/evidence.rs create mode 100644 src/runtime/local_control.rs create mode 100644 tests/fixtures/two-device-evidence.schema.json create mode 100644 tests/fixtures/two-device-evidence.synthetic-template.json create mode 100644 tests/local_control.rs create mode 100644 tests/two_device_evidence.rs diff --git a/README.md b/README.md index e19c56b..90653dc 100644 --- a/README.md +++ b/README.md @@ -131,10 +131,16 @@ roles, binds the exact configured address, performs an mTLS health probe, and registers signed provider manifests before publishing an owner-only readiness artifact. Registration/startup failure withdraws readiness and reaps the listener. `runtime_ready` means this base peer runtime is listening under -current policy; it does not imply a Requester pursuit interface is enabled. -Without explicit secure local-control/model configuration, a signed Requester -role remains health-only and reports Requester disabled. Bootstrap profile is -never used as an authorization role or capability grant. +current policy. Production `domain init` issues the founding node exactly the +signed `Directory` and `Requester` roles with an empty provider capability +ceiling and creates a separate owner-only local-control token. When both are +present, the runtime merges the Requester's signed Artifact route into the +private-overlay mTLS peer listener and starts a separate dynamic loopback-only +pursuit listener. Its owner-only ready record contains a local endpoint and +process generation, never the token. A token without the signed role fails +closed; a signed role without the token leaves the Directory available but +disables pursuits. Provider enrollment creates no local-control token. +Bootstrap profile is never used as an authorization role or capability grant. A verified founding Directory additionally loads a typed founding-only Authority runtime from the existing owner-only Authority credential/signing @@ -180,6 +186,25 @@ cargo run -- demo \ The environment file is read in place. It is never copied, logged, or committed. +For a production founding node on a private overlay, the operator submits the +same source-metrics flow through the local Requester boundary: + +```bash +agenet pursuit run \ + --env-file /owner/controlled/model.env \ + --artifact fixtures/sample.rs \ + --output json +``` + +This command accepts no Directory, Executor, or Verifier endpoint. It reads +only `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `VLM_MODEL`, makes one strict +OpenAI-compatible decision with at most one format repair and no deterministic +fallback, then sends the decision and bounded Artifact bytes over the +authenticated loopback listener. Provider endpoints are learned only from +signed Directory Manifests. The physical acceptance runbook and redacted +evidence boundary are in +[`docs/testing/two-device-acceptance.md`](docs/testing/two-device-acceptance.md). + Required Walkman alias names are `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `VLM_MODEL`. For this command, `OPENAI_BASE_URL` is the complete ModelHub `gemini_multimodal_inline_v1` endpoint: the adapter does not append a route, places the credential only in the `ak` query parameter, and sends inline text content. Only the Requester child receives these three variables. The other three children are started with a cleared environment. The demo never falls back to `DeterministicDecisionAdapter`; an invalid model response fails explicitly after one format-repair request. The successful command prints one JSON summary containing: diff --git a/ROADMAP.md b/ROADMAP.md index 2d66942..ffad3da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,32 @@ # ROADMAP +## 2026-08-15 — Task 14 preflight: refresh live invitation state + +- **Change**: Added one owner-only cross-process invitation operation lock and bounded journal refresh/replay before invitation mutations, so a long-lived Authority sees invitations created later by the real CLI without restart. +- **Files**: invitation journal/store, cross-instance/race/crash/replacement tests, Task 5 report amendment, Task 14 runbook, and real-binary preflight. +- **Root cause / classification**: **计划集成缺口 / 技术盲区**. Task 5 proved thread races through one in-memory store, while production uses a CLI process and a long-lived Authority process. Each held a stale projection and independent append descriptor; a post-start CLI invitation was durable but invisible to Authority reservation. +- **Solution**: Serialize Mutex→`flock` in one order, anchor the lock to an owner-only directory/file, verify the journal pathname still names the opened inode, full-replay bounded checksummed events under the lock, and retain persistence poison semantics. No invitation/credential/journal format changed. +- **Prevention**: Every durable component consumed by both CLI and service must include a real multi-process create-after-service-start acceptance test; thread-only races cannot close a process coordination requirement. +- **Boundary**: The lock coordinates AgenNet processes for one user. It does not defend against a malicious same-euid process that ignores advisory locks, and it is not distributed consensus. + +## 2026-08-15 — Task 14 preflight: authorize the verifier Manifest + +- **Change**: Expanded the production Provider invitation ceiling from only `source.metrics.v1` to the exact pair `source.metrics.v1` and `source.metrics.verify.v1`; split physical evidence into signed credential roles, active runtime roles, and local Requester enablement. +- **Files**: `src/cli/invite.rs`, Task 14 evidence schema/verifier/collector/tests, and the physical acceptance runbook. +- **Root cause / classification**: **计划集成缺口 / 技术盲区**. Provider credentials sign Requester, Executor, and Verifier roles, while runtime intentionally activates only Executor and Verifier without a local-control token. The invitation ceiling authorized only the Executor kind, so the real HostRuntime would reject its own signed Verifier Manifest during startup. The first evidence draft also collapsed signed authorization and active runtime exposure into one ambiguous role list. +- **Solution**: Keep credential and invitation formats unchanged, authorize exactly the two published read-only Provider kinds, and require evidence to show A signed/active Directory+Requester with local Requester enabled, versus B signed Requester+Executor+Verifier but active Executor+Verifier with local Requester disabled. +- **Prevention**: Every multi-role bootstrap profile must test that each production Manifest kind is inside the issued ceiling, while evidence must distinguish signed authorization from routes actually activated by local secret/config prerequisites. +- **Boundary**: The additional kind remains the fixed independent source-metrics verifier; this does not authorize arbitrary capabilities or enable B's Requester surface. + +## 2026-08-15 — Task 14 preflight: expose the production pursuit path + +- **Change**: Added a focused v4 plan amendment before implementing physical-evidence tooling and the production two-device run. +- **Files**: `plan/01-v4-multi-host-node-bootstrap.md`, Task 14 evidence schema/verifier, and the forthcoming founding Requester/local-control path. +- **Root cause / classification**: **计划集成缺口 / 误解需求**. Task 12 proved the complete pursuit only through the loopback demo. Production `domain init` issued a Directory-only credential, `HostRuntime` returned after constructing Directory routes, and the public CLI had no pursuit command or secure model/control configuration. Consequently the approved physical topology A=Directory+Requester and B=Executor+Verifier was not executable without the demo harness. +- **Solution**: Keep the demo least-privilege and add an explicit production-only founding Directory+Requester credential, owner-only local-control token, loopback control listener, and source-metrics pursuit CLI whose only remote knowledge is the signed Directory seed. Finish strict redacted evidence tooling independently, but do not record a milestone until the real physical path passes. +- **Prevention**: Every future acceptance plan must map each operator step to a public production command and its durable/runtime artifact before implementation begins. Simulated and demo-only entrypoints are never presumed to imply an operator-facing production path. +- **Boundary**: This revision does not relax private-overlay, mTLS, signed Manifest, LLM, revocation, or physical-device requirements. Local tests remain nonphysical evidence. + ## 2026-08-15 — Task 13 review: serialize identity generation changes - **Change**: Restored default-feature generation security tests and added two-level advisory locking for lifecycle operations and active identity generations. diff --git a/docs/testing/two-device-acceptance.md b/docs/testing/two-device-acceptance.md new file mode 100644 index 0000000..7b1e60b --- /dev/null +++ b/docs/testing/two-device-acceptance.md @@ -0,0 +1,197 @@ +# Two-device physical acceptance (Task 14) + +This is an operator runbook, not a completed result. A passing loopback demo or +synthetic fixture cannot satisfy this gate. Use two user-controlled physical +devices on one private Tailscale or WireGuard overlay. Never bind a public or +wildcard address. + +## Secret and terminal boundary + +- Run Domain Root passphrase and invitation operations in each device's own + controlling TTY. Do not paste a passphrase or invitation into chat, shell + arguments, environment variables, logs, or evidence. +- `agenet invite create` displays the one-time handoff only to Device A's TTY. + `agenet node join` reads it only from Device B's TTY. +- The model environment file remains owner-controlled on Device A. Do not copy + it into this repository or to Device B. +- Evidence contains only the redacted public fields accepted by + `two-device-evidence.schema.json`. Raw logs remain local and bounded. + +## 1. Preflight on both devices + +Record the same exact 40-character commit on A and B, then build and test it: + +```sh +git rev-parse HEAD +rustc --version +cargo build --locked --release +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets +``` + +Confirm the private overlay is connected and obtain each device's assigned +private overlay address using the overlay's own local CLI. Confirm the chosen +address belongs to the narrow private CIDR that will be authorized. Stop if +either address is public, unassigned, shared by the devices, or outside that +CIDR. Keep addresses out of the final evidence. + +Install the verified release binary using the repository's documented install +flow. Run `agenet node doctor --output json` after provisioning; do not treat a +warning or error as a pass. + +## 2. Device A: found Domain and start Directory + Requester + +Use the exact private overlay values selected during preflight: + +```sh +agenet domain init \ + --network tailscale \ + --bind-ip '' \ + --allowed-cidr '' \ + --output json +agenet invite create --profile provider --ttl 10m --output json +agenet node start --output json +agenet node status --output json +agenet node doctor --output json +``` + +For WireGuard, replace `tailscale` with `wireguard`. The founding credential +must have exactly `directory` and `requester`; it has no provider capability. +The Requester local listener binds a dynamic loopback port and is reached only +through its owner-only token and ready file. Neither value is an operator +argument. + +Creating the invitation before starting A is the shortest operator sequence, +but it is not required. A running Authority refreshes the same bounded, +checksummed invitation journal under an owner-only cross-process operation +lock, so a later real CLI invitation is redeemable without restarting A. +Retain the one-time handoff only in the controlling-TTY workflow until B +immediately joins. + +## 3. Enroll Device B as the provider + +Without copying the handoff through chat or a command argument, enter it at +Device B's controlling TTY when prompted: + +```sh +agenet node join --bind-ip '' --output json +agenet node start --output json +agenet node status --output json +agenet node doctor --output json +``` + +Device B must have exactly `executor` and `verifier`, no local-control token, +and one private-overlay peer listener. Wait for its two signed Capability +Manifests to register with A. + +## 4. Run the real pursuit on Device A + +The model file must contain only the required aliases +`OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `VLM_MODEL`. The CLI parses it without +exporting those values into the node service. + +```sh +agenet pursuit run \ + --env-file '' \ + --artifact '' \ + --output json +``` + +Save the single redacted JSON result locally. It must show +`signed_directory_manifests`, distinct A/B Node IDs, two parent-linked +Contracts, source state +`Proposed → Active → Running → Delivered → Accepted`, verification state +`Proposed → Active → Running → Delivered`, identical Artifact hashes and +metrics, and Accepted strictly after verification Delivered. Requester input +or state must not contain B's endpoint. + +## 5. Restart, Authority-loss, and idempotency checks + +On A, stop the service, verify B remains healthy/auditable but no new +authority-dependent effect succeeds, then restart A: + +```sh +agenet node stop --output json +agenet node status --output json +agenet node start --output json +agenet node doctor --output json +``` + +Reconcile the original pursuit operation and journals. The original Contract +and Event IDs must remain stable and no duplicate effect or identity may +appear. A restarted Directory must accept B's refreshed signed manifests. + +If additional development machines are available, separately record real +results for clock skew, concurrent invitation redemption, stale revocation +snapshot, Directory restart during registration, and Capability churn. Do not +mark an unavailable scenario as passed. + +## 6. Exact revocation check + +From A's controlling TTY, revoke the exact public Node ID collected for B: + +```sh +agenet node revoke '' --output json +``` + +After B refreshes the signed revocation snapshot, its health/audit surface may +remain queryable, but a new effect must be rejected. Record epochs before and +after; the after epoch must increase. Never revoke by an endpoint or address. + +## 7. Redacted fragments and aggregation + +On each still-running physical node, explicitly confirm the physical-device +context and collect the public fragment: + +```sh +agenet evidence collect \ + --label device-a \ + --confirm-physical-device \ + --output json +``` + +Use `device-b` on B. The collector verifies the local credential and emits only +its public claims, the public certificate fingerprint, ready-process instance, +network class, and signed revocation epoch. It does not read `.env`, the Root +keystore, signing/TLS private keys, invitation state, prompts, or raw logs; it +never emits the raw credential or certificate. + +Transfer only the two fragments and the redacted pursuit result through a +user-approved secure channel. On A, manually assemble them with the real +restart/loss/revocation observations into a copy of +`tests/fixtures/two-device-evidence.synthetic-template.json`. The fixture is a +schema-valid synthetic template with `result: "fail"`; it is never passing +physical evidence. Replace every synthetic value, set `result: "pass"` only +after every observation is real, and store the aggregate beneath +`.local/evidence/`. + +Verify exactly one regular, non-symlink evidence file: + +```sh +scripts/verify-two-device-evidence.sh \ + .local/evidence/two-device-physical-v1.json +``` + +The verifier rejects unknown/duplicate fields, oversized or nonregular input, +forbidden secrets/addresses/paths, identity or certificate reuse, clock and +metrics mismatch, missing Contract states, endpoint preknowledge, nonphysical +transport, acceptance before verification, ineffective revocation, and a +non-pass result. It performs no install and no network request. + +## 8. Cleanup and evidence handling + +```sh +agenet node stop --output json +``` + +Stop B before A. Keep raw logs only on the originating device with owner-only +permissions and a bounded retention period. Run a local secret scan over +process arguments, environment captures, logs, evidence, and the repository. +Do not commit `.local/evidence`, fragments, logs, credentials, certificates, +private keys, tokens, invitations, or model configuration. + +Task 14 completes only after the strict verifier passes the real aggregate, +both devices ran the same commit, all Rust gates pass, the real model pursuit +passes without deterministic fallback, and the repository secret scan is +clean. diff --git a/plan/01-v4-multi-host-node-bootstrap.md b/plan/01-v4-multi-host-node-bootstrap.md new file mode 100644 index 0000000..4e35ce5 --- /dev/null +++ b/plan/01-v4-multi-host-node-bootstrap.md @@ -0,0 +1,69 @@ +# AgenNet Task 14 preflight revision — production physical pursuit + +## Goal + +Close the production-path gap found before the physical two-device gate. The +founding host must run a signed Directory and Requester without embedding a +provider endpoint, and an operator must be able to submit one real LLM-decided +source-metrics pursuit through a loopback-only local control plane. + +## Preconditions + +- Tasks 1–13 are complete at commit `ff8e097`. +- Invitation, enrollment, Node Credential, configuration, and journal formats + remain v4/v0.4/v0.3/schema 2 unless a persisted consumer actually changes. +- The loopback demo retains four least-privilege credentials and is never + evidence for the physical gate. + +## Steps + +1. Issue the founding credential with exactly `Directory` and `Requester` roles + and an empty provider capability ceiling. Generate an owner-only 32-byte + local-control token; never expose it through output, argv, environment, or + logs. Provider join does not create this token. +2. Merge the founding Directory peer routes with the Requester's signed peer + Artifact routes on the exact private-overlay mTLS listener. Run pursuit + control on a separate dynamic loopback listener and publish only an + owner-only, versioned ready record without the token. +3. Add `agenet pursuit run --env-file --artifact --output json`. + The CLI reads only the three model variables, makes the strict real model + decision with one repair and no fallback, and submits the decision plus + bounded Artifact bytes to the loopback control listener. It cannot accept a + Directory or provider endpoint. +4. Bind local requests to loopback connection metadata and compare the bearer + in constant time. Reject missing/wrong tokens, oversized Artifacts, invalid + decisions, duplicate operation conflicts, and non-loopback callers before + effects. Remove the ready record and reap the listener on shutdown. +5. Exercise the real production HostRuntime, captured fake LLM server, signed + Manifest discovery, two bilateral Contracts, independent verification, and + final `Accepted` before asking for physical devices. +6. Serialize invitation create/reserve/consume/release across the CLI and + long-lived Authority with one owner-only process lock. Refresh and bounded + replay the pinned checksummed journal under that lock before mutation, so a + live CLI-created invitation is visible without restarting the Authority. + +## Acceptance criteria + +- The founding persisted credential verifies exactly the Directory and + Requester roles; its provider capability ceiling remains empty. +- Provider state contains no control token and exposes no local control + listener. +- The Requester begins with signed Directory seeds only. Executor and Verifier + endpoints are learned only from signed manifests over mTLS. +- Public pursuit output contains only IDs, Artifact hash/metrics, stages, model + call count, and final state. It contains no prompt, path, IP, token, key, + credential, or authorization header. +- Automated loopback/mTLS tests prove protocol behavior only. Task 14 remains + incomplete until the same path passes on two physical private-overlay hosts. +- A real CLI process may create an invitation after Authority startup and a + provider may redeem it immediately; concurrent create/redeem, lock-holder + crash, stale projection, corruption, and pathname replacement fail safely. + +## Risks + +- The local-control token is not yet rotatable; deletion disables pursuits and + rotation is deferred to a versioned lifecycle command. +- The first production pursuit CLI is intentionally source-metrics-specific. + It is not an arbitrary Agent or shell interface. +- Model availability and an interactive user service session remain operational + prerequisites for the physical run. diff --git a/scripts/verify-two-device-evidence.sh b/scripts/verify-two-device-evidence.sh new file mode 100755 index 0000000..7d239c6 --- /dev/null +++ b/scripts/verify-two-device-evidence.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 1 ]; then + printf '%s\n' 'usage: scripts/verify-two-device-evidence.sh EXACT_EVIDENCE_PATH' >&2 + exit 64 +fi + +evidence_path=$1 +if [ -x ./target/debug/agenet ]; then + exec ./target/debug/agenet evidence verify "$evidence_path" --output json +fi + +if ! command -v cargo >/dev/null 2>&1; then + printf '%s\n' 'EvidenceVerifierUnavailable' >&2 + exit 69 +fi + +export CARGO_NET_OFFLINE=true +exec cargo run --quiet --locked -- evidence verify "$evidence_path" --output json diff --git a/src/bootstrap/invitation.rs b/src/bootstrap/invitation.rs index 1be6a3a..e4868a1 100644 --- a/src/bootstrap/invitation.rs +++ b/src/bootstrap/invitation.rs @@ -3,8 +3,11 @@ use std::{ fmt::{Debug, Formatter}, fs::{self, File, OpenOptions}, io::{IsTerminal, Read, Write}, - os::unix::fs::{FileTypeExt, OpenOptionsExt, PermissionsExt}, - path::Path, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + }, + path::{Path, PathBuf}, sync::Mutex, }; @@ -24,6 +27,7 @@ use super::{BootstrapError, journal::DurableJournal, network::OverlayKind}; const PEPPER_FILE: &str = "invitation.pepper"; const JOURNAL_FILE: &str = "invitation.journal"; +const OPERATION_LOCK_FILE: &str = "invitation.operation.lock"; const PEPPER_BYTES: usize = 32; const SECRET_BYTES: usize = 32; const ENCODED_SECRET_BYTES: usize = 43; @@ -140,6 +144,20 @@ impl InvitationHandoff { fn install_drop_audit(&mut self, counter: std::sync::Arc) { self.drop_audit = Some(counter); } + + #[cfg(all(test, feature = "cli-test-fixture"))] + pub(crate) fn duplicate_for_test(&self) -> Self { + use age::secrecy::ExposeSecret; + + Self { + public_claims: self.public_claims.clone(), + authentication: InvitationAuthentication { + secret: SecretString::from(self.authentication.secret.expose_secret().to_owned()), + claims_integrity_hmac_sha256: self.authentication.claims_integrity_hmac_sha256, + }, + drop_audit: None, + } + } } #[cfg(test)] @@ -468,6 +486,7 @@ pub struct InvitationStore { pepper: Zeroizing<[u8; PEPPER_BYTES]>, dummy_hmac: [u8; 32], state: Mutex, + state_directory: PathBuf, } impl InvitationStore { @@ -496,6 +515,7 @@ impl InvitationStore { journal, persistence_failed: false, }), + state_directory: state_directory.to_path_buf(), }) } @@ -527,7 +547,7 @@ impl InvitationStore { getrandom::fill(&mut secret_bytes[..]).map_err(|_| BootstrapError::SecretUnavailable)?; let secret = SecretString::from(URL_SAFE_NO_PAD.encode(&secret_bytes[..])); - let mut state = self.lock_state()?; + let (mut state, _operation) = self.lock_fresh_state()?; if state.projection.records.len() >= MAX_INVITATIONS { return Err(BootstrapError::ResourceLimitExceeded); } @@ -592,7 +612,7 @@ impl InvitationStore { let claims_sha256 = public_claims_sha256(public_claims).map_err(|_| BootstrapError::InvalidInvitation)?; let invitation_id = public_claims.invitation_id; - let mut state = self.lock_state()?; + let (mut state, _operation) = self.lock_fresh_state()?; let record = state.projection.records.get(&invitation_id); let expected = record .map(|record| &record.secret_hmac_sha256) @@ -670,7 +690,7 @@ impl InvitationStore { pub fn release(&self, invitation_id: Uuid, operation_id: Uuid) -> Result<(), BootstrapError> { validate_operation_id(operation_id)?; - let mut state = self.lock_state()?; + let (mut state, _operation) = self.lock_fresh_state()?; let record = state .projection .records @@ -710,7 +730,7 @@ impl InvitationStore { if consumed_at_ms <= 0 { return Err(BootstrapError::InvalidTimestamp); } - let mut state = self.lock_state()?; + let (mut state, _operation) = self.lock_fresh_state()?; if let Some(result) = state .projection .consumptions @@ -761,7 +781,8 @@ impl InvitationStore { pub fn record(&self, invitation_id: Uuid) -> Result, BootstrapError> { Ok(self - .lock_state()? + .lock_fresh_state_for_read()? + .0 .projection .records .get(&invitation_id) @@ -774,7 +795,8 @@ impl InvitationStore { operation_id: Uuid, ) -> Result, BootstrapError> { Ok(self - .lock_state()? + .lock_fresh_state_for_read()? + .0 .projection .consumptions .get(&(invitation_id, operation_id)) @@ -784,6 +806,169 @@ impl InvitationStore { fn lock_state(&self) -> Result, BootstrapError> { self.state.lock().map_err(|_| BootstrapError::StorageFailed) } + + fn lock_fresh_state( + &self, + ) -> Result< + ( + std::sync::MutexGuard<'_, StoreState>, + InvitationOperationLock, + ), + BootstrapError, + > { + let mut state = self.lock_state()?; + let operation = InvitationOperationLock::acquire(&self.state_directory)?; + if state.persistence_failed { + return Err(BootstrapError::PersistenceUnavailable); + } + let events = match state.journal.reload() { + Ok(events) => events, + Err(error) => { + state.persistence_failed = true; + return Err(error); + } + }; + let mut projection = Projection::default(); + for event in events { + let delta = validate_event(&projection, &event)?; + apply_delta(&mut projection, delta); + } + state.projection = projection; + Ok((state, operation)) + } + + fn lock_fresh_state_for_read( + &self, + ) -> Result< + ( + std::sync::MutexGuard<'_, StoreState>, + InvitationOperationLock, + ), + BootstrapError, + > { + let mut state = self.lock_state()?; + let operation = InvitationOperationLock::acquire(&self.state_directory)?; + if state.persistence_failed { + return Ok((state, operation)); + } + let events = state.journal.reload()?; + let mut projection = Projection::default(); + for event in events { + let delta = validate_event(&projection, &event)?; + apply_delta(&mut projection, delta); + } + state.projection = projection; + Ok((state, operation)) + } +} + +struct InvitationOperationLock { + _directory: File, + _lock: File, +} + +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub struct InvitationOperationTestGuard(#[allow(dead_code)] InvitationOperationLock); + +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub fn acquire_invitation_operation_for_test( + state_directory: &Path, +) -> Result { + InvitationOperationLock::acquire(state_directory).map(InvitationOperationTestGuard) +} + +impl InvitationOperationLock { + fn acquire(state_directory: &Path) -> Result { + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(state_directory) + .map_err(|_| BootstrapError::InvalidStatePath)?; + require_secure_directory(&directory)?; + let name = std::ffi::CString::new(OPERATION_LOCK_FILE) + .map_err(|_| BootstrapError::InvalidStatePath)?; + let lock = open_operation_lock(&directory, &name)?; + loop { + if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } == 0 { + break; + } + if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted { + return Err(BootstrapError::StorageFailed); + } + } + require_owner_only_file(&lock)?; + Ok(Self { + _directory: directory, + _lock: lock, + }) + } +} + +fn require_secure_directory(directory: &File) -> Result<(), BootstrapError> { + let metadata = directory + .metadata() + .map_err(|_| BootstrapError::InvalidStatePath)?; + if !metadata.is_dir() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + { + return Err(BootstrapError::InvalidStatePath); + } + Ok(()) +} + +fn require_owner_only_file(file: &File) -> Result<(), BootstrapError> { + let metadata = file + .metadata() + .map_err(|_| BootstrapError::InvalidStatePath)?; + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o600 + { + return Err(BootstrapError::InvalidStatePath); + } + Ok(()) +} + +fn open_operation_lock(directory: &File, name: &std::ffi::CStr) -> Result { + for _ in 0..2 { + let raw = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDWR | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if raw >= 0 { + return Ok(unsafe { File::from_raw_fd(raw) }); + } + if std::io::Error::last_os_error().kind() != std::io::ErrorKind::NotFound { + return Err(BootstrapError::InvalidStatePath); + } + let created = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDWR | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o600, + ) + }; + if created >= 0 { + let file = unsafe { File::from_raw_fd(created) }; + file.sync_data() + .map_err(|_| BootstrapError::StorageFailed)?; + directory + .sync_all() + .map_err(|_| BootstrapError::StorageFailed)?; + return Ok(file); + } + if std::io::Error::last_os_error().kind() != std::io::ErrorKind::AlreadyExists { + return Err(BootstrapError::InvalidStatePath); + } + } + Err(BootstrapError::StorageFailed) } fn append_event(state: &mut StoreState, event: InvitationEvent) -> Result<(), BootstrapError> { diff --git a/src/bootstrap/journal.rs b/src/bootstrap/journal.rs index 97ecf6b..b3a27be 100644 --- a/src/bootstrap/journal.rs +++ b/src/bootstrap/journal.rs @@ -3,7 +3,7 @@ use std::{ io::{Read, Seek, SeekFrom, Write}, marker::PhantomData, os::unix::fs::OpenOptionsExt, - path::Path, + path::{Path, PathBuf}, }; use serde::{Serialize, de::DeserializeOwned}; @@ -20,6 +20,7 @@ const MAX_RECORDS: usize = 100_000; pub(crate) struct DurableJournal { file: File, + path: PathBuf, record_count: usize, marker: PhantomData, } @@ -55,6 +56,7 @@ where Ok(( Self { file, + path: path.to_path_buf(), record_count, marker: PhantomData, }, @@ -93,6 +95,44 @@ where self.record_count += 1; Ok(()) } + + pub(crate) fn reload(&mut self) -> Result, BootstrapError> { + let path_metadata = + std::fs::symlink_metadata(&self.path).map_err(|_| BootstrapError::InvalidStatePath)?; + let file_metadata = self + .file + .metadata() + .map_err(|_| BootstrapError::StorageFailed)?; + require_owner_only_regular(&path_metadata)?; + require_same_file(&path_metadata, &file_metadata)?; + if file_metadata.len() >= MAX_JOURNAL_BYTES { + return Err(BootstrapError::ResourceLimitExceeded); + } + self.file + .seek(SeekFrom::Start(0)) + .map_err(|_| BootstrapError::StorageFailed)?; + let mut bytes = Vec::with_capacity( + usize::try_from(file_metadata.len()).map_err(|_| BootstrapError::InvalidJournal)?, + ); + self.file + .read_to_end(&mut bytes) + .map_err(|_| BootstrapError::StorageFailed)?; + let entries = decode_entries::(&bytes)?; + self.record_count = entries.len(); + Ok(entries) + } +} + +#[cfg(unix)] +fn require_same_file( + path: &std::fs::Metadata, + opened: &std::fs::Metadata, +) -> Result<(), BootstrapError> { + use std::os::unix::fs::MetadataExt; + if path.dev() != opened.dev() || path.ino() != opened.ino() { + return Err(BootstrapError::InvalidStatePath); + } + Ok(()) } fn open_journal_file(path: &Path) -> Result<(File, bool), BootstrapError> { diff --git a/src/bootstrap/managed_binary.rs b/src/bootstrap/managed_binary.rs index ce10a3e..614dc4e 100644 --- a/src/bootstrap/managed_binary.rs +++ b/src/bootstrap/managed_binary.rs @@ -41,6 +41,8 @@ pub struct ServiceMetadataV3 { #[serde(skip_serializing_if = "Option::is_none")] pub managed_binary: Option, pub runtime_ready: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_instance_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub node_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -53,6 +55,7 @@ impl ServiceMetadataV3 { format: SERVICE_METADATA_FORMAT.to_owned(), managed_binary, runtime_ready: false, + process_instance_id: None, node_id: None, endpoint: None, } diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index fbcc9f7..1c09f2f 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -38,6 +38,9 @@ pub use invitation::{ InvitationRecord, InvitationSpec, InvitationState, InvitationStore, ReservationStatus, display_invitation_handoff_to_tty, read_invitation_handoff_from_tty, }; +#[cfg(feature = "cli-test-fixture")] +#[doc(hidden)] +pub use invitation::{InvitationOperationTestGuard, acquire_invitation_operation_for_test}; pub use keystore::{ AgeRootKeystore, DomainRootMaterial, LegacyV1MigrationPolicy, RootKeystore, RootKeystoreFormatVersion, UnlockedRootKeystore, prompt_root_passphrase, diff --git a/src/bootstrap/paths.rs b/src/bootstrap/paths.rs index d752e1d..a649f38 100644 --- a/src/bootstrap/paths.rs +++ b/src/bootstrap/paths.rs @@ -69,10 +69,29 @@ pub struct NodePaths { pub identity_operation_lock_file: PathBuf, pub departure_receipt_file: PathBuf, pub pending_departure_file: PathBuf, + pub local_control_token_file: PathBuf, + pub local_control_ready_file: PathBuf, } impl NodePaths { pub fn for_current_user() -> Result { + #[cfg(feature = "cli-test-fixture")] + if let Some(home) = std::env::var_os("AGENET_CLI_TEST_HOME") { + let home = PathBuf::from(home); + if !home.is_absolute() { + return Err(BootstrapError::InvalidStatePath); + } + #[cfg(target_os = "macos")] + return Self::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(home, None, None), + ); + #[cfg(target_os = "linux")] + return Self::resolve( + UserPlatform::Linux, + &NodePathEnvironment::new(home, None, None), + ); + } let base = directories::BaseDirs::new().ok_or(BootstrapError::InvalidStatePath)?; #[cfg(target_os = "macos")] let (platform, environment) = ( @@ -155,6 +174,8 @@ impl NodePaths { identity_operation_lock_file: state_dir.join("identity-operation-v1.lock"), departure_receipt_file: state_dir.join("departure-receipt-v1.json"), pending_departure_file: state_dir.join("pending-departure-v1.json"), + local_control_token_file: state_dir.join("local-control-token-v1.secret"), + local_control_ready_file: state_dir.join("local-control-ready-v1.json"), config_dir, state_dir, service_definition, diff --git a/src/cli/domain.rs b/src/cli/domain.rs index 261a145..c948f66 100644 --- a/src/cli/domain.rs +++ b/src/cli/domain.rs @@ -5,7 +5,10 @@ use std::{ }; use age::secrecy::{ExposeSecret, SecretString}; -use base64::{Engine, engine::general_purpose::STANDARD}; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; use clap::{Args, Subcommand, ValueEnum}; use ed25519_dalek::SigningKey; use ipnet::IpNet; @@ -220,7 +223,7 @@ pub(crate) fn provision( node_id: founding_node_id.clone(), signing_public_key_base64: STANDARD.encode(founding_key.verifying_key().to_bytes()), bootstrap_profile: BootstrapProfile::Base, - allowed_roles: BTreeSet::from([NodeRole::Directory]), + allowed_roles: BTreeSet::from([NodeRole::Directory, NodeRole::Requester]), capability_ceiling: BTreeSet::new(), issued_at_ms: now, expires_at_ms: now + 30 * 24 * 60 * 60 * 1_000, @@ -261,6 +264,7 @@ pub(crate) fn provision( revocation_endpoint: endpoint(config_ip(&authority_endpoint)?, REVOCATION_PORT)?, }; paths.ensure_secure_layout().map_err(map_bootstrap)?; + write_local_control_token(&paths)?; AgeRootKeystore::create( &paths.root_keystore_file, &DomainRootMaterial { @@ -363,6 +367,13 @@ fn random_signing_key() -> Result { getrandom::fill(&mut bytes).map_err(|_| internal())?; Ok(SigningKey::from_bytes(&bytes)) } +fn write_local_control_token(paths: &NodePaths) -> Result<(), CliError> { + let mut token = zeroize::Zeroizing::new([0_u8; 32]); + getrandom::fill(token.as_mut()).map_err(|_| internal())?; + let encoded = zeroize::Zeroizing::new(URL_SAFE_NO_PAD.encode(token.as_slice())); + atomic_write_owner_only_strict(&paths.local_control_token_file, encoded.as_bytes(), false) + .map_err(|_| persistence()) +} fn now_ms() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -447,13 +458,30 @@ mod tests { SecretString::from("test-only-passphrase".to_owned()), ) .expect("Root unlocks"); - load_startup_bundle( + let bundle = load_startup_bundle( &paths, &root.signing_key.verifying_key(), NodeRole::Directory, now_ms().expect("clock"), ) .expect("founding peer reloads"); + let verified = crate::protocol::verify_credential_chain( + &root.signing_key.verifying_key(), + &bundle.credential, + &bundle.config.domain_id, + NodeRole::Requester, + now_ms().expect("clock"), + ) + .expect("founding Requester role verifies"); + assert_eq!( + verified.allowed_roles, + BTreeSet::from([NodeRole::Directory, NodeRole::Requester]) + ); + assert!(verified.capability_ceiling.is_empty()); + let token = paths + .read_material(&paths.local_control_token_file, 128) + .expect("owner-only control token"); + assert_eq!(token.len(), 43, "32-byte base64url token without padding"); let ca = paths .read_material(&paths.authority_ca_certificate_file, 64 * 1024) .expect("CA cert"); diff --git a/src/cli/evidence.rs b/src/cli/evidence.rs new file mode 100644 index 0000000..c3e533b --- /dev/null +++ b/src/cli/evidence.rs @@ -0,0 +1,265 @@ +use std::path::PathBuf; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use clap::{Args, Subcommand, ValueEnum}; +use ed25519_dalek::VerifyingKey; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::{ + bootstrap::{NodePaths, ServiceMetadataV3}, + evidence::{EvidenceDevice, verify_evidence_path}, + protocol::{CredentialChain, NodeRole, verify_credential_chain}, + runtime::{Clock, inspect_revocation_cache_read_only}, +}; + +use super::{CliError, OutputFormat, output}; + +#[derive(Debug, Args)] +pub struct EvidenceArgs { + #[command(subcommand)] + command: EvidenceCommand, +} + +impl EvidenceArgs { + pub fn output(&self) -> OutputFormat { + match &self.command { + EvidenceCommand::Verify(args) => args.output, + EvidenceCommand::Collect(args) => args.output, + } + } +} + +#[derive(Debug, Subcommand)] +enum EvidenceCommand { + Verify(VerifyArgs), + Collect(CollectArgs), +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum DeviceLabel { + DeviceA, + DeviceB, +} + +#[derive(Debug, Args)] +struct CollectArgs { + #[arg(long, value_enum)] + label: DeviceLabel, + #[arg(long, required = true)] + confirm_physical_device: bool, + #[arg(long, value_enum, default_value = "json")] + output: OutputFormat, +} + +#[derive(Debug, Args)] +struct VerifyArgs { + evidence: PathBuf, + #[arg(long, value_enum, default_value = "json")] + output: OutputFormat, +} + +#[derive(Debug, Serialize)] +struct VerificationResult { + format: &'static str, + schema_version: u32, + result: &'static str, + device_count: usize, +} + +pub fn execute(args: EvidenceArgs) -> Result<(), CliError> { + match args.command { + EvidenceCommand::Verify(args) => verify(args), + EvidenceCommand::Collect(args) => collect(args), + } +} + +fn collect(args: CollectArgs) -> Result<(), CliError> { + if !args.confirm_physical_device { + return Err(collect_failed()); + } + let paths = NodePaths::for_current_user().map_err(|_| collect_failed())?; + let config = paths.read_config().map_err(|_| collect_failed())?; + let transport = match config.network.kind { + crate::bootstrap::network::OverlayKind::Tailscale => "tailscale", + crate::bootstrap::network::OverlayKind::WireGuard => "wireguard", + crate::bootstrap::network::OverlayKind::Loopback => return Err(collect_failed()), + }; + let root = read_root(&paths)?; + let credential = read_credential(&paths)?; + let claims = verified_claims(&credential, &root, &config.domain_id)?; + let signed_roles = claims + .allowed_roles + .iter() + .map(public_role) + .collect::, _>>()?; + let expected_signed_roles = match args.label { + DeviceLabel::DeviceA => ["directory", "requester"].as_slice(), + DeviceLabel::DeviceB => ["requester", "executor", "verifier"].as_slice(), + }; + if signed_roles.as_slice() != expected_signed_roles { + return Err(collect_failed()); + } + let (active_roles, requester_local_enabled) = + runtime_roles(&paths, args.label, &claims.node_id)?; + let service = read_service_metadata(&paths)?; + if !service.runtime_ready || service.node_id.as_deref() != Some(claims.node_id.as_str()) { + return Err(collect_failed()); + } + let process_instance_id = service.process_instance_id.ok_or_else(collect_failed)?; + let revocation = inspect_revocation_cache_read_only( + &paths.revocation_file, + &root, + &config.domain_id, + &credential.authority, + ) + .map_err(|_| collect_failed())?; + let fragment = EvidenceDevice { + label: match args.label { + DeviceLabel::DeviceA => "device-a", + DeviceLabel::DeviceB => "device-b", + } + .to_owned(), + os: std::env::consts::OS.to_owned(), + architecture: std::env::consts::ARCH.to_owned(), + physical: true, + transport: transport.to_owned(), + endpoint_class: "private-overlay".to_owned(), + domain_id: claims.domain_id.as_str().to_owned(), + node_id: claims.node_id.as_str().to_owned(), + credential_signed_roles: signed_roles.into_iter().map(str::to_owned).collect(), + runtime_active_roles: active_roles.into_iter().map(str::to_owned).collect(), + requester_local_enabled, + process_instance_id, + certificate_sha256: certificate_fingerprint(&paths)?, + credential_format: "agenet.node-credential".to_owned(), + credential_version: "v0.3".to_owned(), + revocation_epoch: revocation.epoch, + }; + output::emit( + args.output, + "Redacted device evidence collected.", + &fragment, + ) +} + +fn runtime_roles( + paths: &NodePaths, + label: DeviceLabel, + node_id: &crate::protocol::NodeId, +) -> Result<(Vec<&'static str>, bool), CliError> { + let token_exists = std::fs::symlink_metadata(&paths.local_control_token_file) + .is_ok_and(|metadata| metadata.file_type().is_file()); + let local_ready = crate::runtime::load_local_control_ready(paths).ok(); + match label { + DeviceLabel::DeviceA + if token_exists + && local_ready + .as_ref() + .is_some_and(|ready| &ready.node_id == node_id) => + { + Ok((vec!["directory", "requester"], true)) + } + DeviceLabel::DeviceB if !token_exists && local_ready.is_none() => { + Ok((vec!["executor", "verifier"], false)) + } + _ => Err(collect_failed()), + } +} + +fn read_root(paths: &NodePaths) -> Result { + let bytes = paths + .read_material(&paths.root_public_key_file, 256) + .map_err(|_| collect_failed())?; + let decoded = STANDARD + .decode( + std::str::from_utf8(&bytes) + .map_err(|_| collect_failed())? + .trim(), + ) + .map_err(|_| collect_failed())?; + let raw: [u8; 32] = decoded.try_into().map_err(|_| collect_failed())?; + VerifyingKey::from_bytes(&raw).map_err(|_| collect_failed()) +} + +fn read_credential(paths: &NodePaths) -> Result { + let bytes = paths + .read_material(&paths.credential_file, 64 * 1024) + .map_err(|_| collect_failed())?; + serde_json::from_slice(&bytes).map_err(|_| collect_failed()) +} + +fn verified_claims( + credential: &CredentialChain, + root: &VerifyingKey, + domain: &crate::protocol::DomainId, +) -> Result { + let now = crate::runtime::SystemClock.now_ms(); + [ + NodeRole::Directory, + NodeRole::Requester, + NodeRole::Executor, + NodeRole::Verifier, + ] + .into_iter() + .find_map(|role| verify_credential_chain(root, credential, domain, role, now).ok()) + .ok_or_else(collect_failed) +} + +fn public_role(role: &NodeRole) -> Result<&'static str, CliError> { + match role { + NodeRole::Directory => Ok("directory"), + NodeRole::Requester => Ok("requester"), + NodeRole::Executor => Ok("executor"), + NodeRole::Verifier => Ok("verifier"), + } +} + +fn read_service_metadata(paths: &NodePaths) -> Result { + let bytes = paths + .read_material(&paths.service_metadata_file, 32 * 1024) + .map_err(|_| collect_failed())?; + ServiceMetadataV3::parse(&bytes).map_err(|_| collect_failed()) +} + +fn certificate_fingerprint(paths: &NodePaths) -> Result { + let pem = paths + .read_material(&paths.tls_certificate_file, 64 * 1024) + .map_err(|_| collect_failed())?; + let certificate = rustls_pemfile::certs(&mut std::io::Cursor::new(pem.as_slice())) + .next() + .ok_or_else(collect_failed)? + .map_err(|_| collect_failed())?; + Ok(Sha256::digest(certificate.as_ref()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn collect_failed() -> CliError { + CliError::new( + "EvidenceCollectionFailed", + "Redacted evidence could not be collected from a ready physical node.", + false, + ) +} + +fn verify(args: VerifyArgs) -> Result<(), CliError> { + let evidence = verify_evidence_path(&args.evidence).map_err(|_| { + CliError::new( + "EvidenceVerificationFailed", + "The physical evidence did not pass strict verification.", + false, + ) + })?; + output::emit( + args.output, + "Two-device physical evidence verified.", + &VerificationResult { + format: "agenet.evidence-verification", + schema_version: 1, + result: "pass", + device_count: evidence.device_count(), + }, + ) +} diff --git a/src/cli/invite.rs b/src/cli/invite.rs index b5c86b8..d8e16b7 100644 --- a/src/cli/invite.rs +++ b/src/cli/invite.rs @@ -32,20 +32,20 @@ enum InviteCommand { } #[derive(Debug, Clone, Copy, ValueEnum)] -enum ProfileChoice { +pub(super) enum ProfileChoice { Base, Provider, AgentCandidate, } #[derive(Debug, Args)] -struct CreateArgs { +pub(super) struct CreateArgs { #[arg(long, value_enum)] - profile: ProfileChoice, + pub(super) profile: ProfileChoice, #[arg(long, default_value = "10m", value_parser = parse_ttl)] - ttl: i64, + pub(super) ttl: i64, #[arg(long, value_enum, default_value = "human")] - output: OutputFormat, + pub(super) output: OutputFormat, } impl InviteArgs { @@ -75,7 +75,7 @@ fn create(args: CreateArgs, terminal: &impl SecretTerminal) -> Result<(), CliErr create_at(&paths, args, terminal) } -fn create_at( +pub(super) fn create_at( paths: &NodePaths, args: CreateArgs, terminal: &impl SecretTerminal, @@ -107,12 +107,7 @@ fn create_at( ProfileChoice::Provider => BootstrapProfile::Provider, ProfileChoice::AgentCandidate => BootstrapProfile::AgentCandidate, }; - let capabilities = match profile { - BootstrapProfile::Base => BTreeSet::new(), - BootstrapProfile::Provider | BootstrapProfile::AgentCandidate => { - BTreeSet::from([CapabilityKind::new("source.metrics.v1").map_err(|_| internal())?]) - } - }; + let capabilities = capabilities_for_profile(profile)?; let root_fingerprint = hex(Sha256::digest(root.signing_key.verifying_key().as_bytes()).as_slice()); let authority = &bundle.credential.authority.claims; @@ -158,6 +153,20 @@ fn create_at( ) } +fn capabilities_for_profile( + profile: BootstrapProfile, +) -> Result, CliError> { + let kind = |value| CapabilityKind::new(value).map_err(|_| internal()); + match profile { + BootstrapProfile::Base => Ok(BTreeSet::new()), + BootstrapProfile::Provider => Ok(BTreeSet::from([ + kind("source.metrics.v1")?, + kind("source.metrics.verify.v1")?, + ])), + BootstrapProfile::AgentCandidate => Ok(BTreeSet::from([kind("source.metrics.v1")?])), + } +} + fn parse_ttl(value: &str) -> Result { let (number, multiplier) = if let Some(value) = value.strip_suffix('m') { (value, 60_000_i64) @@ -279,4 +288,17 @@ mod tests { .expect("invitation creates"); assert_eq!(terminal.displays.load(Ordering::SeqCst), 1); } + + #[test] + fn provider_invitation_authorizes_both_published_capabilities() { + let capabilities = + capabilities_for_profile(BootstrapProfile::Provider).expect("provider capabilities"); + assert_eq!( + capabilities, + BTreeSet::from([ + CapabilityKind::new("source.metrics.v1").expect("executor"), + CapabilityKind::new("source.metrics.verify.v1").expect("verifier"), + ]) + ); + } } diff --git a/src/cli/join.rs b/src/cli/join.rs index 9d64cc1..e30b79a 100644 --- a/src/cli/join.rs +++ b/src/cli/join.rs @@ -38,7 +38,7 @@ pub struct JoinArgs { } #[derive(Debug, Serialize)] -struct JoinResult { +pub(super) struct JoinResult { node_id: String, domain_id: String, phase: &'static str, @@ -69,7 +69,7 @@ pub async fn execute(args: JoinArgs, terminal: &impl SecretTerminal) -> Result<( ) } -async fn execute_at( +pub(super) async fn execute_at( paths: &NodePaths, args: JoinArgs, terminal: &impl SecretTerminal, @@ -682,6 +682,10 @@ mod tests { crate::protocol::CapabilityKind::new("source.metrics.v1").expect("capability"), ]) ); + assert!( + std::fs::symlink_metadata(&paths.local_control_token_file).is_err(), + "provider enrollment must not enable the local Requester control plane" + ); handle.shutdown(); task.await.expect("join server"); } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ae19833..02f3ef4 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,9 +1,11 @@ mod doctor; mod domain; +mod evidence; mod invite; mod join; mod lifecycle; mod output; +mod pursuit; #[path = "node.rs"] mod service_node; @@ -89,9 +91,11 @@ pub struct Cli { #[derive(Debug, Subcommand)] enum Command { Domain(domain::DomainArgs), + Evidence(evidence::EvidenceArgs), Invite(invite::InviteArgs), Node(Box), Credential(lifecycle::CredentialArgs), + Pursuit(pursuit::PursuitArgs), Uninstall(lifecycle::UninstallArgs), Demo(DemoArgs), } @@ -204,6 +208,10 @@ async fn run_cli(cli: Cli) -> i32 { let format = args.output(); (format, domain::execute(args, &ControllingTerminal)) } + Command::Evidence(args) => { + let format = args.output(); + (format, evidence::execute(args)) + } Command::Invite(args) => { let format = args.output(); (format, invite::execute(args, &ControllingTerminal)) @@ -249,6 +257,10 @@ async fn run_cli(cli: Cli) -> i32 { let format = args.output(); (format, lifecycle::credential(args).await) } + Command::Pursuit(args) => { + let format = args.output(); + (format, pursuit::execute(args).await) + } Command::Uninstall(args) => { let format = args.output; (format, lifecycle::uninstall(args, &ControllingTerminal)) @@ -318,9 +330,34 @@ async fn run_internal_node(args: NodeArgs) -> Result<(), CliError> { #[cfg(test)] mod tests { + #[cfg(feature = "cli-test-fixture")] + use std::{ + net::IpAddr, + str::FromStr, + sync::{Arc, Mutex}, + time::Duration, + }; + + #[cfg(feature = "cli-test-fixture")] + use age::secrecy::SecretString; + #[cfg(feature = "cli-test-fixture")] + use axum::{Json, Router, routing::post}; use clap::{CommandFactory, Parser}; + #[cfg(feature = "cli-test-fixture")] + use tempfile::TempDir; + + #[cfg(feature = "cli-test-fixture")] + use crate::{ + bootstrap::{ + BootstrapError, InvitationHandoff, NodePathEnvironment, NodePaths, UserPlatform, + }, + protocol::ContractState, + runtime::HostRuntime, + }; use super::Cli; + #[cfg(feature = "cli-test-fixture")] + use super::{OutputFormat, SecretTerminal}; #[test] fn advertised_domain_follow_up_is_a_clap_accepted_command() { @@ -348,4 +385,279 @@ mod tests { assert!(help.contains(public)); } } + + #[test] + fn pursuit_run_exposes_only_local_operator_inputs() { + let parsed = Cli::try_parse_from([ + "agenet", + "pursuit", + "run", + "--env-file", + "/tmp/model.env", + "--artifact", + "/tmp/sample.rs", + "--output", + "json", + ]); + assert!(parsed.is_ok()); + + let mut command = Cli::command(); + let pursuit = command.find_subcommand_mut("pursuit").unwrap(); + let mut output = Vec::new(); + pursuit.write_long_help(&mut output).unwrap(); + let help = String::from_utf8(output).unwrap(); + for forbidden in [ + "directory-endpoint", + "provider-endpoint", + "executor-endpoint", + "verifier-endpoint", + "api-key", + ] { + assert!(!help.contains(forbidden)); + } + } + + #[cfg(feature = "cli-test-fixture")] + struct HandoffTerminal { + handoff: Mutex>, + } + + #[cfg(feature = "cli-test-fixture")] + impl SecretTerminal for HandoffTerminal { + fn prompt_hidden(&self, _: &str) -> Result { + Ok(SecretString::from( + "production-flow-test-passphrase".to_owned(), + )) + } + + fn read_invitation(&self) -> Result { + self.handoff + .lock() + .map_err(|_| BootstrapError::InvalidInvitation)? + .take() + .ok_or(BootstrapError::InvalidInvitation) + } + + fn display_invitation(&self, handoff: &InvitationHandoff) -> Result<(), BootstrapError> { + *self + .handoff + .lock() + .map_err(|_| BootstrapError::InvalidInvitation)? = + Some(handoff.duplicate_for_test()); + Ok(()) + } + } + + #[cfg(feature = "cli-test-fixture")] + #[tokio::test] + async fn production_hosts_discover_provider_and_complete_decided_pursuit() { + let _host_guard = crate::runtime::host_test_guard().await; + let a_home = TempDir::new().expect("A home"); + let b_home = TempDir::new().expect("B home"); + let a_paths = test_paths(&a_home); + let b_paths = test_paths(&b_home); + super::domain::provision( + a_paths.clone(), + loopback_boundary("127.0.0.1"), + SecretString::from("production-flow-test-passphrase".to_owned()), + ) + .expect("found Domain"); + + let a_runtime = HostRuntime::load(&a_paths).await.expect("A runtime"); + let (a_shutdown_tx, a_shutdown_rx) = tokio::sync::oneshot::channel(); + let a_run_paths = a_paths.clone(); + let mut a_task = tokio::spawn(async move { + a_runtime + .run_until(&a_run_paths, async move { + let _ = a_shutdown_rx.await; + }) + .await + }); + wait_for(&a_paths.local_control_ready_file, &mut a_task).await; + + let terminal = HandoffTerminal { + handoff: Mutex::new(None), + }; + super::invite::create_at( + &a_paths, + super::invite::CreateArgs { + profile: super::invite::ProfileChoice::Provider, + ttl: 600_000, + output: OutputFormat::Json, + }, + &terminal, + ) + .expect("live provider invitation"); + + super::join::execute_at( + &b_paths, + super::join::JoinArgs { + bind_ip: Some(IpAddr::from_str("127.0.0.1").expect("B IP")), + output: OutputFormat::Json, + }, + &terminal, + ) + .await + .expect("B joins"); + assert!(!b_paths.local_control_token_file.exists()); + let mut b_config = b_paths.read_config().expect("B config"); + b_config.peer_port = 0; + b_paths + .write_config(&b_config) + .expect("dynamic B peer port"); + + let b_runtime = HostRuntime::load(&b_paths).await.expect("B runtime"); + let (b_shutdown_tx, b_shutdown_rx) = tokio::sync::oneshot::channel(); + let b_run_paths = b_paths.clone(); + let mut b_task = tokio::spawn(async move { + b_runtime + .run_until(&b_run_paths, async move { + let _ = b_shutdown_rx.await; + }) + .await + }); + wait_for(&b_paths.service_metadata_file, &mut b_task).await; + + let captured = Arc::new(Mutex::new(None)); + let captured_request = Arc::clone(&captured); + let llm = Router::new().route( + "/v1/chat/completions", + post(move |Json(body): Json| { + let captured = Arc::clone(&captured_request); + async move { + *captured.lock().expect("capture") = Some(body); + Json(serde_json::json!({ + "choices": [{"message": {"content": "{\"required_capability\":\"source.metrics.v1\",\"acceptance_profile\":\"exact-source-metrics.v1\",\"requires_independent_verifier\":true}"}}] + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("fake LLM"); + let llm_address = listener.local_addr().expect("LLM address"); + let llm_task = tokio::spawn(async move { + axum::serve(listener, llm).await.expect("LLM server"); + }); + let env_file = a_home.path().join("model.env"); + let artifact = a_home.path().join("artifact.rs"); + std::fs::write( + &env_file, + format!( + "OPENAI_BASE_URL=http://{llm_address}/v1\nOPENAI_API_KEY=sentinel-production-key\nVLM_MODEL=captured-model\n" + ), + ) + .expect("env file"); + std::fs::write( + &artifact, + b"fn main() { println!(\"physical-preflight\"); }\n", + ) + .expect("artifact"); + + let binary = std::env::current_exe() + .expect("test executable") + .parent() + .and_then(std::path::Path::parent) + .expect("target debug") + .join("agenet"); + assert!( + binary.is_file(), + "run cargo build before the binary preflight" + ); + let a_home_path = a_home.path().canonicalize().expect("canonical A home"); + let output = tokio::process::Command::new(binary) + .arg("pursuit") + .arg("run") + .arg("--env-file") + .arg(&env_file) + .arg("--artifact") + .arg(&artifact) + .arg("--output") + .arg("json") + .env("HOME", &a_home_path) + .env("CFFIXED_USER_HOME", &a_home_path) + .env("AGENET_CLI_TEST_HOME", &a_home_path) + .env_remove("OPENAI_BASE_URL") + .env_remove("OPENAI_API_KEY") + .env_remove("VLM_MODEL") + .output() + .await + .expect("pursuit binary"); + assert!( + output.status.success(), + "binary failed: {}", + String::from_utf8_lossy(&output.stdout) + ); + assert!(output.stderr.is_empty()); + let result: crate::runtime::PursuitResult = + serde_json::from_slice(&output.stdout).expect("public pursuit JSON"); + assert_eq!(result.discovery_mode, "signed_directory_manifests"); + assert_ne!(result.requester_node_id, result.executor_node_id); + assert_eq!(result.executor_node_id, result.verifier_node_id); + assert_eq!(result.executor_metrics, result.verifier_metrics); + assert_eq!(result.state_path.last(), Some(&ContractState::Accepted)); + assert_eq!(result.llm_calls, 1); + let request_text = serde_json::to_string( + captured + .lock() + .expect("capture") + .as_ref() + .expect("LLM request"), + ) + .expect("request json"); + for forbidden in [ + "physical-preflight", + env_file.to_str().expect("env path"), + artifact.to_str().expect("artifact path"), + "sentinel-production-key", + "provider-endpoint", + ] { + assert!(!request_text.contains(forbidden)); + } + + b_shutdown_tx.send(()).expect("B shutdown"); + b_task.await.expect("B join").expect("B clean shutdown"); + a_shutdown_tx.send(()).expect("A shutdown"); + a_task.await.expect("A join").expect("A clean shutdown"); + llm_task.abort(); + let _ = llm_task.await; + assert!(!a_paths.local_control_ready_file.exists()); + assert!(!b_paths.service_metadata_file.exists()); + } + + #[cfg(feature = "cli-test-fixture")] + fn test_paths(home: &TempDir) -> NodePaths { + NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new(home.path().canonicalize().expect("home"), None, None), + ) + .expect("paths") + } + + #[cfg(feature = "cli-test-fixture")] + fn loopback_boundary(address: &str) -> crate::bootstrap::network::NetworkBoundary { + crate::bootstrap::network::NetworkBoundary { + kind: crate::bootstrap::network::OverlayKind::Loopback, + bind_ip: IpAddr::from_str(address).expect("IP"), + allowed_cidrs: vec!["127.0.0.0/8".parse().expect("CIDR")], + } + } + + #[cfg(feature = "cli-test-fixture")] + async fn wait_for( + path: &std::path::Path, + task: &mut tokio::task::JoinHandle>, + ) { + for _ in 0..400 { + if path.exists() { + return; + } + if task.is_finished() { + let result = task.await.expect("runtime join"); + panic!("runtime stopped before ready: {result:?}"); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("runtime did not become ready") + } } diff --git a/src/cli/pursuit.rs b/src/cli/pursuit.rs new file mode 100644 index 0000000..49acb5c --- /dev/null +++ b/src/cli/pursuit.rs @@ -0,0 +1,273 @@ +use std::{ + collections::HashMap, + io::Read, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use clap::{Args, Subcommand}; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderValue}; +use zeroize::Zeroizing; + +use crate::{ + adapters::{DecisionError, LlmDecisionAdapter}, + bootstrap::NodePaths, + runtime::{DecidedPursuitRequest, MAX_ARTIFACT_BYTES, PursuitResult, load_local_control_ready}, +}; + +use super::{CliError, OutputFormat, output}; + +const DEFAULT_GOAL: &str = "Compute exact source metrics and independently verify the result."; +const MAX_LOCAL_RESPONSE_BYTES: usize = 256 * 1024; + +#[derive(Debug, Args)] +pub struct PursuitArgs { + #[command(subcommand)] + command: PursuitCommand, +} + +impl PursuitArgs { + pub fn output(&self) -> OutputFormat { + match &self.command { + PursuitCommand::Run(args) => args.output, + } + } +} + +#[derive(Debug, Subcommand)] +enum PursuitCommand { + Run(RunArgs), +} + +#[derive(Debug, Args)] +struct RunArgs { + #[arg(long)] + env_file: PathBuf, + #[arg(long)] + artifact: PathBuf, + #[arg(long, default_value = DEFAULT_GOAL)] + goal: String, + #[arg(long, value_enum, default_value = "human")] + output: OutputFormat, +} + +pub async fn execute(args: PursuitArgs) -> Result<(), CliError> { + match args.command { + PursuitCommand::Run(args) => run(args).await, + } +} + +async fn run(args: RunArgs) -> Result<(), CliError> { + let paths = NodePaths::for_current_user().map_err(|_| local_configuration_invalid())?; + let result = run_at(&paths, &args).await?; + output::emit(args.output, "Pursuit accepted.", &result) +} + +async fn run_at(paths: &NodePaths, args: &RunArgs) -> Result { + validate_goal(&args.goal)?; + let artifact = read_artifact(&args.artifact)?; + let environment = read_llm_environment(&args.env_file)?; + let token = read_control_token(paths)?; + let ready = load_local_control_ready(paths).map_err(|_| local_control_unavailable())?; + + let decision_adapter = LlmDecisionAdapter::new( + environment.base_url.as_str(), + environment.api_key.as_str(), + environment.model.as_str(), + ) + .map_err(map_decision_error)?; + let llm_started = Instant::now(); + let decision = decision_adapter + .decide(&args.goal) + .await + .map_err(map_decision_error)?; + let llm_ms = elapsed_ms(llm_started); + + let request = DecidedPursuitRequest { + operation_id: uuid::Uuid::new_v4(), + artifact_bytes_base64: STANDARD.encode(artifact), + media_type: "text/x-rust".to_owned(), + decision: decision.decision, + llm_calls: decision.llm_calls, + }; + let operation_id = request.operation_id; + let mut result = submit_local(&ready.endpoint, token.as_str(), &request) + .await + .map_err(|error| error.with_operation(operation_id.to_string()))?; + result.phase_ms.insert("llm_decision".to_owned(), llm_ms); + let service_total = result.phase_ms.get("total").copied().unwrap_or_default(); + result + .phase_ms + .insert("total".to_owned(), service_total.saturating_add(llm_ms)); + Ok(result) +} + +struct LlmEnvironment { + base_url: String, + api_key: Zeroizing, + model: String, +} + +fn read_llm_environment(path: &Path) -> Result { + let entries = dotenvy::from_path_iter(path).map_err(|_| llm_configuration_invalid())?; + let values: HashMap = entries + .map(|entry| entry.map_err(|_| llm_configuration_invalid())) + .collect::>()?; + let required = |name: &str| { + values + .get(name) + .filter(|value| !value.is_empty()) + .cloned() + .ok_or_else(llm_configuration_invalid) + }; + Ok(LlmEnvironment { + base_url: required("OPENAI_BASE_URL")?, + api_key: Zeroizing::new(required("OPENAI_API_KEY")?), + model: required("VLM_MODEL")?, + }) +} + +fn read_artifact(path: &Path) -> Result, CliError> { + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(path) + .map_err(|_| artifact_invalid())?; + let metadata = file.metadata().map_err(|_| artifact_invalid())?; + if !metadata.file_type().is_file() || metadata.len() > MAX_ARTIFACT_BYTES as u64 { + return Err(artifact_invalid()); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take((MAX_ARTIFACT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| artifact_invalid())?; + if bytes.len() > MAX_ARTIFACT_BYTES { + return Err(artifact_invalid()); + } + Ok(bytes) +} + +fn read_control_token(paths: &NodePaths) -> Result, CliError> { + let bytes = paths + .read_material(&paths.local_control_token_file, 128) + .map_err(|_| local_control_unavailable())?; + let token = std::str::from_utf8(&bytes).map_err(|_| local_control_unavailable())?; + if token.len() != 43 { + return Err(local_control_unavailable()); + } + Ok(Zeroizing::new(token.to_owned())) +} + +async fn submit_local( + endpoint: &url::Url, + token: &str, + request: &DecidedPursuitRequest, +) -> Result { + let url = endpoint + .join("local/v0/pursuits") + .map_err(|_| local_control_unavailable())?; + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(2)) + .timeout(Duration::from_secs(90)) + .build() + .map_err(|_| local_control_unavailable())?; + let mut authorization = HeaderValue::from_str(&format!("Bearer {token}")) + .map_err(|_| local_control_unavailable())?; + authorization.set_sensitive(true); + let response = client + .post(url) + .header(AUTHORIZATION, authorization) + .header(CONTENT_TYPE, "application/json") + .json(request) + .send() + .await + .map_err(|_| local_control_unavailable())?; + if !response.status().is_success() { + return Err(CliError::new( + "PursuitRejected", + "The local pursuit was rejected.", + response.status().is_server_error(), + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_LOCAL_RESPONSE_BYTES as u64) + { + return Err(local_control_unavailable()); + } + let bytes = response + .bytes() + .await + .map_err(|_| local_control_unavailable())?; + if bytes.len() > MAX_LOCAL_RESPONSE_BYTES { + return Err(local_control_unavailable()); + } + serde_json::from_slice(&bytes).map_err(|_| local_control_unavailable()) +} + +fn validate_goal(goal: &str) -> Result<(), CliError> { + if goal.is_empty() || goal.len() > 2_048 || goal.chars().any(char::is_control) { + return Err(CliError::new( + "InvalidGoal", + "The public goal is invalid.", + false, + )); + } + Ok(()) +} + +fn elapsed_ms(started: Instant) -> u64 { + started.elapsed().as_millis().try_into().unwrap_or(u64::MAX) +} + +fn map_decision_error(error: DecisionError) -> CliError { + match error { + DecisionError::InvalidConfiguration => llm_configuration_invalid(), + DecisionError::AgentDecisionInvalid => CliError::new( + "AgentDecisionInvalid", + "The model did not return a valid AgenNet decision.", + false, + ), + _ => CliError::new( + "LlmRequestFailed", + "The model decision request failed.", + true, + ), + } +} + +fn artifact_invalid() -> CliError { + CliError::new( + "ArtifactInvalid", + "The artifact must be a regular file no larger than 64 KiB.", + false, + ) +} + +fn llm_configuration_invalid() -> CliError { + CliError::new( + "LlmConfigurationInvalid", + "The model environment file is invalid.", + false, + ) +} + +fn local_configuration_invalid() -> CliError { + CliError::new( + "LocalConfigurationInvalid", + "The local AgenNet node configuration is invalid.", + false, + ) +} + +fn local_control_unavailable() -> CliError { + CliError::new( + "LocalControlUnavailable", + "The local Requester control service is unavailable.", + true, + ) +} diff --git a/src/evidence.rs b/src/evidence.rs new file mode 100644 index 0000000..362ec6a --- /dev/null +++ b/src/evidence.rs @@ -0,0 +1,767 @@ +//! Strict, redacted evidence boundary for the physical two-device gate. + +use std::{ + collections::BTreeSet, + fmt::{Display, Formatter}, + fs::OpenOptions, + io::Read, + net::IpAddr, + path::Path, +}; + +use serde::{Deserialize, Deserializer, Serialize, de}; +use serde_json::{Map, Value}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +const MAX_EVIDENCE_BYTES: usize = 1024 * 1024; +const EVIDENCE_SCHEMA: &[u8] = include_bytes!("../tests/fixtures/two-device-evidence.schema.json"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EvidenceError { + InvalidEvidenceFile, + InvalidJson, + DuplicateJsonField, + SchemaViolation, + ForbiddenContent, + ResultNotPass, + DuplicateIdentity, + VersionMismatch, + MetricsMismatch, + ContractPathInvalid, + AcceptanceBeforeVerification, + TimestampInvalid, + DiscoveryInvalid, + RevocationInvalid, +} + +impl Display for EvidenceError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + let code = match self { + Self::InvalidEvidenceFile => "InvalidEvidenceFile", + Self::InvalidJson => "InvalidEvidenceJson", + Self::DuplicateJsonField => "DuplicateEvidenceField", + Self::SchemaViolation => "EvidenceSchemaViolation", + Self::ForbiddenContent => "ForbiddenEvidenceContent", + Self::ResultNotPass => "EvidenceResultNotPass", + Self::DuplicateIdentity => "DuplicateEvidenceIdentity", + Self::VersionMismatch => "EvidenceVersionMismatch", + Self::MetricsMismatch => "EvidenceMetricsMismatch", + Self::ContractPathInvalid => "EvidenceContractPathInvalid", + Self::AcceptanceBeforeVerification => "EvidenceAcceptanceBeforeVerification", + Self::TimestampInvalid => "EvidenceTimestampInvalid", + Self::DiscoveryInvalid => "EvidenceDiscoveryInvalid", + Self::RevocationInvalid => "EvidenceRevocationInvalid", + }; + formatter.write_str(code) + } +} + +impl std::error::Error for EvidenceError {} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EvidenceDocument { + pub format: String, + pub schema_version: u32, + pub result: String, + pub run: EvidenceRun, + pub devices: Vec, + pub discovery: DiscoveryEvidence, + pub artifact: MetricsEvidence, + pub contracts: ContractEvidenceSet, + pub evidence: IndependentEvidence, + pub restart: RestartEvidence, + pub revocation: RevocationEvidence, + pub clock: ClockEvidence, + pub stage_timings_ms: StageTimings, + pub assertions: PhysicalAssertions, +} + +impl EvidenceDocument { + #[must_use] + pub fn device_count(&self) -> usize { + self.devices.len() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EvidenceRun { + pub started_at_utc: String, + pub finished_at_utc: String, + pub agenet_version: String, + pub git_commit: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EvidenceDevice { + pub label: String, + pub os: String, + pub architecture: String, + pub physical: bool, + pub transport: String, + pub endpoint_class: String, + pub domain_id: String, + pub node_id: String, + pub credential_signed_roles: Vec, + pub runtime_active_roles: Vec, + pub requester_local_enabled: bool, + pub process_instance_id: String, + pub certificate_sha256: String, + pub credential_format: String, + pub credential_version: String, + pub revocation_epoch: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiscoveryEvidence { + pub requester_node_id: String, + pub directory_node_id: String, + pub provider_node_id: String, + pub requester_initial_knowledge: String, + pub signed_manifests_verified: bool, + pub executor_discovered: bool, + pub verifier_discovered: bool, + pub endpoint_class: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MetricsEvidence { + #[serde(default)] + pub evidence_id: Option, + pub artifact_id: String, + pub sha256: String, + pub byte_count: u64, + pub line_count: u64, + pub non_empty_line_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContractEvidenceSet { + pub source: ContractEvidence, + pub verification: ContractEvidence, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContractEvidence { + pub contract_id: String, + pub parent_contract_id: Option, + pub capability: String, + pub state_path: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EventEvidence { + pub event_id: String, + pub kind: String, + pub timestamp_utc: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IndependentEvidence { + pub executor: MetricsEvidence, + pub verifier: MetricsEvidence, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RestartEvidence { + pub authority_loss_observed: bool, + pub authority_restored: bool, + pub identity_duplicated: bool, + pub duplicate_effects: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RevocationEvidence { + pub target_node_id: String, + pub epoch_before: u64, + pub epoch_after: u64, + pub refresh_observed: bool, + pub effect_rejected: bool, + pub health_available: bool, + pub audit_available: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClockEvidence { + pub maximum_skew_ms: u64, + pub observed_skew_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StageTimings { + pub enrollment: u64, + pub discovery: u64, + pub execution: u64, + pub verification: u64, + pub acceptance: u64, + pub restart_recovery: u64, + pub revocation: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PhysicalAssertions { + pub distinct_devices: bool, + pub distinct_nodes: bool, + pub distinct_processes: bool, + pub private_physical_transport: bool, + pub no_endpoint_preknowledge: bool, + pub secrets_scan_passed: bool, +} + +pub fn validate_schema(input: &[u8]) -> Result { + let evidence = parse_no_duplicates(input)?; + let schema: Value = + serde_json::from_slice(EVIDENCE_SCHEMA).map_err(|_| EvidenceError::InvalidJson)?; + validate_value(&schema, &evidence)?; + Ok(evidence) +} + +pub fn verify_evidence(input: &[u8]) -> Result { + if input.is_empty() || input.len() > MAX_EVIDENCE_BYTES { + return Err(EvidenceError::InvalidEvidenceFile); + } + let value = parse_no_duplicates(input)?; + scan_value(&value)?; + let schema: Value = + serde_json::from_slice(EVIDENCE_SCHEMA).map_err(|_| EvidenceError::InvalidJson)?; + validate_value(&schema, &value)?; + let evidence: EvidenceDocument = + serde_json::from_value(value).map_err(|_| EvidenceError::SchemaViolation)?; + verify_semantics(&evidence)?; + Ok(evidence) +} + +pub fn verify_evidence_path(path: &Path) -> Result { + let metadata = + std::fs::symlink_metadata(path).map_err(|_| EvidenceError::InvalidEvidenceFile)?; + if !metadata.file_type().is_file() || metadata.len() > MAX_EVIDENCE_BYTES as u64 { + return Err(EvidenceError::InvalidEvidenceFile); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = options + .open(path) + .map_err(|_| EvidenceError::InvalidEvidenceFile)?; + let opened = file + .metadata() + .map_err(|_| EvidenceError::InvalidEvidenceFile)?; + if !opened.is_file() || opened.len() > MAX_EVIDENCE_BYTES as u64 { + return Err(EvidenceError::InvalidEvidenceFile); + } + let mut bytes = Vec::with_capacity(opened.len() as usize); + file.take((MAX_EVIDENCE_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| EvidenceError::InvalidEvidenceFile)?; + if bytes.len() > MAX_EVIDENCE_BYTES { + return Err(EvidenceError::InvalidEvidenceFile); + } + verify_evidence(&bytes) +} + +fn verify_semantics(evidence: &EvidenceDocument) -> Result<(), EvidenceError> { + if evidence.result != "pass" { + return Err(EvidenceError::ResultNotPass); + } + verify_version(evidence)?; + verify_devices(evidence)?; + verify_discovery(evidence)?; + verify_metrics(evidence)?; + verify_contracts(evidence)?; + verify_revocation(evidence)?; + verify_run_bounds(evidence)?; + Ok(()) +} + +fn verify_version(evidence: &EvidenceDocument) -> Result<(), EvidenceError> { + if evidence.run.agenet_version != env!("CARGO_PKG_VERSION") + || !is_lower_hex(&evidence.run.git_commit, 40) + { + return Err(EvidenceError::VersionMismatch); + } + Ok(()) +} + +fn verify_devices(evidence: &EvidenceDocument) -> Result<(), EvidenceError> { + if evidence.devices.len() != 2 { + return Err(EvidenceError::DuplicateIdentity); + } + let a = evidence + .devices + .iter() + .find(|device| device.label == "device-a") + .ok_or(EvidenceError::DuplicateIdentity)?; + let b = evidence + .devices + .iter() + .find(|device| device.label == "device-b") + .ok_or(EvidenceError::DuplicateIdentity)?; + if a.node_id == b.node_id + || a.process_instance_id == b.process_instance_id + || a.certificate_sha256 == b.certificate_sha256 + || a.domain_id != b.domain_id + || !is_lower_hex(&a.certificate_sha256, 64) + || !is_lower_hex(&b.certificate_sha256, 64) + || !same_set(&a.credential_signed_roles, &["directory", "requester"]) + || !same_set(&a.runtime_active_roles, &["directory", "requester"]) + || !a.requester_local_enabled + || !same_set( + &b.credential_signed_roles, + &["requester", "executor", "verifier"], + ) + || !same_set(&b.runtime_active_roles, &["executor", "verifier"]) + || b.requester_local_enabled + || !active_roles_are_signed(a) + || !active_roles_are_signed(b) + || !valid_public_id(&a.domain_id) + || !valid_public_id(&a.node_id) + || !valid_public_id(&b.node_id) + || !valid_public_id(&a.process_instance_id) + || !valid_public_id(&b.process_instance_id) + { + return Err(EvidenceError::DuplicateIdentity); + } + Ok(()) +} + +fn active_roles_are_signed(device: &EvidenceDevice) -> bool { + device + .runtime_active_roles + .iter() + .all(|role| device.credential_signed_roles.contains(role)) +} + +fn verify_discovery(evidence: &EvidenceDocument) -> Result<(), EvidenceError> { + let a = &evidence.devices[device_index(evidence, "device-a")?]; + let b = &evidence.devices[device_index(evidence, "device-b")?]; + if evidence.discovery.requester_node_id != a.node_id + || evidence.discovery.directory_node_id != a.node_id + || evidence.discovery.provider_node_id != b.node_id + { + return Err(EvidenceError::DiscoveryInvalid); + } + Ok(()) +} + +fn verify_metrics(evidence: &EvidenceDocument) -> Result<(), EvidenceError> { + let artifact = &evidence.artifact; + let executor = &evidence.evidence.executor; + let verifier = &evidence.evidence.verifier; + let expected_artifact = format!("sha256:{}", artifact.sha256); + if artifact.artifact_id != expected_artifact + || !is_lower_hex(&artifact.sha256, 64) + || !metrics_equal(artifact, executor) + || !metrics_equal(artifact, verifier) + || executor.evidence_id == verifier.evidence_id + || executor.evidence_id.is_none() + || verifier.evidence_id.is_none() + { + return Err(EvidenceError::MetricsMismatch); + } + Ok(()) +} + +fn verify_contracts(evidence: &EvidenceDocument) -> Result<(), EvidenceError> { + let source = &evidence.contracts.source; + let verification = &evidence.contracts.verification; + if source.contract_id == verification.contract_id + || source.parent_contract_id.is_some() + || verification.parent_contract_id.as_ref() != Some(&source.contract_id) + || state_path(source) != ["Proposed", "Active", "Running", "Delivered", "Accepted"] + || state_path(verification) != ["Proposed", "Active", "Running", "Delivered"] + || event_kinds(source) != ["Active", "Running", "Delivered", "Accepted"] + || event_kinds(verification) != ["Active", "Running", "Delivered"] + { + return Err(EvidenceError::ContractPathInvalid); + } + let mut event_ids = BTreeSet::new(); + for event in source.events.iter().chain(&verification.events) { + if !event_ids.insert(&event.event_id) || !valid_public_id(&event.event_id) { + return Err(EvidenceError::ContractPathInvalid); + } + } + let source_times = event_times(source)?; + let verification_times = event_times(verification)?; + if !strictly_non_decreasing(&source_times) || !strictly_non_decreasing(&verification_times) { + return Err(EvidenceError::ContractPathInvalid); + } + if source_times[3] <= verification_times[2] { + return Err(EvidenceError::AcceptanceBeforeVerification); + } + Ok(()) +} + +fn verify_revocation(evidence: &EvidenceDocument) -> Result<(), EvidenceError> { + let b = &evidence.devices[device_index(evidence, "device-b")?]; + if evidence.revocation.target_node_id != b.node_id + || evidence.revocation.epoch_after <= evidence.revocation.epoch_before + || evidence + .devices + .iter() + .any(|device| device.revocation_epoch != evidence.revocation.epoch_before) + { + return Err(EvidenceError::RevocationInvalid); + } + Ok(()) +} + +fn verify_run_bounds(evidence: &EvidenceDocument) -> Result<(), EvidenceError> { + let started = parse_timestamp(&evidence.run.started_at_utc)?; + let finished = parse_timestamp(&evidence.run.finished_at_utc)?; + if started >= finished { + return Err(EvidenceError::TimestampInvalid); + } + for event in evidence + .contracts + .source + .events + .iter() + .chain(&evidence.contracts.verification.events) + { + let timestamp = parse_timestamp(&event.timestamp_utc)?; + if timestamp < started || timestamp > finished { + return Err(EvidenceError::TimestampInvalid); + } + } + Ok(()) +} + +fn device_index(evidence: &EvidenceDocument, label: &str) -> Result { + evidence + .devices + .iter() + .position(|device| device.label == label) + .ok_or(EvidenceError::DuplicateIdentity) +} + +fn metrics_equal(left: &MetricsEvidence, right: &MetricsEvidence) -> bool { + left.artifact_id == right.artifact_id + && left.sha256 == right.sha256 + && left.byte_count == right.byte_count + && left.line_count == right.line_count + && left.non_empty_line_count == right.non_empty_line_count +} + +fn event_kinds(contract: &ContractEvidence) -> Vec<&str> { + contract + .events + .iter() + .map(|event| event.kind.as_str()) + .collect() +} + +fn state_path(contract: &ContractEvidence) -> Vec<&str> { + contract.state_path.iter().map(String::as_str).collect() +} + +fn event_times(contract: &ContractEvidence) -> Result, EvidenceError> { + contract + .events + .iter() + .map(|event| parse_timestamp(&event.timestamp_utc)) + .collect() +} + +fn parse_timestamp(timestamp: &str) -> Result { + let parsed = + OffsetDateTime::parse(timestamp, &Rfc3339).map_err(|_| EvidenceError::TimestampInvalid)?; + if parsed.offset() != time::UtcOffset::UTC { + return Err(EvidenceError::TimestampInvalid); + } + Ok(parsed) +} + +fn strictly_non_decreasing(values: &[OffsetDateTime]) -> bool { + values.windows(2).all(|window| window[0] <= window[1]) +} + +fn same_set(actual: &[String], expected: &[&str]) -> bool { + actual.iter().map(String::as_str).collect::>() + == expected.iter().copied().collect::>() +} + +fn is_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn valid_public_id(value: &str) -> bool { + (8..=128).contains(&value.len()) + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'-' | b'_')) +} + +fn scan_value(value: &Value) -> Result<(), EvidenceError> { + match value { + Value::String(value) => scan_string(value), + Value::Array(values) => values.iter().try_for_each(scan_value), + Value::Object(values) => values.values().try_for_each(scan_value), + _ => Ok(()), + } +} + +fn scan_string(value: &str) -> Result<(), EvidenceError> { + let lower = value.to_ascii_lowercase(); + let forbidden = [ + "bearer ", + "authorization", + "api_key", + "apikey", + "password", + "passphrase", + "invitation secret", + "private key", + "public key", + "-----begin", + ]; + if forbidden.iter().any(|needle| lower.contains(needle)) + || value.starts_with('/') + || value.starts_with("~/") + || value.contains(":\\") + || value.parse::().is_ok() + { + return Err(EvidenceError::ForbiddenContent); + } + Ok(()) +} + +fn parse_no_duplicates(input: &[u8]) -> Result { + if input.is_empty() || input.len() > MAX_EVIDENCE_BYTES { + return Err(EvidenceError::InvalidEvidenceFile); + } + let mut deserializer = serde_json::Deserializer::from_slice(input); + let value = NoDuplicateValue::deserialize(&mut deserializer) + .map_err(|error| { + if error.to_string().contains("duplicate field") { + EvidenceError::DuplicateJsonField + } else { + EvidenceError::InvalidJson + } + })? + .0; + deserializer.end().map_err(|_| EvidenceError::InvalidJson)?; + Ok(value) +} + +struct NoDuplicateValue(Value); + +impl<'de> Deserialize<'de> for NoDuplicateValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(NoDuplicateVisitor) + } +} + +struct NoDuplicateVisitor; + +impl<'de> de::Visitor<'de> for NoDuplicateVisitor { + type Value = NoDuplicateValue; + + fn expecting(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON value without duplicate object fields") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(NoDuplicateValue(Value::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(NoDuplicateValue(Value::Number(value.into()))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(NoDuplicateValue(Value::Number(value.into()))) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + serde_json::Number::from_f64(value) + .map(Value::Number) + .map(NoDuplicateValue) + .ok_or_else(|| E::custom("invalid JSON number")) + } + + fn visit_str(self, value: &str) -> Result { + Ok(NoDuplicateValue(Value::String(value.to_owned()))) + } + + fn visit_string(self, value: String) -> Result { + Ok(NoDuplicateValue(Value::String(value))) + } + + fn visit_none(self) -> Result { + Ok(NoDuplicateValue(Value::Null)) + } + + fn visit_unit(self) -> Result { + Ok(NoDuplicateValue(Value::Null)) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + NoDuplicateValue::deserialize(deserializer) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: de::SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element::()? { + values.push(value.0); + } + Ok(NoDuplicateValue(Value::Array(values))) + } + + fn visit_map(self, mut map: A) -> Result + where + A: de::MapAccess<'de>, + { + let mut values = Map::new(); + while let Some(key) = map.next_key::()? { + if values.contains_key(&key) { + return Err(de::Error::custom("duplicate field")); + } + values.insert(key, map.next_value::()?.0); + } + Ok(NoDuplicateValue(Value::Object(values))) + } +} + +fn validate_value(schema: &Value, value: &Value) -> Result<(), EvidenceError> { + let object = schema.as_object().ok_or(EvidenceError::SchemaViolation)?; + if let Some(expected) = object.get("const") + && expected != value + { + return Err(EvidenceError::SchemaViolation); + } + if let Some(allowed) = object.get("enum").and_then(Value::as_array) + && !allowed.contains(value) + { + return Err(EvidenceError::SchemaViolation); + } + if let Some(kind) = object.get("type").and_then(Value::as_str) { + validate_type(kind, object, value)?; + } + Ok(()) +} + +fn validate_type( + kind: &str, + schema: &Map, + value: &Value, +) -> Result<(), EvidenceError> { + match kind { + "object" => validate_object(schema, value), + "array" => validate_array(schema, value), + "string" => validate_string(schema, value), + "integer" => validate_integer(schema, value), + "boolean" if value.is_boolean() => Ok(()), + "null" if value.is_null() => Ok(()), + _ => Err(EvidenceError::SchemaViolation), + } +} + +fn validate_object(schema: &Map, value: &Value) -> Result<(), EvidenceError> { + let value = value.as_object().ok_or(EvidenceError::SchemaViolation)?; + let properties = schema + .get("properties") + .and_then(Value::as_object) + .ok_or(EvidenceError::SchemaViolation)?; + let required = schema + .get("required") + .and_then(Value::as_array) + .ok_or(EvidenceError::SchemaViolation)?; + if required + .iter() + .filter_map(Value::as_str) + .any(|key| !value.contains_key(key)) + || value.keys().any(|key| !properties.contains_key(key)) + { + return Err(EvidenceError::SchemaViolation); + } + for (key, child) in value { + validate_value(&properties[key], child)?; + } + Ok(()) +} + +fn validate_array(schema: &Map, value: &Value) -> Result<(), EvidenceError> { + let values = value.as_array().ok_or(EvidenceError::SchemaViolation)?; + let minimum = schema.get("minItems").and_then(Value::as_u64).unwrap_or(0) as usize; + let maximum = schema + .get("maxItems") + .and_then(Value::as_u64) + .map_or(usize::MAX, |value| value as usize); + if values.len() < minimum || values.len() > maximum { + return Err(EvidenceError::SchemaViolation); + } + if schema.get("uniqueItems") == Some(&Value::Bool(true)) { + let mut unique = BTreeSet::new(); + for value in values { + let encoded = + serde_json::to_string(value).map_err(|_| EvidenceError::SchemaViolation)?; + if !unique.insert(encoded) { + return Err(EvidenceError::SchemaViolation); + } + } + } + let item_schema = schema.get("items").ok_or(EvidenceError::SchemaViolation)?; + values + .iter() + .try_for_each(|value| validate_value(item_schema, value)) +} + +fn validate_string(schema: &Map, value: &Value) -> Result<(), EvidenceError> { + let value = value.as_str().ok_or(EvidenceError::SchemaViolation)?; + let minimum = schema.get("minLength").and_then(Value::as_u64).unwrap_or(0) as usize; + let maximum = schema + .get("maxLength") + .and_then(Value::as_u64) + .map_or(usize::MAX, |value| value as usize); + let length = value.chars().count(); + if length < minimum || length > maximum { + return Err(EvidenceError::SchemaViolation); + } + Ok(()) +} + +fn validate_integer(schema: &Map, value: &Value) -> Result<(), EvidenceError> { + let value = value.as_u64().ok_or(EvidenceError::SchemaViolation)?; + let minimum = schema.get("minimum").and_then(Value::as_u64).unwrap_or(0); + let maximum = schema + .get("maximum") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX); + if value < minimum || value > maximum { + return Err(EvidenceError::SchemaViolation); + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 90d001f..8faf84e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod bootstrap; pub mod cli; pub mod cli_diagnostics; pub mod demo; +pub mod evidence; pub mod node; pub mod protocol; pub mod runtime; diff --git a/src/protocol/authority.rs b/src/protocol/authority.rs index 0d07032..16ac687 100644 --- a/src/protocol/authority.rs +++ b/src/protocol/authority.rs @@ -108,7 +108,7 @@ pub fn verify_credential_chain( return Err(ProtocolError::DomainMismatch); } let node = chain.node.verify(&authority_key, now_ms)?; - if node.allowed_roles == BTreeSet::from([NodeRole::Directory]) { + if node.allowed_roles.contains(&NodeRole::Directory) { validate_founding_directory_issuance(authority, &node)?; } else { validate_node_issuance(authority, &node)?; @@ -210,7 +210,9 @@ fn validate_founding_directory_issuance( if !authority.allowed_profiles.contains(&node.bootstrap_profile) { return Err(ProtocolError::BootstrapProfileNotAllowed); } - if node.allowed_roles != BTreeSet::from([NodeRole::Directory]) { + let directory_only = BTreeSet::from([NodeRole::Directory]); + let directory_requester = BTreeSet::from([NodeRole::Directory, NodeRole::Requester]); + if node.allowed_roles != directory_only && node.allowed_roles != directory_requester { return Err(ProtocolError::CredentialRoleMismatch); } if !node.capability_ceiling.is_empty() { diff --git a/src/runtime/host.rs b/src/runtime/host.rs index de2687b..e88a4c3 100644 --- a/src/runtime/host.rs +++ b/src/runtime/host.rs @@ -5,9 +5,14 @@ use std::{ time::Duration, }; -use base64::{Engine as _, engine::general_purpose::STANDARD}; +use base64::{ + Engine as _, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; use ed25519_dalek::VerifyingKey; +use serde::{Deserialize, Serialize}; use url::Url; +use zeroize::Zeroizing; use crate::{ bootstrap::{ @@ -20,12 +25,14 @@ use crate::{ transport::{ PeerClient, RevocationClient, base_router_with_revocation, directory_router_with_revocation, provider_router_with_revocation, + requester_local_control_router, requester_peer_router_with_revocation, }, }; use super::{ - AuthorityRevocationStore, Clock, ContractRecorder, DirectoryRegistry, NodeIdentity, - ProviderService, RevocationCache, RevocationGuard, RuntimeError, SystemClock, serve_peer_tls, + ArtifactAccessService, ArtifactStore, AuthorityRevocationStore, Clock, ContractRecorder, + DirectoryRegistry, LocalPursuitStore, NodeIdentity, ProviderService, RequesterService, + RevocationCache, RevocationGuard, RuntimeError, SystemClock, serve_peer_tls, }; pub struct FoundingAuthorityRuntime { @@ -138,6 +145,23 @@ pub struct HostRuntime { founding: Option, } +struct LocalControlPlan { + service: RequesterService, + token: Zeroizing, + operations: Arc, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocalControlReadyV1 { + pub format: String, + pub version: u32, + pub node_id: crate::protocol::NodeId, + pub endpoint: Url, + pub generation: uuid::Uuid, + pub started_at_ms: i64, +} + impl HostRuntime { pub async fn load(paths: &NodePaths) -> Result { let root = read_root(paths)?; @@ -171,6 +195,10 @@ impl HostRuntime { } else { None }; + let token_exists = std::fs::symlink_metadata(&paths.local_control_token_file).is_ok(); + if token_exists && !verified.allowed_roles.contains(&NodeRole::Requester) { + return Err(RuntimeError::LocalControlUnavailable); + } Ok(Self { bundle, root, @@ -190,6 +218,7 @@ impl HostRuntime { None => None, }; remove_ready(paths)?; + remove_local_control_ready(paths)?; let address = SocketAddr::new( self.bundle.config.network.bind_ip, self.bundle.config.peer_port, @@ -205,7 +234,7 @@ impl HostRuntime { let endpoint = Url::parse(&format!("https://{actual}/")) .map_err(|_| RuntimeError::UnsupportedNonLoopbackTransport)?; let handle = axum_server::Handle::new(); - let (app, registration) = self.router(paths).await?; + let (app, registration, local_control) = self.router(paths, &endpoint).await?; let tls_identity = self.bundle.tls_identity.clone(); let revocations = Arc::clone(&self.revocations); let boundary = self.bundle.config.network.clone(); @@ -246,10 +275,25 @@ impl HostRuntime { remove_ready(paths)?; return Err(error); } + let mut local_server = match local_control { + Some(plan) => match LocalControlServer::start(paths, plan, self.clock.now_ms()).await { + Ok(server) => Some(server), + Err(error) => { + handle.graceful_shutdown(Some(Duration::from_secs(1))); + let _ = server.await; + remove_ready(paths)?; + return Err(error); + } + }, + None => None, + }; if let Err(error) = write_ready(paths, &endpoint, &self.bundle.tls_identity.node_id) { handle.graceful_shutdown(Some(Duration::from_secs(1))); let _ = server.await; remove_ready(paths)?; + if let Some(server) = local_server.take() { + server.shutdown().await; + } return Err(error); } let mut server = server; @@ -261,6 +305,9 @@ impl HostRuntime { } }; remove_ready(paths)?; + if let Some(server) = local_server.take() { + server.shutdown().await; + } if let Some(servers) = authority_servers.take() { servers.shutdown().await; } @@ -270,10 +317,12 @@ impl HostRuntime { async fn router( &self, paths: &NodePaths, + peer_endpoint: &Url, ) -> Result< ( axum::Router, Option<(NodeIdentity, PeerClient, BTreeSet)>, + Option, ), RuntimeError, > { @@ -294,7 +343,60 @@ impl HostRuntime { guard, )); } - return Ok((router, None)); + let local = if self.roles.contains(&NodeRole::Requester) { + match load_local_control_token(paths)? { + Some(token) => { + let identity = self.identity(NodeRole::Requester)?; + let guard = RevocationGuard::new(self.revocations.as_ref().clone()); + let store = + ArtifactStore::open(&paths.state_dir, identity.node_id().clone())?; + let access = ArtifactAccessService::new_with_policy( + store.clone(), + self.root, + self.bundle.config.domain_id.clone(), + Arc::clone(&self.clock), + guard.clone(), + ); + let client = PeerClient::new_mtls_dynamic( + self.root, + self.bundle.config.domain_id.clone(), + Arc::clone(&self.clock), + self.bundle.config.network.clone(), + self.bundle.tls_identity.clone(), + )? + .with_local_policy(identity.clone(), guard.clone())?; + let directory_seed = self + .bundle + .config + .directory_seeds + .first() + .cloned() + .ok_or(RuntimeError::CapabilityUnavailable)?; + let service = RequesterService::new_for_external_decisions_with_policy( + identity, + store, + access, + client, + directory_seed, + peer_endpoint.as_str().trim_end_matches('/').to_owned(), + guard.clone(), + )?; + router = router.merge(requester_peer_router_with_revocation( + service.clone(), + guard, + )); + Some(LocalControlPlan { + service, + token, + operations: Arc::new(LocalPursuitStore::open(&paths.state_dir)?), + }) + } + None => None, + } + } else { + None + }; + return Ok((router, None, local)); } let provider_roles: BTreeSet<_> = self .roles @@ -313,6 +415,7 @@ impl HostRuntime { RevocationGuard::new(self.revocations.as_ref().clone()), ), None, + None, )); } let identity = self.identity(*provider_roles.iter().next().expect("non-empty"))?; @@ -350,6 +453,7 @@ impl HostRuntime { RevocationGuard::new(self.revocations.as_ref().clone()), ), Some((identity, client, provider_roles)), + None, )) } @@ -415,6 +519,170 @@ impl HostRuntime { } } +struct LocalControlServer { + shutdown: tokio::sync::watch::Sender, + task: Option>, + ready_file: std::path::PathBuf, +} + +impl LocalControlServer { + async fn start( + paths: &NodePaths, + plan: LocalControlPlan, + started_at_ms: i64, + ) -> Result { + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; + let address = listener.local_addr()?; + if !address.ip().is_loopback() { + return Err(RuntimeError::LocalControlUnavailable); + } + let endpoint = Url::parse(&format!("http://{address}/")) + .map_err(|_| RuntimeError::LocalControlUnavailable)?; + let node_id = plan.service.identity().node_id().clone(); + let app = requester_local_control_router(plan.service, plan.token, plan.operations); + let (shutdown, mut shutdown_rx) = tokio::sync::watch::channel(false); + let task = tokio::spawn(async move { + let _ = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + while !*shutdown_rx.borrow() && shutdown_rx.changed().await.is_ok() {} + }) + .await; + }); + if let Err(error) = probe_local_control(&endpoint).await { + let _ = shutdown.send(true); + task.abort(); + let _ = task.await; + return Err(error); + } + let ready = LocalControlReadyV1 { + format: "agenet.local-control-ready".to_owned(), + version: 1, + node_id, + endpoint, + generation: uuid::Uuid::new_v4(), + started_at_ms, + }; + let bytes = serde_json::to_vec(&ready)?; + if super::key_store::atomic_write_owner_only_strict( + &paths.local_control_ready_file, + &bytes, + true, + ) + .is_err() + { + let _ = shutdown.send(true); + task.abort(); + let _ = task.await; + return Err(RuntimeError::Io); + } + Ok(Self { + shutdown, + task: Some(task), + ready_file: paths.local_control_ready_file.clone(), + }) + } + + async fn shutdown(mut self) { + let _ = self.shutdown.send(true); + if let Some(task) = self.task.take() { + let _ = task.await; + } + let _ = remove_local_control_ready_path(&self.ready_file); + } +} + +impl Drop for LocalControlServer { + fn drop(&mut self) { + let _ = self.shutdown.send(true); + if let Some(task) = self.task.take() { + task.abort(); + } + let _ = remove_local_control_ready_path(&self.ready_file); + } +} + +pub fn load_local_control_ready(paths: &NodePaths) -> Result { + let bytes = paths + .read_material(&paths.local_control_ready_file, 16 * 1024) + .map_err(|_| RuntimeError::LocalControlUnavailable)?; + let ready: LocalControlReadyV1 = + serde_json::from_slice(&bytes).map_err(|_| RuntimeError::LocalControlUnavailable)?; + let host = ready.endpoint.host_str().and_then(|host| { + host.trim_matches(['[', ']']) + .parse::() + .ok() + }); + if ready.format != "agenet.local-control-ready" + || ready.version != 1 + || ready.endpoint.scheme() != "http" + || !host.is_some_and(|host| host.is_loopback()) + || ready.endpoint.port().is_none() + || ready.started_at_ms <= 0 + { + return Err(RuntimeError::LocalControlUnavailable); + } + Ok(ready) +} + +fn load_local_control_token(paths: &NodePaths) -> Result>, RuntimeError> { + match std::fs::symlink_metadata(&paths.local_control_token_file) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Ok(metadata) if metadata.file_type().is_file() => {} + Ok(_) | Err(_) => return Err(RuntimeError::LocalControlUnavailable), + } + let bytes = paths + .read_material(&paths.local_control_token_file, 128) + .map_err(|_| RuntimeError::LocalControlUnavailable)?; + let token = std::str::from_utf8(&bytes).map_err(|_| RuntimeError::LocalControlUnavailable)?; + let decoded = URL_SAFE_NO_PAD + .decode(token) + .map_err(|_| RuntimeError::LocalControlUnavailable)?; + if decoded.len() != 32 || token.len() != 43 { + return Err(RuntimeError::LocalControlUnavailable); + } + Ok(Some(Zeroizing::new(token.to_owned()))) +} + +async fn probe_local_control(endpoint: &Url) -> Result<(), RuntimeError> { + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(1)) + .timeout(Duration::from_secs(2)) + .build() + .map_err(|_| RuntimeError::LocalControlUnavailable)?; + let url = endpoint + .join("local/healthz") + .map_err(|_| RuntimeError::LocalControlUnavailable)?; + for _ in 0..40 { + if client + .get(url.clone()) + .send() + .await + .is_ok_and(|response| response.status().is_success()) + { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err(RuntimeError::LocalControlUnavailable) +} + +fn remove_local_control_ready(paths: &NodePaths) -> Result<(), RuntimeError> { + remove_local_control_ready_path(&paths.local_control_ready_file) +} + +fn remove_local_control_ready_path(path: &std::path::Path) -> Result<(), RuntimeError> { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(_) => super::key_store::remove_owner_only_user_service_file(path), + Err(_) => Err(RuntimeError::Io), + } +} + struct AuthorityServers { handles: Vec>, tasks: Vec>, @@ -671,6 +939,7 @@ fn write_ready( }; let mut metadata = crate::bootstrap::ServiceMetadataV3::empty(managed_binary); metadata.runtime_ready = true; + metadata.process_instance_id = Some(format!("process:{}", uuid::Uuid::new_v4())); metadata.node_id = Some(node_id.as_str().to_owned()); metadata.endpoint = Some(endpoint.as_str().to_owned()); let bytes = serde_json::to_vec(&metadata)?; @@ -687,6 +956,7 @@ fn remove_ready(paths: &NodePaths) -> Result<(), RuntimeError> { return super::key_store::remove_owner_only_user_service_file(&paths.service_metadata_file); }; metadata.runtime_ready = false; + metadata.process_instance_id = None; metadata.node_id = None; metadata.endpoint = None; if metadata.managed_binary.is_none() { @@ -708,3 +978,162 @@ impl From for RuntimeError { Self::InvalidStartupBundle } } + +#[cfg(test)] +pub(crate) async fn host_test_guard() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await +} + +#[cfg(test)] +mod tests { + use std::{net::IpAddr, str::FromStr, time::Duration}; + + use age::secrecy::SecretString; + use reqwest::StatusCode; + use tempfile::TempDir; + + use crate::bootstrap::{NodePathEnvironment, UserPlatform}; + + use super::*; + + fn test_paths(root: &TempDir) -> NodePaths { + NodePaths::resolve( + UserPlatform::MacOs, + &NodePathEnvironment::new( + root.path().canonicalize().expect("canonical home"), + None, + None, + ), + ) + .expect("paths") + } + + #[tokio::test] + async fn founding_runtime_publishes_and_reaps_authenticated_local_control() { + let _host_guard = super::host_test_guard().await; + let root = TempDir::new().expect("tempdir"); + let paths = test_paths(&root); + let boundary = crate::bootstrap::network::NetworkBoundary { + kind: crate::bootstrap::network::OverlayKind::Loopback, + bind_ip: IpAddr::from_str("127.0.0.1").expect("loopback"), + allowed_cidrs: vec!["127.0.0.0/8".parse().expect("cidr")], + }; + crate::cli::provision_test_domain( + paths.clone(), + boundary, + SecretString::from("local-test-passphrase".to_owned()), + ) + .expect("domain"); + let runtime = HostRuntime::load(&paths).await.expect("runtime"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let run_paths = paths.clone(); + let task = tokio::spawn(async move { + runtime + .run_until(&run_paths, async move { + let _ = shutdown_rx.await; + }) + .await + }); + + let Some(ready) = wait_local_ready(&paths).await else { + let result = task.await.expect("runtime task join"); + panic!("local control did not become ready: {result:?}"); + }; + assert_eq!(ready.endpoint.host_str(), Some("127.0.0.1")); + let metadata = crate::bootstrap::ServiceMetadataV3::parse( + &paths + .read_material(&paths.service_metadata_file, 32 * 1024) + .expect("metadata"), + ) + .expect("valid metadata"); + assert!(metadata.runtime_ready); + assert!( + metadata + .process_instance_id + .as_deref() + .is_some_and(|value| value.starts_with("process:")) + ); + + let endpoint = ready + .endpoint + .join("local/v0/pursuits") + .expect("pursuit url"); + let client = reqwest::Client::builder() + .no_proxy() + .build() + .expect("client"); + let response = client + .post(endpoint.clone()) + .bearer_auth("wrong-token") + .json(&serde_json::json!({})) + .send() + .await + .expect("wrong token response"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let token = std::str::from_utf8( + &paths + .read_material(&paths.local_control_token_file, 128) + .expect("token"), + ) + .expect("token utf8") + .to_owned(); + let response = client + .post(endpoint.clone()) + .bearer_auth(&token) + .json(&serde_json::json!({ + "operation_id": uuid::Uuid::new_v4(), + "artifact_bytes_base64": "Zg==", + "media_type": "text/x-rust", + "decision": { + "required_capability": "wrong.v1", + "acceptance_profile": "exact-source-metrics.v1", + "requires_independent_verifier": true + }, + "llm_calls": 1 + })) + .send() + .await + .expect("invalid decision response"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let operations = paths.state_dir.join("local-pursuits-v1"); + assert_eq!( + std::fs::read_dir(operations).expect("operations").count(), + 0 + ); + + let response = client + .post(endpoint) + .bearer_auth(&token) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(vec![b'x'; crate::transport::MAX_JSON_BODY_BYTES + 1]) + .send() + .await + .expect("oversized response"); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + std::fs::read_dir(paths.state_dir.join("local-pursuits-v1")) + .expect("operations") + .count(), + 0 + ); + + shutdown_tx.send(()).expect("shutdown"); + task.await.expect("join").expect("clean shutdown"); + assert!(!paths.local_control_ready_file.exists()); + assert!(!paths.service_metadata_file.exists()); + } + + async fn wait_local_ready(paths: &NodePaths) -> Option { + for _ in 0..200 { + if let Ok(ready) = load_local_control_ready(paths) { + return Some(ready); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + None + } +} diff --git a/src/runtime/local_control.rs b/src/runtime/local_control.rs new file mode 100644 index 0000000..2ee8691 --- /dev/null +++ b/src/runtime/local_control.rs @@ -0,0 +1,156 @@ +use std::{path::PathBuf, sync::Mutex}; + +use serde::{Deserialize, Serialize}; + +use super::{PursuitResult, RuntimeError}; + +const FORMAT: &str = "agenet.local-pursuit-operation"; +const VERSION: u32 = 1; +const MAX_RECORD_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalPursuitBegin { + Started, + Completed(Box), + Uncertain, + Conflict, +} + +pub struct LocalPursuitStore { + directory: PathBuf, + lock: Mutex<()>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum OperationState { + Running, + Completed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct OperationRecord { + format: String, + version: u32, + operation_id: uuid::Uuid, + request_sha256: String, + state: OperationState, + result: Option, +} + +impl LocalPursuitStore { + pub fn open(state_root: &std::path::Path) -> Result { + let directory = state_root.join("local-pursuits-v1"); + super::key_store::ensure_owner_only_dir(&directory).map_err(|_| RuntimeError::Io)?; + Ok(Self { + directory, + lock: Mutex::new(()), + }) + } + + pub fn begin( + &self, + operation_id: uuid::Uuid, + request_sha256: &str, + ) -> Result { + validate_digest(request_sha256)?; + let _guard = self.lock.lock().map_err(|_| RuntimeError::Io)?; + let path = self.path(operation_id); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_file() => { + let bytes = super::key_store::read_owner_only(&path, MAX_RECORD_BYTES)?; + let record = parse_record(&bytes, operation_id)?; + if record.request_sha256 != request_sha256 { + return Ok(LocalPursuitBegin::Conflict); + } + match (record.state, record.result) { + (OperationState::Running, None) => Ok(LocalPursuitBegin::Uncertain), + (OperationState::Completed, Some(result)) => { + Ok(LocalPursuitBegin::Completed(Box::new(result))) + } + _ => Err(RuntimeError::Serialization), + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let record = OperationRecord { + format: FORMAT.to_owned(), + version: VERSION, + operation_id, + request_sha256: request_sha256.to_owned(), + state: OperationState::Running, + result: None, + }; + write_record(&path, &record, false)?; + Ok(LocalPursuitBegin::Started) + } + Ok(_) | Err(_) => Err(RuntimeError::Io), + } + } + + pub fn complete( + &self, + operation_id: uuid::Uuid, + request_sha256: &str, + result: &PursuitResult, + ) -> Result<(), RuntimeError> { + validate_digest(request_sha256)?; + let _guard = self.lock.lock().map_err(|_| RuntimeError::Io)?; + let path = self.path(operation_id); + let bytes = super::key_store::read_owner_only(&path, MAX_RECORD_BYTES) + .map_err(|_| RuntimeError::Io)?; + let record = parse_record(&bytes, operation_id)?; + if record.request_sha256 != request_sha256 || record.state != OperationState::Running { + return Err(RuntimeError::ContractAlreadyExists); + } + write_record( + &path, + &OperationRecord { + state: OperationState::Completed, + result: Some(result.clone()), + ..record + }, + true, + ) + } + + fn path(&self, operation_id: uuid::Uuid) -> PathBuf { + self.directory.join(format!("{operation_id}.json")) + } +} + +fn parse_record(bytes: &[u8], operation_id: uuid::Uuid) -> Result { + let record: OperationRecord = + serde_json::from_slice(bytes).map_err(|_| RuntimeError::Serialization)?; + if record.format != FORMAT || record.version != VERSION || record.operation_id != operation_id { + return Err(RuntimeError::Serialization); + } + Ok(record) +} + +fn write_record( + path: &std::path::Path, + record: &OperationRecord, + replace: bool, +) -> Result<(), RuntimeError> { + let bytes = serde_json::to_vec(record).map_err(|_| RuntimeError::Serialization)?; + if bytes.len() > MAX_RECORD_BYTES { + return Err(RuntimeError::Serialization); + } + super::key_store::atomic_write_owner_only_strict(path, &bytes, replace) + .map_err(|_| RuntimeError::Io) +} + +fn validate_digest(value: &str) -> Result<(), RuntimeError> { + let Some(hex) = value.strip_prefix("sha256:") else { + return Err(RuntimeError::Serialization); + }; + if hex.len() != 64 + || !hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(RuntimeError::Serialization); + } + Ok(()) +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 2eaa2c8..a938b00 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -6,6 +6,7 @@ mod error; mod host; mod identity; pub(crate) mod key_store; +mod local_control; mod node; mod provider; mod recorder; @@ -17,13 +18,19 @@ pub use artifact_access::ArtifactAccessService; pub use clock::{Clock, FixedClock, SystemClock}; pub use directory::DirectoryRegistry; pub use error::RuntimeError; -pub use host::HostRuntime; +#[cfg(all(test, feature = "cli-test-fixture"))] +pub(crate) use host::host_test_guard; +pub use host::{HostRuntime, LocalControlReadyV1, load_local_control_ready}; pub use identity::NodeIdentity; pub use key_store::{read_signing_key, write_signing_key}; +pub use local_control::{LocalPursuitBegin, LocalPursuitStore}; pub use node::serve_peer_tls; pub use provider::ProviderService; pub use recorder::ContractRecorder; -pub use requester::{PursuitQuery, PursuitRequest, PursuitResult, RequesterService}; +pub use requester::{ + DecidedPursuitRequest, PublicEventEvidence, PursuitQuery, PursuitRequest, PursuitResult, + RequesterService, +}; pub use revocation::{ AuthorityRevocationStore, RevocationCache, RevocationCacheInspection, RevocationGuard, inspect_revocation_cache_read_only, merge_revoked_nodes, diff --git a/src/runtime/requester.rs b/src/runtime/requester.rs index 845c167..f9a88fe 100644 --- a/src/runtime/requester.rs +++ b/src/runtime/requester.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use crate::{ - adapters::{DecisionError, LlmDecisionAdapter}, + adapters::{AgentDecision, DecisionError, DecisionResult, LlmDecisionAdapter}, protocol::{ AcceptanceProfile, CandidateSet, CapabilityManifest, ContractDraft, ContractEvent, ContractId, ContractProjection, ContractProposeRequest, ContractProposeResponse, @@ -45,6 +45,32 @@ pub struct PursuitResult { pub llm_calls: u8, pub http: HttpStats, pub phase_ms: BTreeMap, + pub requester_node_id: NodeId, + pub directory_node_id: NodeId, + pub executor_node_id: NodeId, + pub verifier_node_id: NodeId, + pub discovery_mode: String, + pub source_events: Vec, + pub verification_events: Vec, + pub executor_evidence_hash: String, + pub verifier_evidence_hash: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PublicEventEvidence { + pub event_id: String, + pub kind: ContractState, + pub occurred_at_unix_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DecidedPursuitRequest { + pub operation_id: uuid::Uuid, + pub artifact_bytes_base64: String, + pub media_type: String, + pub decision: AgentDecision, + pub llm_calls: u8, } #[derive(Clone)] @@ -53,7 +79,7 @@ pub struct RequesterService { store: ArtifactStore, access: ArtifactAccessService, client: PeerClient, - decision: Arc, + decision: Option>, directory_seed: DirectorySeed, artifact_endpoint: String, pursuits: Arc>>, @@ -78,7 +104,7 @@ impl RequesterService { store, access, client, - decision: Arc::new(decision), + decision: Some(Arc::new(decision)), directory_seed: DirectorySeed { endpoint: url::Url::parse(&directory_endpoint) .map_err(|_| RuntimeError::TransportFailed)?, @@ -108,7 +134,7 @@ impl RequesterService { store, access, client, - decision: Arc::new(decision), + decision: Some(Arc::new(decision)), directory_seed, artifact_endpoint, pursuits: Arc::new(RwLock::new(HashMap::new())), @@ -141,6 +167,34 @@ impl RequesterService { Ok(service) } + #[allow(clippy::too_many_arguments)] + pub fn new_for_external_decisions_with_policy( + identity: NodeIdentity, + store: ArtifactStore, + access: ArtifactAccessService, + client: PeerClient, + directory_seed: DirectorySeed, + artifact_endpoint: String, + revocations: RevocationGuard, + ) -> Result { + if identity.role() != NodeRole::Requester { + return Err(RuntimeError::CredentialRoleMismatch); + } + let service = Self { + identity, + store, + access, + client, + decision: None, + directory_seed, + artifact_endpoint, + pursuits: Arc::new(RwLock::new(HashMap::new())), + revocations: Some(revocations), + }; + service.verify_local_effect()?; + Ok(service) + } + pub fn identity(&self) -> &NodeIdentity { &self.identity } @@ -155,17 +209,59 @@ impl RequesterService { let bytes = STANDARD .decode(request.artifact_bytes_base64) .map_err(|_| RuntimeError::ArtifactIntegrityMismatch)?; - let artifact = self.store.import(&bytes, &request.media_type)?; - let mut phase_ms = BTreeMap::new(); - phase_ms.insert("artifact_import".to_owned(), elapsed_ms(started)); - let decision_started = Instant::now(); let decision = self .decision + .as_ref() + .ok_or(RuntimeError::DecisionFailed)? .decide(&request.goal) .await .map_err(map_decision_error)?; - phase_ms.insert("llm_decision".to_owned(), elapsed_ms(decision_started)); + self.pursue_after_decision( + request.media_type, + bytes, + decision, + elapsed_ms(decision_started), + started, + ) + .await + } + + pub async fn pursue_decided( + &self, + request: DecidedPursuitRequest, + ) -> Result { + self.verify_local_effect()?; + validate_external_decision(&request.decision, request.llm_calls)?; + let bytes = STANDARD + .decode(request.artifact_bytes_base64) + .map_err(|_| RuntimeError::ArtifactIntegrityMismatch)?; + self.pursue_after_decision( + request.media_type, + bytes, + DecisionResult { + decision: request.decision, + llm_calls: request.llm_calls, + }, + 0, + Instant::now(), + ) + .await + } + + async fn pursue_after_decision( + &self, + media_type: String, + bytes: Vec, + decision: DecisionResult, + decision_ms: u64, + started: Instant, + ) -> Result { + let import_started = Instant::now(); + let artifact = self.store.import(&bytes, &media_type)?; + let mut phase_ms = BTreeMap::new(); + phase_ms.insert("artifact_import".to_owned(), elapsed_ms(import_started)); + phase_ms.insert("llm_decision".to_owned(), decision_ms); let intent_id = IntentId::new(format!("intent:{}", uuid::Uuid::new_v4()))?; let route_started = Instant::now(); @@ -212,16 +308,21 @@ impl RequesterService { phase_ms.insert("verifier_contract".to_owned(), elapsed_ms(verifier_started)); let accept_started = Instant::now(); - self.accept( - &executor, - &source_projection, - &verification_contract, - &verifier_evidence, - ) - .await?; + let accepted_event = self + .accept( + &executor, + &source_projection, + &verification_contract, + &verifier_evidence, + ) + .await?; phase_ms.insert("acceptance".to_owned(), elapsed_ms(accept_started)); phase_ms.insert("total".to_owned(), elapsed_ms(started)); + let executor_evidence_hash = evidence_hash(&executor_evidence)?; + let verifier_evidence_hash = evidence_hash(&verifier_evidence)?; + let mut source_events = public_events(&source_projection.events); + source_events.push(public_event(&accepted_event)); let result = PursuitResult { intent_id, source_contract_id: source_contract, @@ -239,6 +340,15 @@ impl RequesterService { llm_calls: decision.llm_calls, http: self.client.stats(), phase_ms, + requester_node_id: self.identity.node_id().clone(), + directory_node_id: self.directory_seed.node_id.clone(), + executor_node_id: executor.provider, + verifier_node_id: verifier.provider, + discovery_mode: "signed_directory_manifests".to_owned(), + source_events, + verification_events: public_events(&verification_projection.events), + executor_evidence_hash, + verifier_evidence_hash, }; self.pursuits .write() @@ -399,7 +509,7 @@ impl RequesterService { source: &ContractProjection, verification_contract_id: &ContractId, evidence: &EvidenceClaim, - ) -> Result { + ) -> Result { self.verify_local_effect()?; let event = ContractEvent { contract_id: source.draft.contract_id.clone(), @@ -432,7 +542,7 @@ impl RequesterService { if response.get("state") != Some(&serde_json::json!(ContractState::Accepted)) { return Err(RuntimeError::VerificationFailed); } - Ok(ContractState::Accepted) + Ok(event) } fn verify_local_effect(&self) -> Result<(), RuntimeError> { @@ -444,6 +554,22 @@ impl RequesterService { } } +impl DecidedPursuitRequest { + pub(crate) fn validate(&self) -> Result<(), RuntimeError> { + validate_external_decision(&self.decision, self.llm_calls)?; + let bytes = STANDARD + .decode(&self.artifact_bytes_base64) + .map_err(|_| RuntimeError::ArtifactIntegrityMismatch)?; + if bytes.len() > super::MAX_ARTIFACT_BYTES + || self.media_type.is_empty() + || self.media_type.len() > 128 + { + return Err(RuntimeError::ArtifactTooLarge); + } + Ok(()) + } +} + fn provider_role(manifest: &CapabilityManifest) -> Result { let kind = crate::protocol::CapabilityKind::new(manifest.capability_kind_version())?; crate::protocol::validate_capability_binding(&manifest.capability_id, &kind) @@ -459,6 +585,40 @@ fn delivered_evidence(projection: &ContractProjection) -> Result Result<(), RuntimeError> { + if decision.required_capability != crate::protocol::SOURCE_METRICS_EXECUTOR_KIND + || decision.acceptance_profile != "exact-source-metrics.v1" + || !decision.requires_independent_verifier + || !(1..=2).contains(&llm_calls) + { + return Err(RuntimeError::AgentDecisionInvalid); + } + Ok(()) +} + +fn public_events(events: &[ContractEvent]) -> Vec { + events.iter().map(public_event).collect() +} + +fn public_event(event: &ContractEvent) -> PublicEventEvidence { + let kind = match event.kind { + EventKind::Activated => ContractState::Active, + EventKind::Started => ContractState::Running, + EventKind::Delivered => ContractState::Delivered, + EventKind::Accepted => ContractState::Accepted, + EventKind::VerificationFailed => ContractState::Delivered, + EventKind::Failed => ContractState::Failed, + }; + PublicEventEvidence { + event_id: event.event_id.clone(), + kind, + occurred_at_unix_ms: event.occurred_at_unix_ms, + } +} + fn evidence_hash(evidence: &EvidenceClaim) -> Result { use sha2::{Digest, Sha256}; let bytes = serde_json::to_vec(evidence)?; @@ -486,3 +646,30 @@ fn map_decision_error(error: DecisionError) -> RuntimeError { DecisionError::AgentDecisionInvalid => RuntimeError::AgentDecisionInvalid, } } + +#[cfg(test)] +mod tests { + use crate::adapters::AgentDecision; + + use super::validate_external_decision; + + #[test] + fn external_decision_accepts_only_the_fixed_verified_source_metrics_shape() { + let valid = AgentDecision { + required_capability: "source.metrics.v1".to_owned(), + acceptance_profile: "exact-source-metrics.v1".to_owned(), + requires_independent_verifier: true, + }; + assert!(validate_external_decision(&valid, 1).is_ok()); + assert!(validate_external_decision(&valid, 2).is_ok()); + + let mut wrong_capability = valid.clone(); + wrong_capability.required_capability = "project.build.v1".to_owned(); + assert!(validate_external_decision(&wrong_capability, 1).is_err()); + let mut no_verifier = valid.clone(); + no_verifier.requires_independent_verifier = false; + assert!(validate_external_decision(&no_verifier, 1).is_err()); + assert!(validate_external_decision(&valid, 0).is_err()); + assert!(validate_external_decision(&valid, 3).is_err()); + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 0fe52d1..e85bc1e 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -14,7 +14,8 @@ pub use enrollment::{ pub(crate) use lifecycle::lifecycle_authority_router; pub use node::{ artifact_router, artifact_router_with_revocation, base_router_with_revocation, provider_router, - provider_router_with_revocation, requester_router, requester_router_with_revocation, + provider_router_with_revocation, requester_local_control_router, + requester_peer_router_with_revocation, requester_router, requester_router_with_revocation, }; pub use revocation::{RevocationClient, RevocationTransportError, authority_revocation_router}; pub(crate) use tls::validate_persisted_peer_identity; diff --git a/src/transport/node.rs b/src/transport/node.rs index 3d0c56a..1322a70 100644 --- a/src/transport/node.rs +++ b/src/transport/node.rs @@ -1,22 +1,24 @@ -use std::sync::Arc; +use std::{net::SocketAddr, sync::Arc}; use axum::{ Json, Router, - extract::{DefaultBodyLimit, State, rejection::JsonRejection}, + extract::{ConnectInfo, DefaultBodyLimit, State, rejection::JsonRejection}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde_json::json; +use zeroize::Zeroizing; use crate::{ protocol::{ ArtifactReadRequest, ContractProposeRequest, ContractQuery, ErrorEnvelope, WireEnvelope, }, runtime::{ - ArtifactAccessService, NodeIdentity, ProviderService, PursuitQuery, PursuitRequest, - RequesterService, RevocationGuard, RuntimeError, + ArtifactAccessService, DecidedPursuitRequest, LocalPursuitBegin, LocalPursuitStore, + NodeIdentity, ProviderService, PursuitQuery, PursuitRequest, RequesterService, + RevocationGuard, RuntimeError, }, }; @@ -246,12 +248,130 @@ fn artifact_router_inner( })) } +pub fn requester_peer_router_with_revocation( + service: RequesterService, + revocations: RevocationGuard, +) -> Router { + Router::new() + .route("/v0/artifacts/read", post(artifact_read)) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .layer(axum::middleware::from_fn( + super::tls::enforce_tls_envelope_binding, + )) + .with_state(Arc::new(ArtifactHttpState { + identity: service.identity().clone(), + access: service.access().clone(), + revocations: Some(revocations), + })) +} + #[derive(Clone)] struct RequesterHttpState { service: RequesterService, control_token: Arc, } +#[derive(Clone)] +struct LocalControlHttpState { + service: RequesterService, + control_token: Arc>, + operations: Arc, +} + +pub fn requester_local_control_router( + service: RequesterService, + control_token: Zeroizing, + operations: Arc, +) -> Router { + Router::new() + .route("/local/healthz", get(local_health)) + .route("/local/v0/pursuits", post(local_decided_pursuit)) + .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) + .with_state(Arc::new(LocalControlHttpState { + service, + control_token: Arc::new(control_token), + operations, + })) +} + +async fn local_health(ConnectInfo(peer): ConnectInfo) -> Response { + if !peer.ip().is_loopback() { + return error(StatusCode::FORBIDDEN, "LocalControlLoopbackRequired"); + } + (StatusCode::OK, Json(json!({"status":"ok"}))).into_response() +} + +async fn local_decided_pursuit( + ConnectInfo(peer): ConnectInfo, + State(state): State>, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Response { + if !peer.ip().is_loopback() { + return error(StatusCode::FORBIDDEN, "LocalControlLoopbackRequired"); + } + if !valid_bearer(&headers, state.control_token.as_str()) { + return error(StatusCode::UNAUTHORIZED, "InvalidControlToken"); + } + let Json(request) = match payload { + Ok(request) => request, + Err(rejection) if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE => { + return error(StatusCode::PAYLOAD_TOO_LARGE, "RequestBodyTooLarge"); + } + Err(_) => return error(StatusCode::BAD_REQUEST, "InvalidPursuitRequest"), + }; + if request.validate().is_err() { + return error(StatusCode::BAD_REQUEST, "InvalidPursuitRequest"); + } + let digest = match request_digest(&request) { + Ok(digest) => digest, + Err(_) => return error(StatusCode::BAD_REQUEST, "InvalidPursuitRequest"), + }; + match state.operations.begin(request.operation_id, &digest) { + Ok(LocalPursuitBegin::Completed(result)) => { + return (StatusCode::OK, Json(*result)).into_response(); + } + Ok(LocalPursuitBegin::Conflict) => { + return error(StatusCode::CONFLICT, "PursuitOperationConflict"); + } + Ok(LocalPursuitBegin::Uncertain) => { + return error(StatusCode::CONFLICT, "PursuitOperationUncertain"); + } + Ok(LocalPursuitBegin::Started) => {} + Err(_) => return error(StatusCode::INTERNAL_SERVER_ERROR, "PursuitStateUnavailable"), + } + let operation_id = request.operation_id; + match state.service.pursue_decided(request).await { + Ok(result) => { + if state + .operations + .complete(operation_id, &digest, &result) + .is_err() + { + return error(StatusCode::INTERNAL_SERVER_ERROR, "PursuitStateUnavailable"); + } + (StatusCode::OK, Json(result)).into_response() + } + Err(error_code) => { + tracing::warn!(code = ?error_code, "pursuit failed"); + error(StatusCode::UNPROCESSABLE_ENTITY, "PursuitFailed") + } + } +} + +fn request_digest(request: &DecidedPursuitRequest) -> Result { + use sha2::{Digest, Sha256}; + let bytes = serde_json::to_vec(request).map_err(|_| RuntimeError::Serialization)?; + let digest = Sha256::digest(bytes); + Ok(format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + pub fn requester_router(service: RequesterService, control_token: String) -> Router { let artifact_routes = artifact_router(service.identity().clone(), service.access().clone()); requester_router_inner(service, control_token, artifact_routes) @@ -333,7 +453,18 @@ fn valid_bearer(headers: &HeaderMap, expected: &str) -> bool { .get(axum::http::header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")) - == Some(expected) + .is_some_and(|provided| constant_time_equal(provided.as_bytes(), expected.as_bytes())) +} + +fn constant_time_equal(provided: &[u8], expected: &[u8]) -> bool { + let mut difference = provided.len() ^ expected.len(); + let length = provided.len().max(expected.len()); + for index in 0..length { + difference |= usize::from( + provided.get(index).copied().unwrap_or(0) ^ expected.get(index).copied().unwrap_or(0), + ); + } + difference == 0 } async fn artifact_read( @@ -445,3 +576,17 @@ fn error(status: StatusCode, code: &str) -> Response { ) .into_response() } + +#[cfg(test)] +mod tests { + use super::constant_time_equal; + + #[test] + fn local_control_token_comparison_rejects_length_and_bit_changes() { + let expected = b"0123456789abcdef"; + assert!(constant_time_equal(expected, expected)); + assert!(!constant_time_equal(b"0123456789abcdee", expected)); + assert!(!constant_time_equal(b"0123456789abcdef0", expected)); + assert!(!constant_time_equal(b"0123456789abcde", expected)); + } +} diff --git a/tests/fixtures/two-device-evidence.schema.json b/tests/fixtures/two-device-evidence.schema.json new file mode 100644 index 0000000..8984ff6 --- /dev/null +++ b/tests/fixtures/two-device-evidence.schema.json @@ -0,0 +1,279 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nexa-language.github.io/AgenNet/schemas/two-device-evidence-v1.json", + "title": "AgenNet two-device physical acceptance evidence v1", + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "schema_version", + "result", + "run", + "devices", + "discovery", + "artifact", + "contracts", + "evidence", + "restart", + "revocation", + "clock", + "stage_timings_ms", + "assertions" + ], + "properties": { + "format": { "const": "agenet.two-device-acceptance" }, + "schema_version": { "const": 1 }, + "result": { "enum": ["pass", "fail"] }, + "run": { + "type": "object", + "additionalProperties": false, + "required": ["started_at_utc", "finished_at_utc", "agenet_version", "git_commit"], + "properties": { + "started_at_utc": { "type": "string", "minLength": 20, "maxLength": 32 }, + "finished_at_utc": { "type": "string", "minLength": 20, "maxLength": 32 }, + "agenet_version": { "type": "string", "minLength": 1, "maxLength": 32 }, + "git_commit": { "type": "string", "minLength": 40, "maxLength": 40 } + } + }, + "devices": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "label", "os", "architecture", "physical", "transport", + "endpoint_class", "domain_id", "node_id", "credential_signed_roles", + "runtime_active_roles", "requester_local_enabled", + "process_instance_id", "certificate_sha256", "credential_format", + "credential_version", "revocation_epoch" + ], + "properties": { + "label": { "enum": ["device-a", "device-b"] }, + "os": { "enum": ["macos", "linux"] }, + "architecture": { "enum": ["aarch64", "x86_64"] }, + "physical": { "const": true }, + "transport": { "enum": ["tailscale", "wireguard"] }, + "endpoint_class": { "const": "private-overlay" }, + "domain_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "node_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "credential_signed_roles": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "uniqueItems": true, + "items": { "enum": ["directory", "requester", "executor", "verifier"] } + }, + "runtime_active_roles": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "uniqueItems": true, + "items": { "enum": ["directory", "requester", "executor", "verifier"] } + }, + "requester_local_enabled": { "type": "boolean" }, + "process_instance_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "certificate_sha256": { "type": "string", "minLength": 64, "maxLength": 64 }, + "credential_format": { "const": "agenet.node-credential" }, + "credential_version": { "const": "v0.3" }, + "revocation_epoch": { "type": "integer", "minimum": 1 } + } + } + }, + "discovery": { + "type": "object", + "additionalProperties": false, + "required": [ + "requester_node_id", "directory_node_id", "provider_node_id", + "requester_initial_knowledge", "signed_manifests_verified", + "executor_discovered", "verifier_discovered", "endpoint_class" + ], + "properties": { + "requester_node_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "directory_node_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "provider_node_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "requester_initial_knowledge": { "const": "directory-seeds-only" }, + "signed_manifests_verified": { "const": true }, + "executor_discovered": { "const": true }, + "verifier_discovered": { "const": true }, + "endpoint_class": { "const": "private-overlay" } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["artifact_id", "sha256", "byte_count", "line_count", "non_empty_line_count"], + "properties": { + "artifact_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "sha256": { "type": "string", "minLength": 64, "maxLength": 64 }, + "byte_count": { "type": "integer", "minimum": 0, "maximum": 65536 }, + "line_count": { "type": "integer", "minimum": 0, "maximum": 65536 }, + "non_empty_line_count": { "type": "integer", "minimum": 0, "maximum": 65536 } + } + }, + "contracts": { + "type": "object", + "additionalProperties": false, + "required": ["source", "verification"], + "properties": { + "source": { + "type": "object", + "additionalProperties": false, + "required": ["contract_id", "parent_contract_id", "capability", "state_path", "events"], + "properties": { + "contract_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "parent_contract_id": { "type": "null" }, + "capability": { "const": "source.metrics.v1" }, + "state_path": { + "type": "array", + "minItems": 5, + "maxItems": 5, + "items": { "enum": ["Proposed", "Active", "Running", "Delivered", "Accepted"] } + }, + "events": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["event_id", "kind", "timestamp_utc"], + "properties": { + "event_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "kind": { "enum": ["Active", "Running", "Delivered", "Accepted"] }, + "timestamp_utc": { "type": "string", "minLength": 20, "maxLength": 32 } + } + } + } + } + }, + "verification": { + "type": "object", + "additionalProperties": false, + "required": ["contract_id", "parent_contract_id", "capability", "state_path", "events"], + "properties": { + "contract_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "parent_contract_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "capability": { "const": "source.metrics.verify.v1" }, + "state_path": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { "enum": ["Proposed", "Active", "Running", "Delivered"] } + }, + "events": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["event_id", "kind", "timestamp_utc"], + "properties": { + "event_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "kind": { "enum": ["Active", "Running", "Delivered"] }, + "timestamp_utc": { "type": "string", "minLength": 20, "maxLength": 32 } + } + } + } + } + } + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["executor", "verifier"], + "properties": { + "executor": { + "type": "object", + "additionalProperties": false, + "required": ["evidence_id", "artifact_id", "sha256", "byte_count", "line_count", "non_empty_line_count"], + "properties": { + "evidence_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "artifact_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "sha256": { "type": "string", "minLength": 64, "maxLength": 64 }, + "byte_count": { "type": "integer", "minimum": 0, "maximum": 65536 }, + "line_count": { "type": "integer", "minimum": 0, "maximum": 65536 }, + "non_empty_line_count": { "type": "integer", "minimum": 0, "maximum": 65536 } + } + }, + "verifier": { + "type": "object", + "additionalProperties": false, + "required": ["evidence_id", "artifact_id", "sha256", "byte_count", "line_count", "non_empty_line_count"], + "properties": { + "evidence_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "artifact_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "sha256": { "type": "string", "minLength": 64, "maxLength": 64 }, + "byte_count": { "type": "integer", "minimum": 0, "maximum": 65536 }, + "line_count": { "type": "integer", "minimum": 0, "maximum": 65536 }, + "non_empty_line_count": { "type": "integer", "minimum": 0, "maximum": 65536 } + } + } + } + }, + "restart": { + "type": "object", + "additionalProperties": false, + "required": ["authority_loss_observed", "authority_restored", "identity_duplicated", "duplicate_effects"], + "properties": { + "authority_loss_observed": { "const": true }, + "authority_restored": { "const": true }, + "identity_duplicated": { "const": false }, + "duplicate_effects": { "const": false } + } + }, + "revocation": { + "type": "object", + "additionalProperties": false, + "required": ["target_node_id", "epoch_before", "epoch_after", "refresh_observed", "effect_rejected", "health_available", "audit_available"], + "properties": { + "target_node_id": { "type": "string", "minLength": 8, "maxLength": 128 }, + "epoch_before": { "type": "integer", "minimum": 1 }, + "epoch_after": { "type": "integer", "minimum": 2 }, + "refresh_observed": { "const": true }, + "effect_rejected": { "const": true }, + "health_available": { "const": true }, + "audit_available": { "const": true } + } + }, + "clock": { + "type": "object", + "additionalProperties": false, + "required": ["maximum_skew_ms", "observed_skew_ms"], + "properties": { + "maximum_skew_ms": { "const": 30000 }, + "observed_skew_ms": { "type": "integer", "minimum": 0, "maximum": 30000 } + } + }, + "stage_timings_ms": { + "type": "object", + "additionalProperties": false, + "required": ["enrollment", "discovery", "execution", "verification", "acceptance", "restart_recovery", "revocation"], + "properties": { + "enrollment": { "type": "integer", "minimum": 0 }, + "discovery": { "type": "integer", "minimum": 0 }, + "execution": { "type": "integer", "minimum": 0 }, + "verification": { "type": "integer", "minimum": 0 }, + "acceptance": { "type": "integer", "minimum": 0 }, + "restart_recovery": { "type": "integer", "minimum": 0 }, + "revocation": { "type": "integer", "minimum": 0 } + } + }, + "assertions": { + "type": "object", + "additionalProperties": false, + "required": ["distinct_devices", "distinct_nodes", "distinct_processes", "private_physical_transport", "no_endpoint_preknowledge", "secrets_scan_passed"], + "properties": { + "distinct_devices": { "const": true }, + "distinct_nodes": { "const": true }, + "distinct_processes": { "const": true }, + "private_physical_transport": { "const": true }, + "no_endpoint_preknowledge": { "const": true }, + "secrets_scan_passed": { "const": true } + } + } + } +} diff --git a/tests/fixtures/two-device-evidence.synthetic-template.json b/tests/fixtures/two-device-evidence.synthetic-template.json new file mode 100644 index 0000000..13e444e --- /dev/null +++ b/tests/fixtures/two-device-evidence.synthetic-template.json @@ -0,0 +1,145 @@ +{ + "format": "agenet.two-device-acceptance", + "schema_version": 1, + "result": "fail", + "run": { + "started_at_utc": "2026-08-15T01:00:00Z", + "finished_at_utc": "2026-08-15T01:01:00Z", + "agenet_version": "0.2.0", + "git_commit": "1111111111111111111111111111111111111111" + }, + "devices": [ + { + "label": "device-a", + "os": "macos", + "architecture": "aarch64", + "physical": true, + "transport": "tailscale", + "endpoint_class": "private-overlay", + "domain_id": "domain:public-demo", + "node_id": "node:public-device-a", + "credential_signed_roles": ["directory", "requester"], + "runtime_active_roles": ["directory", "requester"], + "requester_local_enabled": true, + "process_instance_id": "process:public-device-a", + "certificate_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "credential_format": "agenet.node-credential", + "credential_version": "v0.3", + "revocation_epoch": 1 + }, + { + "label": "device-b", + "os": "linux", + "architecture": "x86_64", + "physical": true, + "transport": "tailscale", + "endpoint_class": "private-overlay", + "domain_id": "domain:public-demo", + "node_id": "node:public-device-b", + "credential_signed_roles": ["requester", "executor", "verifier"], + "runtime_active_roles": ["executor", "verifier"], + "requester_local_enabled": false, + "process_instance_id": "process:public-device-b", + "certificate_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "credential_format": "agenet.node-credential", + "credential_version": "v0.3", + "revocation_epoch": 1 + } + ], + "discovery": { + "requester_node_id": "node:public-device-a", + "directory_node_id": "node:public-device-a", + "provider_node_id": "node:public-device-b", + "requester_initial_knowledge": "directory-seeds-only", + "signed_manifests_verified": true, + "executor_discovered": true, + "verifier_discovered": true, + "endpoint_class": "private-overlay" + }, + "artifact": { + "artifact_id": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "byte_count": 32, + "line_count": 2, + "non_empty_line_count": 2 + }, + "contracts": { + "source": { + "contract_id": "contract:public-source", + "parent_contract_id": null, + "capability": "source.metrics.v1", + "state_path": ["Proposed", "Active", "Running", "Delivered", "Accepted"], + "events": [ + { "event_id": "event:source-active", "kind": "Active", "timestamp_utc": "2026-08-15T01:00:11Z" }, + { "event_id": "event:source-running", "kind": "Running", "timestamp_utc": "2026-08-15T01:00:12Z" }, + { "event_id": "event:source-delivered", "kind": "Delivered", "timestamp_utc": "2026-08-15T01:00:13Z" }, + { "event_id": "event:source-accepted", "kind": "Accepted", "timestamp_utc": "2026-08-15T01:00:17Z" } + ] + }, + "verification": { + "contract_id": "contract:public-verification", + "parent_contract_id": "contract:public-source", + "capability": "source.metrics.verify.v1", + "state_path": ["Proposed", "Active", "Running", "Delivered"], + "events": [ + { "event_id": "event:verify-active", "kind": "Active", "timestamp_utc": "2026-08-15T01:00:15Z" }, + { "event_id": "event:verify-running", "kind": "Running", "timestamp_utc": "2026-08-15T01:00:16Z" }, + { "event_id": "event:verify-delivered", "kind": "Delivered", "timestamp_utc": "2026-08-15T01:00:16Z" } + ] + } + }, + "evidence": { + "executor": { + "evidence_id": "evidence:public-executor", + "artifact_id": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "byte_count": 32, + "line_count": 2, + "non_empty_line_count": 2 + }, + "verifier": { + "evidence_id": "evidence:public-verifier", + "artifact_id": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "byte_count": 32, + "line_count": 2, + "non_empty_line_count": 2 + } + }, + "restart": { + "authority_loss_observed": true, + "authority_restored": true, + "identity_duplicated": false, + "duplicate_effects": false + }, + "revocation": { + "target_node_id": "node:public-device-b", + "epoch_before": 1, + "epoch_after": 2, + "refresh_observed": true, + "effect_rejected": true, + "health_available": true, + "audit_available": true + }, + "clock": { + "maximum_skew_ms": 30000, + "observed_skew_ms": 10 + }, + "stage_timings_ms": { + "enrollment": 100, + "discovery": 100, + "execution": 100, + "verification": 100, + "acceptance": 100, + "restart_recovery": 100, + "revocation": 100 + }, + "assertions": { + "distinct_devices": true, + "distinct_nodes": true, + "distinct_processes": true, + "private_physical_transport": true, + "no_endpoint_preknowledge": true, + "secrets_scan_passed": true + } +} diff --git a/tests/invitation_store.rs b/tests/invitation_store.rs index 6badf84..026afdb 100644 --- a/tests/invitation_store.rs +++ b/tests/invitation_store.rs @@ -61,6 +61,173 @@ fn create_invitation(store: &InvitationStore) -> InvitationHandoff { store.create(spec(), NOW_MS).expect("invitation is created") } +#[test] +fn long_lived_store_refreshes_invitation_created_by_another_instance() { + let temp = TempDir::new().expect("temporary directory"); + let authority_store = create_store(&temp); + let cli_store = create_store(&temp); + let handoff = create_invitation(&cli_store); + let operation = Uuid::new_v4(); + + assert_eq!( + authority_store + .reserve( + handoff.public_claims(), + handoff.authentication(), + operation, + NOW_MS + 1, + ) + .expect("stale Authority projection refreshes"), + ReservationStatus::Reserved + ); + authority_store + .consume( + handoff.public_claims().invitation_id, + operation, + NodeId::new("node:live-refresh").expect("node"), + NOW_MS + 2, + ) + .expect("consumption remains serialized"); + assert!(matches!( + cli_store + .record(handoff.public_claims().invitation_id) + .expect("creator refreshes") + .expect("record") + .state, + InvitationState::Consumed { .. } + )); +} + +#[test] +fn concurrent_store_instances_create_unique_invitations() { + let temp = TempDir::new().expect("temporary directory"); + let stores: Vec<_> = (0..8).map(|_| create_store(&temp)).collect(); + let barrier = Arc::new(Barrier::new(stores.len())); + let handles: Vec<_> = stores + .into_iter() + .map(|store| { + let barrier = Arc::clone(&barrier); + thread::spawn(move || { + barrier.wait(); + create_invitation(&store).public_claims().invitation_id + }) + }) + .collect(); + let ids: BTreeSet<_> = handles + .into_iter() + .map(|handle| handle.join().expect("creator")) + .collect(); + assert_eq!(ids.len(), 8); + let replay = create_store(&temp); + assert!( + ids.into_iter() + .all(|id| replay.record(id).expect("record").is_some()) + ); +} + +#[test] +fn independent_store_redeem_race_has_one_winner() { + let temp = TempDir::new().expect("temporary directory"); + let creator = create_store(&temp); + let handoff = create_invitation(&creator); + let stores = [create_store(&temp), create_store(&temp)]; + let barrier = Barrier::new(2); + let results = thread::scope(|scope| { + let handles: Vec<_> = stores + .iter() + .map(|store| { + let handoff = &handoff; + let barrier = &barrier; + scope.spawn(move || { + barrier.wait(); + store.reserve( + handoff.public_claims(), + handoff.authentication(), + Uuid::new_v4(), + NOW_MS + 1, + ) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().expect("redeemer")) + .collect::>() + }); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(BootstrapError::InvitationUnavailable))) + .count(), + 1 + ); +} + +#[test] +fn long_lived_store_rejects_journal_or_operation_lock_replacement() { + let journal_temp = TempDir::new().expect("temporary directory"); + let journal_store = create_store(&journal_temp); + let handoff = create_invitation(&journal_store); + let journal = journal_temp.path().join(JOURNAL_FILE); + let replacement = journal_temp.path().join("replacement-journal"); + fs::rename(&journal, &replacement).expect("move original journal"); + fs::copy(&replacement, &journal).expect("publish different inode"); + fs::set_permissions(&journal, fs::Permissions::from_mode(0o600)).expect("journal mode"); + assert_eq!( + journal_store.record(handoff.public_claims().invitation_id), + Err(BootstrapError::InvalidStatePath) + ); + + let lock_temp = TempDir::new().expect("temporary directory"); + let lock_store = create_store(&lock_temp); + let lock_handoff = create_invitation(&lock_store); + let lock = lock_temp.path().join("invitation.operation.lock"); + let target = lock_temp.path().join("lock-target"); + fs::write(&target, b"not a lock").expect("target"); + fs::remove_file(&lock).expect("remove lock"); + symlink(&target, &lock).expect("replace lock with symlink"); + assert_eq!( + lock_store.reserve( + lock_handoff.public_claims(), + lock_handoff.authentication(), + Uuid::new_v4(), + NOW_MS + 1, + ), + Err(BootstrapError::InvalidStatePath) + ); +} + +#[cfg(feature = "cli-test-fixture")] +#[test] +fn crashed_lock_holder_releases_cross_process_operation_lock() { + let temp = TempDir::new().expect("temporary directory"); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)).expect("mode"); + let executable = std::env::current_exe().expect("test executable"); + let output = std::process::Command::new(executable) + .arg("crashed_invitation_lock_holder_child") + .arg("--exact") + .arg("--nocapture") + .env("AGENET_INVITATION_LOCK_CRASH_CHILD", temp.path()) + .output() + .expect("child runs"); + assert!(!output.status.success(), "child must terminate abnormally"); + let store = create_store(&temp); + create_invitation(&store); +} + +#[cfg(feature = "cli-test-fixture")] +#[test] +fn crashed_invitation_lock_holder_child() { + let Some(path) = std::env::var_os("AGENET_INVITATION_LOCK_CRASH_CHILD") else { + return; + }; + let _guard = + agenet::bootstrap::acquire_invitation_operation_for_test(std::path::Path::new(&path)) + .expect("lock acquired"); + std::process::abort(); +} + fn reserve_handoff( store: &InvitationStore, handoff: &InvitationHandoff, diff --git a/tests/local_control.rs b/tests/local_control.rs new file mode 100644 index 0000000..ed73d37 --- /dev/null +++ b/tests/local_control.rs @@ -0,0 +1,77 @@ +use agenet::runtime::{LocalPursuitBegin, LocalPursuitStore, PursuitResult}; + +const FIRST: &str = "sha256:1111111111111111111111111111111111111111111111111111111111111111"; +const OTHER: &str = "sha256:2222222222222222222222222222222222222222222222222222222222222222"; + +fn result() -> PursuitResult { + serde_json::from_value(serde_json::json!({ + "intent_id": "intent:public-test", + "source_contract_id": "contract:public-source", + "verification_contract_id": "contract:public-verification", + "state_path": ["proposed", "active", "running", "delivered", "accepted"], + "artifact_id": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "executor_metrics": {"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","byte_count":1,"line_count":1,"non_empty_line_count":1}, + "verifier_metrics": {"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","byte_count":1,"line_count":1,"non_empty_line_count":1}, + "llm_calls": 1, + "http": {"requests":1,"bytes_sent":1,"bytes_received":1}, + "phase_ms": {}, + "requester_node_id":"node:requester", + "directory_node_id":"node:directory", + "executor_node_id":"node:executor", + "verifier_node_id":"node:verifier", + "discovery_mode":"signed_directory_manifests", + "source_events":[], + "verification_events":[], + "executor_evidence_hash":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "verifier_evidence_hash":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + })) + .expect("result") +} + +#[test] +fn operation_is_durable_idempotent_and_conflict_safe() { + let root = tempfile::tempdir().expect("tempdir"); + let root_path = root.path().canonicalize().expect("canonical tempdir"); + let operation = uuid::Uuid::new_v4(); + let store = LocalPursuitStore::open(&root_path).expect("store"); + assert_eq!( + store.begin(operation, FIRST).expect("begin"), + LocalPursuitBegin::Started + ); + assert_eq!( + store.begin(operation, FIRST).expect("repeat pending"), + LocalPursuitBegin::Uncertain + ); + assert_eq!( + store.begin(operation, OTHER).expect("conflict"), + LocalPursuitBegin::Conflict + ); + let completed = result(); + store + .complete(operation, FIRST, &completed) + .expect("complete"); + + let reopened = LocalPursuitStore::open(&root_path).expect("reopen"); + assert_eq!( + reopened.begin(operation, FIRST).expect("replay"), + LocalPursuitBegin::Completed(Box::new(completed)) + ); + assert_eq!( + reopened.begin(operation, OTHER).expect("conflict"), + LocalPursuitBegin::Conflict + ); +} + +#[cfg(unix)] +#[test] +fn operation_store_rejects_symlinked_state() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().expect("tempdir"); + let root_path = root.path().canonicalize().expect("canonical tempdir"); + let target = root_path.join("target"); + std::fs::create_dir(&target).expect("target"); + let link = root_path.join("local-pursuits-v1"); + symlink(&target, &link).expect("symlink"); + assert!(LocalPursuitStore::open(&root_path).is_err()); +} diff --git a/tests/two_device_evidence.rs b/tests/two_device_evidence.rs new file mode 100644 index 0000000..419eb29 --- /dev/null +++ b/tests/two_device_evidence.rs @@ -0,0 +1,214 @@ +use std::fs; + +use agenet::evidence::{EvidenceError, validate_schema, verify_evidence, verify_evidence_path}; +use serde_json::Value; + +const TEMPLATE: &[u8] = include_bytes!("fixtures/two-device-evidence.synthetic-template.json"); +const SCHEMA: &[u8] = include_bytes!("fixtures/two-device-evidence.schema.json"); + +fn passing_value() -> Value { + let mut value: Value = serde_json::from_slice(TEMPLATE).expect("template JSON"); + value["result"] = Value::String("pass".to_owned()); + value +} + +fn bytes(value: &Value) -> Vec { + serde_json::to_vec(value).expect("evidence JSON") +} + +#[test] +fn schema_is_strict_and_template_is_schema_valid_but_not_physical_evidence() { + let schema: Value = serde_json::from_slice(SCHEMA).expect("schema JSON"); + assert_all_objects_are_closed(&schema); + validate_schema(TEMPLATE).expect("synthetic template obeys the data shape"); + assert_eq!( + verify_evidence(TEMPLATE).unwrap_err(), + EvidenceError::ResultNotPass + ); +} + +#[test] +fn complete_public_physical_evidence_passes_schema_and_semantics() { + let evidence = bytes(&passing_value()); + validate_schema(&evidence).expect("schema"); + let verified = verify_evidence(&evidence).expect("semantic verification"); + assert_eq!(verified.device_count(), 2); +} + +#[test] +fn schema_rejects_unknown_fields_independently_of_typed_deserialization() { + let mut value = passing_value(); + value["unexpected"] = Value::Bool(true); + assert_eq!( + validate_schema(&bytes(&value)).unwrap_err(), + EvidenceError::SchemaViolation + ); +} + +#[test] +fn semantics_reject_identity_process_and_fingerprint_reuse() { + for (target, source) in [ + ("label", "label"), + ("node_id", "node_id"), + ("process_instance_id", "process_instance_id"), + ("certificate_sha256", "certificate_sha256"), + ] { + let mut value = passing_value(); + value["devices"][1][target] = value["devices"][0][source].clone(); + assert!(matches!( + verify_evidence(&bytes(&value)), + Err(EvidenceError::DuplicateIdentity) | Err(EvidenceError::SchemaViolation) + )); + } +} + +#[test] +fn semantics_reject_signed_active_role_and_local_requester_mismatches() { + for value in [ + { + let mut value = passing_value(); + value["devices"][1]["runtime_active_roles"] = + serde_json::json!(["requester", "executor", "verifier"]); + value + }, + { + let mut value = passing_value(); + value["devices"][1]["requester_local_enabled"] = Value::Bool(true); + value + }, + { + let mut value = passing_value(); + value["devices"][0]["credential_signed_roles"] = serde_json::json!(["directory"]); + value + }, + ] { + assert_eq!( + verify_evidence(&bytes(&value)).unwrap_err(), + EvidenceError::DuplicateIdentity + ); + } +} + +#[test] +fn semantics_reject_metrics_and_contract_causality_mismatches() { + let mut metrics = passing_value(); + metrics["evidence"]["verifier"]["byte_count"] = Value::from(33_u64); + assert_eq!( + verify_evidence(&bytes(&metrics)).unwrap_err(), + EvidenceError::MetricsMismatch + ); + + let mut missing_state = passing_value(); + missing_state["contracts"]["source"]["state_path"][2] = Value::String("Active".to_owned()); + assert_eq!( + verify_evidence(&bytes(&missing_state)).unwrap_err(), + EvidenceError::ContractPathInvalid + ); + + let mut early_accept = passing_value(); + early_accept["contracts"]["source"]["events"][3]["timestamp_utc"] = + Value::String("2026-08-15T01:00:15Z".to_owned()); + assert_eq!( + verify_evidence(&bytes(&early_accept)).unwrap_err(), + EvidenceError::AcceptanceBeforeVerification + ); +} + +#[test] +fn semantics_reject_nonphysical_preknowledge_and_ineffective_revocation() { + let mut preknowledge = passing_value(); + preknowledge["discovery"]["requester_initial_knowledge"] = + Value::String("provider-endpoint-known".to_owned()); + assert_eq!( + verify_evidence(&bytes(&preknowledge)).unwrap_err(), + EvidenceError::SchemaViolation + ); + + let mut loopback = passing_value(); + loopback["devices"][0]["transport"] = Value::String("loopback".to_owned()); + assert_eq!( + verify_evidence(&bytes(&loopback)).unwrap_err(), + EvidenceError::SchemaViolation + ); + + let mut ineffective = passing_value(); + ineffective["revocation"]["effect_rejected"] = Value::Bool(false); + assert_eq!( + verify_evidence(&bytes(&ineffective)).unwrap_err(), + EvidenceError::SchemaViolation + ); +} + +#[test] +fn recursive_scanner_rejects_secrets_addresses_and_paths_without_echoing_them() { + for rejected in [ + "Bearer sentinel-value", + "OPENAI_API_KEY=sentinel-value", + "100.64.0.8", + "fd7a:115c:a1e0::8", + "/Users/example/private.rs", + "-----BEGIN CERTIFICATE-----", + ] { + let mut value = passing_value(); + value["artifact"]["artifact_id"] = Value::String(rejected.to_owned()); + let error = verify_evidence(&bytes(&value)).unwrap_err(); + assert_eq!(error, EvidenceError::ForbiddenContent); + assert!(!error.to_string().contains(rejected)); + } +} + +#[test] +fn path_verifier_rejects_symlink_nonregular_and_oversized_input() { + let directory = tempfile::tempdir().expect("tempdir"); + let valid = directory.path().join("valid.json"); + fs::write(&valid, bytes(&passing_value())).expect("write evidence"); + verify_evidence_path(&valid).expect("regular bounded evidence"); + + let oversized = directory.path().join("oversized.json"); + fs::write(&oversized, vec![b' '; 1_048_577]).expect("write oversized"); + assert_eq!( + verify_evidence_path(&oversized).unwrap_err(), + EvidenceError::InvalidEvidenceFile + ); + + let directory_path = directory.path().join("directory.json"); + fs::create_dir(&directory_path).expect("create directory"); + assert_eq!( + verify_evidence_path(&directory_path).unwrap_err(), + EvidenceError::InvalidEvidenceFile + ); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let link = directory.path().join("link.json"); + symlink(&valid, &link).expect("create symlink"); + assert_eq!( + verify_evidence_path(&link).unwrap_err(), + EvidenceError::InvalidEvidenceFile + ); + } +} + +fn assert_all_objects_are_closed(schema: &Value) { + match schema { + Value::Object(object) => { + if object.get("type") == Some(&Value::String("object".to_owned())) { + assert_eq!( + object.get("additionalProperties"), + Some(&Value::Bool(false)), + "every object schema must reject unknown fields" + ); + } + for value in object.values() { + assert_all_objects_are_closed(value); + } + } + Value::Array(values) => { + for value in values { + assert_all_objects_are_closed(value); + } + } + _ => {} + } +} From d5cbe966c88e5ec0212e1b84fa29d913317aee28 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 10:33:03 +0800 Subject: [PATCH 42/67] [bug] Fix physical preflight integrity Root cause: Path-reopened locks and metadata-only readiness could split coordination or admit stale evidence. Solution: Pin and unify invitation locks, probe live runtime identity, and filter model configuration through an allowlist. Risks: Physical overlay validation and Linux adoption remain pending. Dependency: Preflight checkpoint 41a60c67bc5127c70fe327439b995874401c92e4 Links: docs/testing/two-device-acceptance.md Post-mortem: ROADMAP.md records causes and prevention. --- README.md | 11 +- ROADMAP.md | 9 + docs/testing/two-device-acceptance.md | 7 +- plan/01-v4-multi-host-node-bootstrap.md | 11 + src/bootstrap/invitation.rs | 197 +++++++++++++----- src/bootstrap/journal.rs | 73 ++++++- src/bootstrap/mod.rs | 2 +- src/cli/evidence.rs | 173 ++++++++++++++- src/cli/mod.rs | 2 +- src/cli/pursuit.rs | 63 +++++- src/evidence.rs | 11 + src/runtime/host.rs | 78 ++++++- src/transport/node.rs | 24 ++- .../fixtures/two-device-evidence.schema.json | 3 + ...wo-device-evidence.synthetic-template.json | 4 + tests/invitation_store.rs | 85 +++++++- tests/two_device_evidence.rs | 13 ++ 17 files changed, 670 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 90653dc..e00b8d8 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,11 @@ ceiling and creates a separate owner-only local-control token. When both are present, the runtime merges the Requester's signed Artifact route into the private-overlay mTLS peer listener and starts a separate dynamic loopback-only pursuit listener. Its owner-only ready record contains a local endpoint and -process generation, never the token. A token without the signed role fails +process generation, never the token. Its unauthenticated `/healthz` is +loopback-only and returns only the Node ID, process generation, and readiness +booleans. Evidence collection requires that live response to match the ready +record and service metadata, and independently requires the platform user +service to report a running process. A token without the signed role fails closed; a signed role without the token leaves the Directory available but disables pursuits. Provider enrollment creates no local-control token. Bootstrap profile is never used as an authorization role or capability grant. @@ -196,8 +200,9 @@ agenet pursuit run \ --output json ``` -This command accepts no Directory, Executor, or Verifier endpoint. It reads -only `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `VLM_MODEL`, makes one strict +This command accepts no Directory, Executor, or Verifier endpoint. It retains +only the allowlisted `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and `VLM_MODEL` +entries while parsing the file and immediately discards unrelated entries. It makes one strict OpenAI-compatible decision with at most one format repair and no deterministic fallback, then sends the decision and bounded Artifact bytes over the authenticated loopback listener. Provider endpoints are learned only from diff --git a/ROADMAP.md b/ROADMAP.md index ffad3da..c996570 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Task 14 review: preserve lock and runtime liveness integrity + +- **Change**: Pinned the invitation operation-lock inode per store, added an exclusive lock on the pinned journal inode, and made evidence collection verify a live platform service plus exact loopback Requester health identity. +- **Files**: Invitation store/journal and replacement/race tests; local-control health, collector, evidence schema/tests; pursuit dotenv allowlist; Task 14 plan/runbook and README. +- **Root cause / classification**: **技术盲区 / 安全边界遗漏**. Reopening the operation-lock pathname on every operation allowed a regular `0600` replacement to create two advisory-lock domains. The collector trusted ready and service metadata without proving a process was alive, and the pursuit parser temporarily retained unrelated dotenv entries. +- **Solution**: Fix lock order to store Mutex → bounded pinned operation flock → bounded pinned journal flock; compare path and opened lock identity after acquisition and before reload/append; keep reload through sync inside the journal lock. Require platform service `Running` on A/B, and on A require a no-proxy/no-redirect bounded `/healthz` response matching ready and service Node/process IDs. Retain only the three model aliases and redact key diagnostics. +- **Prevention**: Every pathname lock requires a normal-file replacement test, every durable-ready claim requires an active liveness observation tied to one process generation, and config allowlists must filter during parsing rather than after collection. +- **Boundary**: These are local preflight integrity tests. They do not constitute the pending physical two-device private-overlay evidence or Task 14 completion. + ## 2026-08-15 — Task 14 preflight: refresh live invitation state - **Change**: Added one owner-only cross-process invitation operation lock and bounded journal refresh/replay before invitation mutations, so a long-lived Authority sees invitations created later by the real CLI without restart. diff --git a/docs/testing/two-device-acceptance.md b/docs/testing/two-device-acceptance.md index 7b1e60b..5de82cf 100644 --- a/docs/testing/two-device-acceptance.md +++ b/docs/testing/two-device-acceptance.md @@ -153,7 +153,12 @@ agenet evidence collect \ Use `device-b` on B. The collector verifies the local credential and emits only its public claims, the public certificate fingerprint, ready-process instance, -network class, and signed revocation epoch. It does not read `.env`, the Root +network class, liveness booleans, and signed revocation epoch. On both devices +it requires the platform user-service manager to report a running process. On +A it also probes the unauthenticated loopback-only local `/healthz` with proxy +and redirects disabled and short timeouts, then exact-matches the public Node +and process IDs from health, ready state, and service metadata. Stale ready +files and dead or mismatched processes fail collection. It does not read `.env`, the Root keystore, signing/TLS private keys, invitation state, prompts, or raw logs; it never emits the raw credential or certificate. diff --git a/plan/01-v4-multi-host-node-bootstrap.md b/plan/01-v4-multi-host-node-bootstrap.md index 4e35ce5..9963a0c 100644 --- a/plan/01-v4-multi-host-node-bootstrap.md +++ b/plan/01-v4-multi-host-node-bootstrap.md @@ -41,6 +41,14 @@ source-metrics pursuit through a loopback-only local control plane. long-lived Authority with one owner-only process lock. Refresh and bounded replay the pinned checksummed journal under that lock before mutation, so a live CLI-created invitation is visible without restarting the Authority. +7. Pin both the invitation operation lock and journal inode for the store + lifetime. Validate lock pathname continuity after bounded acquisition and + immediately before reload or append; hold the journal inode's exclusive + lock across replay, validation, append, flush, and sync. +8. Require evidence collection to observe a running platform service on both + devices. Device A additionally probes the loopback local-control health + response and exact-matches its public Node/process identity to both ready + state and service metadata. ## Acceptance criteria @@ -58,6 +66,9 @@ source-metrics pursuit through a loopback-only local control plane. - A real CLI process may create an invitation after Authority startup and a provider may redeem it immediately; concurrent create/redeem, lock-holder crash, stale projection, corruption, and pathname replacement fail safely. +- Replacing a regular owner-only operation-lock pathname cannot split the + journal write domain. A stale ready file, dead local listener, or mismatched + health process cannot produce an evidence fragment. ## Risks diff --git a/src/bootstrap/invitation.rs b/src/bootstrap/invitation.rs index e4868a1..d3a5e13 100644 --- a/src/bootstrap/invitation.rs +++ b/src/bootstrap/invitation.rs @@ -7,7 +7,7 @@ use std::{ fd::{AsRawFd, FromRawFd}, unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}, }, - path::{Path, PathBuf}, + path::Path, sync::Mutex, }; @@ -486,7 +486,7 @@ pub struct InvitationStore { pepper: Zeroizing<[u8; PEPPER_BYTES]>, dummy_hmac: [u8; 32], state: Mutex, - state_directory: PathBuf, + operation_lock: InvitationOperationLock, } impl InvitationStore { @@ -501,6 +501,7 @@ impl InvitationStore { } let pepper = load_or_create_pepper(&pepper_path)?; let (journal, events) = DurableJournal::open(&journal_path)?; + let operation_lock = InvitationOperationLock::open(state_directory)?; let mut projection = Projection::default(); for event in events { let delta = validate_event(&projection, &event)?; @@ -515,7 +516,7 @@ impl InvitationStore { journal, persistence_failed: false, }), - state_directory: state_directory.to_path_buf(), + operation_lock, }) } @@ -547,7 +548,7 @@ impl InvitationStore { getrandom::fill(&mut secret_bytes[..]).map_err(|_| BootstrapError::SecretUnavailable)?; let secret = SecretString::from(URL_SAFE_NO_PAD.encode(&secret_bytes[..])); - let (mut state, _operation) = self.lock_fresh_state()?; + let mut state = self.lock_fresh_state()?; if state.projection.records.len() >= MAX_INVITATIONS { return Err(BootstrapError::ResourceLimitExceeded); } @@ -612,7 +613,7 @@ impl InvitationStore { let claims_sha256 = public_claims_sha256(public_claims).map_err(|_| BootstrapError::InvalidInvitation)?; let invitation_id = public_claims.invitation_id; - let (mut state, _operation) = self.lock_fresh_state()?; + let mut state = self.lock_fresh_state()?; let record = state.projection.records.get(&invitation_id); let expected = record .map(|record| &record.secret_hmac_sha256) @@ -690,7 +691,7 @@ impl InvitationStore { pub fn release(&self, invitation_id: Uuid, operation_id: Uuid) -> Result<(), BootstrapError> { validate_operation_id(operation_id)?; - let (mut state, _operation) = self.lock_fresh_state()?; + let mut state = self.lock_fresh_state()?; let record = state .projection .records @@ -730,7 +731,7 @@ impl InvitationStore { if consumed_at_ms <= 0 { return Err(BootstrapError::InvalidTimestamp); } - let (mut state, _operation) = self.lock_fresh_state()?; + let mut state = self.lock_fresh_state()?; if let Some(result) = state .projection .consumptions @@ -782,7 +783,6 @@ impl InvitationStore { pub fn record(&self, invitation_id: Uuid) -> Result, BootstrapError> { Ok(self .lock_fresh_state_for_read()? - .0 .projection .records .get(&invitation_id) @@ -796,7 +796,6 @@ impl InvitationStore { ) -> Result, BootstrapError> { Ok(self .lock_fresh_state_for_read()? - .0 .projection .consumptions .get(&(invitation_id, operation_id)) @@ -807,20 +806,14 @@ impl InvitationStore { self.state.lock().map_err(|_| BootstrapError::StorageFailed) } - fn lock_fresh_state( - &self, - ) -> Result< - ( - std::sync::MutexGuard<'_, StoreState>, - InvitationOperationLock, - ), - BootstrapError, - > { + fn lock_fresh_state(&self) -> Result, BootstrapError> { let mut state = self.lock_state()?; - let operation = InvitationOperationLock::acquire(&self.state_directory)?; + let operation = self.operation_lock.lock()?; if state.persistence_failed { return Err(BootstrapError::PersistenceUnavailable); } + operation.verify_path_identity()?; + let journal = state.journal.lock_exclusive()?; let events = match state.journal.reload() { Ok(events) => events, Err(error) => { @@ -834,23 +827,26 @@ impl InvitationStore { apply_delta(&mut projection, delta); } state.projection = projection; - Ok((state, operation)) + Ok(LockedStoreState { + state, + operation, + _journal: journal, + }) } - fn lock_fresh_state_for_read( - &self, - ) -> Result< - ( - std::sync::MutexGuard<'_, StoreState>, - InvitationOperationLock, - ), - BootstrapError, - > { + fn lock_fresh_state_for_read(&self) -> Result, BootstrapError> { let mut state = self.lock_state()?; - let operation = InvitationOperationLock::acquire(&self.state_directory)?; + let operation = self.operation_lock.lock()?; if state.persistence_failed { - return Ok((state, operation)); + let journal = state.journal.lock_exclusive()?; + return Ok(LockedStoreState { + state, + operation, + _journal: journal, + }); } + operation.verify_path_identity()?; + let journal = state.journal.lock_exclusive()?; let events = state.journal.reload()?; let mut projection = Projection::default(); for event in events { @@ -858,29 +854,73 @@ impl InvitationStore { apply_delta(&mut projection, delta); } state.projection = projection; - Ok((state, operation)) + Ok(LockedStoreState { + state, + operation, + _journal: journal, + }) + } +} + +struct LockedStoreState<'a> { + state: std::sync::MutexGuard<'a, StoreState>, + operation: InvitationOperationGuard<'a>, + _journal: super::journal::JournalExclusiveLock, +} + +impl std::ops::Deref for LockedStoreState<'_> { + type Target = StoreState; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +impl std::ops::DerefMut for LockedStoreState<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.state } } struct InvitationOperationLock { - _directory: File, - _lock: File, + directory: File, + lock: File, + name: std::ffi::CString, +} + +struct InvitationOperationGuard<'a> { + lock: &'a InvitationOperationLock, +} + +impl InvitationOperationGuard<'_> { + fn verify_path_identity(&self) -> Result<(), BootstrapError> { + self.lock.verify_path_identity() + } +} + +impl Drop for InvitationOperationGuard<'_> { + fn drop(&mut self) { + // SAFETY: the pinned lock descriptor outlives this guard. + let _ = unsafe { libc::flock(self.lock.lock.as_raw_fd(), libc::LOCK_UN) }; + } } #[cfg(feature = "cli-test-fixture")] #[doc(hidden)] -pub struct InvitationOperationTestGuard(#[allow(dead_code)] InvitationOperationLock); +pub struct InvitationStoreOperationTestGuard<'a>(#[allow(dead_code)] LockedStoreState<'a>); #[cfg(feature = "cli-test-fixture")] #[doc(hidden)] -pub fn acquire_invitation_operation_for_test( - state_directory: &Path, -) -> Result { - InvitationOperationLock::acquire(state_directory).map(InvitationOperationTestGuard) +pub fn hold_invitation_store_operation_for_test( + store: &InvitationStore, +) -> Result, BootstrapError> { + store + .lock_fresh_state() + .map(InvitationStoreOperationTestGuard) } impl InvitationOperationLock { - fn acquire(state_directory: &Path) -> Result { + fn open(state_directory: &Path) -> Result { let directory = OpenOptions::new() .read(true) .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) @@ -890,19 +930,74 @@ impl InvitationOperationLock { let name = std::ffi::CString::new(OPERATION_LOCK_FILE) .map_err(|_| BootstrapError::InvalidStatePath)?; let lock = open_operation_lock(&directory, &name)?; + require_owner_only_file(&lock)?; + Ok(Self { + directory, + lock, + name, + }) + } + + fn lock(&self) -> Result, BootstrapError> { + self.lock_bounded()?; + if let Err(error) = self.verify_path_identity() { + // SAFETY: the pinned descriptor remains open. + let _ = unsafe { libc::flock(self.lock.as_raw_fd(), libc::LOCK_UN) }; + return Err(error); + } + Ok(InvitationOperationGuard { lock: self }) + } + + fn lock_bounded(&self) -> Result<(), BootstrapError> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); loop { - if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) } == 0 { - break; + // SAFETY: the pinned descriptor remains open for the store lifetime. + if unsafe { libc::flock(self.lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; } - if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted { + if error.kind() != std::io::ErrorKind::WouldBlock { return Err(BootstrapError::StorageFailed); } + if std::time::Instant::now() >= deadline { + return Err(BootstrapError::StateLocked); + } + std::thread::sleep(std::time::Duration::from_millis(5)); } - require_owner_only_file(&lock)?; - Ok(Self { - _directory: directory, - _lock: lock, - }) + } + + fn verify_path_identity(&self) -> Result<(), BootstrapError> { + let mut path_stat = std::mem::MaybeUninit::::uninit(); + // SAFETY: the directory/name are pinned and `path_stat` is writable storage. + if unsafe { + libc::fstatat( + self.directory.as_raw_fd(), + self.name.as_ptr(), + path_stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + return Err(BootstrapError::InvalidStatePath); + } + // SAFETY: successful `fstatat` initialized the value. + let path_stat = unsafe { path_stat.assume_init() }; + let opened = self + .lock + .metadata() + .map_err(|_| BootstrapError::InvalidStatePath)?; + if path_stat.st_dev as u64 != opened.dev() + || path_stat.st_ino != opened.ino() + || path_stat.st_uid != unsafe { libc::geteuid() } + || path_stat.st_mode & libc::S_IFMT != libc::S_IFREG + || path_stat.st_mode & 0o777 != 0o600 + { + return Err(BootstrapError::InvalidStatePath); + } + require_owner_only_file(&self.lock) } } @@ -971,10 +1066,14 @@ fn open_operation_lock(directory: &File, name: &std::ffi::CStr) -> Result Result<(), BootstrapError> { +fn append_event( + state: &mut LockedStoreState<'_>, + event: InvitationEvent, +) -> Result<(), BootstrapError> { if state.persistence_failed { return Err(BootstrapError::PersistenceUnavailable); } + state.operation.verify_path_identity()?; let delta = validate_event(&state.projection, &event)?; if let Err(error) = state.journal.append(&event) { state.persistence_failed = true; diff --git a/src/bootstrap/journal.rs b/src/bootstrap/journal.rs index b3a27be..d8fc6bf 100644 --- a/src/bootstrap/journal.rs +++ b/src/bootstrap/journal.rs @@ -2,7 +2,7 @@ use std::{ fs::{File, OpenOptions}, io::{Read, Seek, SeekFrom, Write}, marker::PhantomData, - os::unix::fs::OpenOptionsExt, + os::{fd::AsRawFd, unix::fs::OpenOptionsExt}, path::{Path, PathBuf}, }; @@ -17,6 +17,19 @@ const CHECKSUM_BYTES: usize = 32; const MAX_RECORD_BYTES: usize = 64 * 1024; const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024; const MAX_RECORDS: usize = 100_000; +const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2); +const LOCK_POLL: std::time::Duration = std::time::Duration::from_millis(5); + +pub(crate) struct JournalExclusiveLock { + file: File, +} + +impl Drop for JournalExclusiveLock { + fn drop(&mut self) { + // SAFETY: the guard owns this open descriptor until after unlock. + let _ = unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) }; + } +} pub(crate) struct DurableJournal { file: File, @@ -65,6 +78,7 @@ where } pub(crate) fn append(&mut self, entry: &Entry) -> Result<(), BootstrapError> { + self.verify_path_identity()?; if self.record_count >= MAX_RECORDS { return Err(BootstrapError::ResourceLimitExceeded); } @@ -97,14 +111,7 @@ where } pub(crate) fn reload(&mut self) -> Result, BootstrapError> { - let path_metadata = - std::fs::symlink_metadata(&self.path).map_err(|_| BootstrapError::InvalidStatePath)?; - let file_metadata = self - .file - .metadata() - .map_err(|_| BootstrapError::StorageFailed)?; - require_owner_only_regular(&path_metadata)?; - require_same_file(&path_metadata, &file_metadata)?; + let file_metadata = self.verify_path_identity()?; if file_metadata.len() >= MAX_JOURNAL_BYTES { return Err(BootstrapError::ResourceLimitExceeded); } @@ -121,6 +128,54 @@ where self.record_count = entries.len(); Ok(entries) } + + pub(crate) fn lock_exclusive(&self) -> Result { + let file = self + .file + .try_clone() + .map_err(|_| BootstrapError::StorageFailed)?; + lock_bounded(&file)?; + if let Err(error) = self.verify_path_identity() { + // SAFETY: `file` is open and owned by this scope. + let _ = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) }; + return Err(error); + } + Ok(JournalExclusiveLock { file }) + } + + fn verify_path_identity(&self) -> Result { + let path_metadata = + std::fs::symlink_metadata(&self.path).map_err(|_| BootstrapError::InvalidStatePath)?; + let file_metadata = self + .file + .metadata() + .map_err(|_| BootstrapError::StorageFailed)?; + require_owner_only_regular(&path_metadata)?; + require_owner_only_regular(&file_metadata)?; + require_same_file(&path_metadata, &file_metadata)?; + Ok(file_metadata) + } +} + +fn lock_bounded(file: &File) -> Result<(), BootstrapError> { + let deadline = std::time::Instant::now() + LOCK_WAIT; + loop { + // SAFETY: `file` remains open for the full call. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + if error.kind() != std::io::ErrorKind::WouldBlock { + return Err(BootstrapError::StorageFailed); + } + if std::time::Instant::now() >= deadline { + return Err(BootstrapError::StateLocked); + } + std::thread::sleep(LOCK_POLL); + } } #[cfg(unix)] diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 1c09f2f..6e39cb8 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -40,7 +40,7 @@ pub use invitation::{ }; #[cfg(feature = "cli-test-fixture")] #[doc(hidden)] -pub use invitation::{InvitationOperationTestGuard, acquire_invitation_operation_for_test}; +pub use invitation::{InvitationStoreOperationTestGuard, hold_invitation_store_operation_for_test}; pub use keystore::{ AgeRootKeystore, DomainRootMaterial, LegacyV1MigrationPolicy, RootKeystore, RootKeystoreFormatVersion, UnlockedRootKeystore, prompt_root_passphrase, diff --git a/src/cli/evidence.rs b/src/cli/evidence.rs index c3e533b..2c9c124 100644 --- a/src/cli/evidence.rs +++ b/src/cli/evidence.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use base64::{Engine as _, engine::general_purpose::STANDARD}; use clap::{Args, Subcommand, ValueEnum}; use ed25519_dalek::VerifyingKey; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ @@ -67,14 +67,14 @@ struct VerificationResult { device_count: usize, } -pub fn execute(args: EvidenceArgs) -> Result<(), CliError> { +pub async fn execute(args: EvidenceArgs) -> Result<(), CliError> { match args.command { EvidenceCommand::Verify(args) => verify(args), - EvidenceCommand::Collect(args) => collect(args), + EvidenceCommand::Collect(args) => collect(args).await, } } -fn collect(args: CollectArgs) -> Result<(), CliError> { +async fn collect(args: CollectArgs) -> Result<(), CliError> { if !args.confirm_physical_device { return Err(collect_failed()); } @@ -100,13 +100,13 @@ fn collect(args: CollectArgs) -> Result<(), CliError> { if signed_roles.as_slice() != expected_signed_roles { return Err(collect_failed()); } - let (active_roles, requester_local_enabled) = - runtime_roles(&paths, args.label, &claims.node_id)?; let service = read_service_metadata(&paths)?; if !service.runtime_ready || service.node_id.as_deref() != Some(claims.node_id.as_str()) { return Err(collect_failed()); } let process_instance_id = service.process_instance_id.ok_or_else(collect_failed)?; + let (active_roles, requester_local_enabled) = + runtime_roles(&paths, args.label, &claims.node_id, &process_instance_id).await?; let revocation = inspect_revocation_cache_read_only( &paths.revocation_file, &root, @@ -130,6 +130,8 @@ fn collect(args: CollectArgs) -> Result<(), CliError> { credential_signed_roles: signed_roles.into_iter().map(str::to_owned).collect(), runtime_active_roles: active_roles.into_iter().map(str::to_owned).collect(), requester_local_enabled, + service_process_running: true, + runtime_health_verified: true, process_instance_id, certificate_sha256: certificate_fingerprint(&paths)?, credential_format: "agenet.node-credential".to_owned(), @@ -143,21 +145,28 @@ fn collect(args: CollectArgs) -> Result<(), CliError> { ) } -fn runtime_roles( +async fn runtime_roles( paths: &NodePaths, label: DeviceLabel, node_id: &crate::protocol::NodeId, + process_instance_id: &str, ) -> Result<(Vec<&'static str>, bool), CliError> { + let status = super::service_node::status_for_doctor(paths)?; + if status.process != crate::service::ServiceProcessState::Running { + return Err(collect_failed()); + } let token_exists = std::fs::symlink_metadata(&paths.local_control_token_file) .is_ok_and(|metadata| metadata.file_type().is_file()); let local_ready = crate::runtime::load_local_control_ready(paths).ok(); match label { DeviceLabel::DeviceA if token_exists - && local_ready - .as_ref() - .is_some_and(|ready| &ready.node_id == node_id) => + && local_ready.as_ref().is_some_and(|ready| { + &ready.node_id == node_id && ready.process_instance_id == process_instance_id + }) => { + let ready = local_ready.as_ref().ok_or_else(collect_failed)?; + probe_requester_health(&ready.endpoint, node_id, process_instance_id).await?; Ok((vec!["directory", "requester"], true)) } DeviceLabel::DeviceB if !token_exists && local_ready.is_none() => { @@ -167,6 +176,67 @@ fn runtime_roles( } } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LocalControlHealth { + node_id: String, + process_instance_id: String, + runtime_ready: bool, + requester_enabled: bool, +} + +async fn probe_requester_health( + endpoint: &url::Url, + node_id: &crate::protocol::NodeId, + process_instance_id: &str, +) -> Result<(), CliError> { + let host = endpoint.host_str().and_then(|value| { + value + .trim_matches(['[', ']']) + .parse::() + .ok() + }); + if endpoint.scheme() != "http" + || !host.is_some_and(|value| value.is_loopback()) + || endpoint.port().is_none() + { + return Err(collect_failed()); + } + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(1)) + .timeout(std::time::Duration::from_secs(2)) + .build() + .map_err(|_| collect_failed())?; + let response = client + .get(endpoint.join("healthz").map_err(|_| collect_failed())?) + .send() + .await + .map_err(|_| collect_failed())?; + if !response.status().is_success() + || response + .content_length() + .is_some_and(|length| length > 16 * 1024) + { + return Err(collect_failed()); + } + let bytes = response.bytes().await.map_err(|_| collect_failed())?; + if bytes.len() > 16 * 1024 { + return Err(collect_failed()); + } + let health: LocalControlHealth = + serde_json::from_slice(&bytes).map_err(|_| collect_failed())?; + if health.node_id != node_id.as_str() + || health.process_instance_id != process_instance_id + || !health.runtime_ready + || !health.requester_enabled + { + return Err(collect_failed()); + } + Ok(()) +} + fn read_root(paths: &NodePaths) -> Result { let bytes = paths .read_material(&paths.root_public_key_file, 256) @@ -263,3 +333,86 @@ fn verify(args: VerifyArgs) -> Result<(), CliError> { }, ) } + +#[cfg(test)] +mod tests { + use axum::{Json, Router, routing::get}; + + use super::*; + + async fn health_server( + node_id: &str, + process_instance_id: &str, + ) -> (url::Url, tokio::task::JoinHandle<()>) { + let body = serde_json::json!({ + "node_id": node_id, + "process_instance_id": process_instance_id, + "runtime_ready": true, + "requester_enabled": true + }); + let app = Router::new().route( + "/healthz", + get(move || { + let body = body.clone(); + async move { Json(body) } + }), + ); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("listener"); + let endpoint = url::Url::parse(&format!( + "http://{}/", + listener.local_addr().expect("address") + )) + .expect("endpoint"); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.expect("health server"); + }); + (endpoint, task) + } + + #[tokio::test] + async fn requester_health_rejects_stale_ready_process_and_wrong_node() { + let expected = crate::protocol::NodeId::new("node:expected").expect("node"); + let (stale, stale_task) = health_server("node:expected", "process:live").await; + assert!( + probe_requester_health(&stale, &expected, "process:stale") + .await + .is_err() + ); + stale_task.abort(); + + let (wrong, wrong_task) = health_server("node:other", "process:expected").await; + assert!( + probe_requester_health(&wrong, &expected, "process:expected") + .await + .is_err() + ); + wrong_task.abort(); + } + + #[tokio::test] + async fn requester_health_rejects_dead_process_and_accepts_exact_live_identity() { + let expected = crate::protocol::NodeId::new("node:expected").expect("node"); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("dead listener"); + let dead = url::Url::parse(&format!( + "http://{}/", + listener.local_addr().expect("dead address") + )) + .expect("dead endpoint"); + drop(listener); + assert!( + probe_requester_health(&dead, &expected, "process:expected") + .await + .is_err() + ); + + let (live, live_task) = health_server("node:expected", "process:expected").await; + probe_requester_health(&live, &expected, "process:expected") + .await + .expect("exact live process"); + live_task.abort(); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 02f3ef4..18f5e18 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -210,7 +210,7 @@ async fn run_cli(cli: Cli) -> i32 { } Command::Evidence(args) => { let format = args.output(); - (format, evidence::execute(args)) + (format, evidence::execute(args).await) } Command::Invite(args) => { let format = args.output(); diff --git a/src/cli/pursuit.rs b/src/cli/pursuit.rs index 49acb5c..0940cc7 100644 --- a/src/cli/pursuit.rs +++ b/src/cli/pursuit.rs @@ -1,5 +1,4 @@ use std::{ - collections::HashMap, io::Read, os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, @@ -110,22 +109,42 @@ struct LlmEnvironment { model: String, } +impl std::fmt::Debug for LlmEnvironment { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("LlmEnvironment") + .field("base_url", &self.base_url) + .field("api_key", &"[REDACTED]") + .field("model", &self.model) + .finish() + } +} + fn read_llm_environment(path: &Path) -> Result { let entries = dotenvy::from_path_iter(path).map_err(|_| llm_configuration_invalid())?; - let values: HashMap = entries - .map(|entry| entry.map_err(|_| llm_configuration_invalid())) - .collect::>()?; - let required = |name: &str| { - values - .get(name) + let mut base_url = None; + let mut api_key = None; + let mut model = None; + for entry in entries { + let (name, value) = entry.map_err(|_| llm_configuration_invalid())?; + match name.as_str() { + "OPENAI_BASE_URL" => base_url = Some(value), + "OPENAI_API_KEY" => api_key = Some(Zeroizing::new(value)), + "VLM_MODEL" => model = Some(value), + _ => {} + } + } + let required = |value: Option| { + value .filter(|value| !value.is_empty()) - .cloned() .ok_or_else(llm_configuration_invalid) }; Ok(LlmEnvironment { - base_url: required("OPENAI_BASE_URL")?, - api_key: Zeroizing::new(required("OPENAI_API_KEY")?), - model: required("VLM_MODEL")?, + base_url: required(base_url)?, + api_key: api_key + .filter(|value| !value.is_empty()) + .ok_or_else(llm_configuration_invalid)?, + model: required(model)?, }) } @@ -271,3 +290,25 @@ fn local_control_unavailable() -> CliError { true, ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn llm_environment_discards_unrelated_dotenv_values_and_redacts_key() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("model.env"); + std::fs::write( + &path, + "OPENAI_BASE_URL=https://model.invalid/v1\nOPENAI_API_KEY=test-api-sentinel\nVLM_MODEL=test-model\nUNRELATED_SECRET=unrelated-sentinel\n", + ) + .expect("environment fixture"); + let environment = read_llm_environment(&path).expect("allowed aliases"); + let debug = format!("{environment:?}"); + assert!(debug.contains("https://model.invalid/v1")); + assert!(debug.contains("test-model")); + assert!(!debug.contains("test-api-sentinel")); + assert!(!debug.contains("unrelated-sentinel")); + } +} diff --git a/src/evidence.rs b/src/evidence.rs index 362ec6a..c8c53d6 100644 --- a/src/evidence.rs +++ b/src/evidence.rs @@ -32,6 +32,7 @@ pub enum EvidenceError { TimestampInvalid, DiscoveryInvalid, RevocationInvalid, + RuntimeLivenessInvalid, } impl Display for EvidenceError { @@ -51,6 +52,7 @@ impl Display for EvidenceError { Self::TimestampInvalid => "EvidenceTimestampInvalid", Self::DiscoveryInvalid => "EvidenceDiscoveryInvalid", Self::RevocationInvalid => "EvidenceRevocationInvalid", + Self::RuntimeLivenessInvalid => "EvidenceRuntimeLivenessInvalid", }; formatter.write_str(code) } @@ -107,6 +109,8 @@ pub struct EvidenceDevice { pub credential_signed_roles: Vec, pub runtime_active_roles: Vec, pub requester_local_enabled: bool, + pub service_process_running: bool, + pub runtime_health_verified: bool, pub process_instance_id: String, pub certificate_sha256: String, pub credential_format: String, @@ -314,6 +318,13 @@ fn verify_devices(evidence: &EvidenceDocument) -> Result<(), EvidenceError> { .iter() .find(|device| device.label == "device-b") .ok_or(EvidenceError::DuplicateIdentity)?; + if evidence + .devices + .iter() + .any(|device| !device.service_process_running || !device.runtime_health_verified) + { + return Err(EvidenceError::RuntimeLivenessInvalid); + } if a.node_id == b.node_id || a.process_instance_id == b.process_instance_id || a.certificate_sha256 == b.certificate_sha256 diff --git a/src/runtime/host.rs b/src/runtime/host.rs index e88a4c3..6464217 100644 --- a/src/runtime/host.rs +++ b/src/runtime/host.rs @@ -157,6 +157,7 @@ pub struct LocalControlReadyV1 { pub format: String, pub version: u32, pub node_id: crate::protocol::NodeId, + pub process_instance_id: String, pub endpoint: Url, pub generation: uuid::Uuid, pub started_at_ms: i64, @@ -275,8 +276,16 @@ impl HostRuntime { remove_ready(paths)?; return Err(error); } + let process_instance_id = format!("process:{}", uuid::Uuid::new_v4()); let mut local_server = match local_control { - Some(plan) => match LocalControlServer::start(paths, plan, self.clock.now_ms()).await { + Some(plan) => match LocalControlServer::start( + paths, + plan, + self.clock.now_ms(), + process_instance_id.clone(), + ) + .await + { Ok(server) => Some(server), Err(error) => { handle.graceful_shutdown(Some(Duration::from_secs(1))); @@ -287,7 +296,12 @@ impl HostRuntime { }, None => None, }; - if let Err(error) = write_ready(paths, &endpoint, &self.bundle.tls_identity.node_id) { + if let Err(error) = write_ready( + paths, + &endpoint, + &self.bundle.tls_identity.node_id, + &process_instance_id, + ) { handle.graceful_shutdown(Some(Duration::from_secs(1))); let _ = server.await; remove_ready(paths)?; @@ -530,6 +544,7 @@ impl LocalControlServer { paths: &NodePaths, plan: LocalControlPlan, started_at_ms: i64, + process_instance_id: String, ) -> Result { let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?; let address = listener.local_addr()?; @@ -539,7 +554,13 @@ impl LocalControlServer { let endpoint = Url::parse(&format!("http://{address}/")) .map_err(|_| RuntimeError::LocalControlUnavailable)?; let node_id = plan.service.identity().node_id().clone(); - let app = requester_local_control_router(plan.service, plan.token, plan.operations); + let app = requester_local_control_router( + plan.service, + plan.token, + plan.operations, + node_id.clone(), + process_instance_id.clone(), + ); let (shutdown, mut shutdown_rx) = tokio::sync::watch::channel(false); let task = tokio::spawn(async move { let _ = axum::serve( @@ -561,6 +582,7 @@ impl LocalControlServer { format: "agenet.local-control-ready".to_owned(), version: 1, node_id, + process_instance_id, endpoint, generation: uuid::Uuid::new_v4(), started_at_ms, @@ -617,6 +639,11 @@ pub fn load_local_control_ready(paths: &NodePaths) -> Result Result<(), RuntimeError> { .build() .map_err(|_| RuntimeError::LocalControlUnavailable)?; let url = endpoint - .join("local/healthz") + .join("healthz") .map_err(|_| RuntimeError::LocalControlUnavailable)?; for _ in 0..40 { if client @@ -930,6 +957,7 @@ fn write_ready( paths: &NodePaths, endpoint: &Url, node_id: &crate::protocol::NodeId, + process_instance_id: &str, ) -> Result<(), RuntimeError> { let managed_binary = match paths.read_material(&paths.service_metadata_file, 32 * 1024) { Ok(bytes) => crate::bootstrap::ServiceMetadataV3::parse(&bytes) @@ -939,7 +967,7 @@ fn write_ready( }; let mut metadata = crate::bootstrap::ServiceMetadataV3::empty(managed_binary); metadata.runtime_ready = true; - metadata.process_instance_id = Some(format!("process:{}", uuid::Uuid::new_v4())); + metadata.process_instance_id = Some(process_instance_id.to_owned()); metadata.node_id = Some(node_id.as_str().to_owned()); metadata.endpoint = Some(endpoint.as_str().to_owned()); let bytes = serde_json::to_vec(&metadata)?; @@ -1065,6 +1093,20 @@ mod tests { .no_proxy() .build() .expect("client"); + let health = client + .get(ready.endpoint.join("healthz").expect("health url")) + .send() + .await + .expect("health response"); + assert_eq!(health.status(), StatusCode::OK); + let health: serde_json::Value = health.json().await.expect("health JSON"); + assert_eq!(health["node_id"], ready.node_id.as_str()); + assert_eq!( + health["process_instance_id"], + metadata.process_instance_id.as_deref().expect("process id") + ); + assert_eq!(health["runtime_ready"], true); + assert_eq!(health["requester_enabled"], true); let response = client .post(endpoint.clone()) .bearer_auth("wrong-token") @@ -1127,6 +1169,32 @@ mod tests { assert!(!paths.service_metadata_file.exists()); } + #[test] + fn local_control_ready_rejects_malformed_process_identity() { + let root = TempDir::new().expect("tempdir"); + let paths = test_paths(&root); + paths.ensure_secure_layout().expect("layout"); + let ready = LocalControlReadyV1 { + format: "agenet.local-control-ready".to_owned(), + version: 1, + node_id: crate::protocol::NodeId::new("node:ready-test").expect("node"), + process_instance_id: "process:not-a-uuid".to_owned(), + endpoint: Url::parse("http://127.0.0.1:12345/").expect("endpoint"), + generation: uuid::Uuid::new_v4(), + started_at_ms: 1, + }; + crate::runtime::key_store::atomic_write_owner_only_strict( + &paths.local_control_ready_file, + &serde_json::to_vec(&ready).expect("JSON"), + true, + ) + .expect("ready file"); + assert_eq!( + load_local_control_ready(&paths), + Err(RuntimeError::LocalControlUnavailable) + ); + } + async fn wait_local_ready(paths: &NodePaths) -> Option { for _ in 0..200 { if let Ok(ready) = load_local_control_ready(paths) { diff --git a/src/transport/node.rs b/src/transport/node.rs index 1322a70..fedb85e 100644 --- a/src/transport/node.rs +++ b/src/transport/node.rs @@ -276,29 +276,47 @@ struct LocalControlHttpState { service: RequesterService, control_token: Arc>, operations: Arc, + node_id: crate::protocol::NodeId, + process_instance_id: String, } pub fn requester_local_control_router( service: RequesterService, control_token: Zeroizing, operations: Arc, + node_id: crate::protocol::NodeId, + process_instance_id: String, ) -> Router { Router::new() - .route("/local/healthz", get(local_health)) + .route("/healthz", get(local_health)) .route("/local/v0/pursuits", post(local_decided_pursuit)) .layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES)) .with_state(Arc::new(LocalControlHttpState { service, control_token: Arc::new(control_token), operations, + node_id, + process_instance_id, })) } -async fn local_health(ConnectInfo(peer): ConnectInfo) -> Response { +async fn local_health( + ConnectInfo(peer): ConnectInfo, + State(state): State>, +) -> Response { if !peer.ip().is_loopback() { return error(StatusCode::FORBIDDEN, "LocalControlLoopbackRequired"); } - (StatusCode::OK, Json(json!({"status":"ok"}))).into_response() + ( + StatusCode::OK, + Json(json!({ + "node_id": state.node_id.as_str(), + "process_instance_id": state.process_instance_id, + "runtime_ready": true, + "requester_enabled": true + })), + ) + .into_response() } async fn local_decided_pursuit( diff --git a/tests/fixtures/two-device-evidence.schema.json b/tests/fixtures/two-device-evidence.schema.json index 8984ff6..0a5c765 100644 --- a/tests/fixtures/two-device-evidence.schema.json +++ b/tests/fixtures/two-device-evidence.schema.json @@ -46,6 +46,7 @@ "label", "os", "architecture", "physical", "transport", "endpoint_class", "domain_id", "node_id", "credential_signed_roles", "runtime_active_roles", "requester_local_enabled", + "service_process_running", "runtime_health_verified", "process_instance_id", "certificate_sha256", "credential_format", "credential_version", "revocation_epoch" ], @@ -73,6 +74,8 @@ "items": { "enum": ["directory", "requester", "executor", "verifier"] } }, "requester_local_enabled": { "type": "boolean" }, + "service_process_running": { "type": "boolean" }, + "runtime_health_verified": { "type": "boolean" }, "process_instance_id": { "type": "string", "minLength": 8, "maxLength": 128 }, "certificate_sha256": { "type": "string", "minLength": 64, "maxLength": 64 }, "credential_format": { "const": "agenet.node-credential" }, diff --git a/tests/fixtures/two-device-evidence.synthetic-template.json b/tests/fixtures/two-device-evidence.synthetic-template.json index 13e444e..c33a1f7 100644 --- a/tests/fixtures/two-device-evidence.synthetic-template.json +++ b/tests/fixtures/two-device-evidence.synthetic-template.json @@ -21,6 +21,8 @@ "credential_signed_roles": ["directory", "requester"], "runtime_active_roles": ["directory", "requester"], "requester_local_enabled": true, + "service_process_running": true, + "runtime_health_verified": true, "process_instance_id": "process:public-device-a", "certificate_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "credential_format": "agenet.node-credential", @@ -39,6 +41,8 @@ "credential_signed_roles": ["requester", "executor", "verifier"], "runtime_active_roles": ["executor", "verifier"], "requester_local_enabled": false, + "service_process_running": true, + "runtime_health_verified": true, "process_instance_id": "process:public-device-b", "certificate_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "credential_format": "agenet.node-credential", diff --git a/tests/invitation_store.rs b/tests/invitation_store.rs index 026afdb..455e1dd 100644 --- a/tests/invitation_store.rs +++ b/tests/invitation_store.rs @@ -198,6 +198,85 @@ fn long_lived_store_rejects_journal_or_operation_lock_replacement() { ); } +#[test] +fn long_lived_store_rejects_regular_operation_lock_replacement() { + let temp = TempDir::new().expect("temporary directory"); + let authority_store = create_store(&temp); + let handoff = create_invitation(&authority_store); + let lock = temp.path().join("invitation.operation.lock"); + let original = temp.path().join("original-operation-lock"); + fs::rename(&lock, &original).expect("move original operation lock"); + fs::write(&lock, b"").expect("publish regular replacement lock"); + fs::set_permissions(&lock, fs::Permissions::from_mode(0o600)).expect("replacement mode"); + + let replacement_store = create_store(&temp); + assert_eq!( + authority_store.record(handoff.public_claims().invitation_id), + Err(BootstrapError::InvalidStatePath) + ); + assert!( + replacement_store + .record(handoff.public_claims().invitation_id) + .expect("replacement store remains journal-serialized") + .is_some() + ); +} + +#[cfg(feature = "cli-test-fixture")] +#[test] +fn journal_lock_serializes_a_replacement_store_during_held_operation() { + let temp = TempDir::new().expect("temporary directory"); + let authority_store = create_store(&temp); + let handoff = create_invitation(&authority_store); + let held = agenet::bootstrap::hold_invitation_store_operation_for_test(&authority_store) + .expect("old Authority holds operation and journal locks"); + + let lock = temp.path().join("invitation.operation.lock"); + let original = temp.path().join("held-original-operation-lock"); + fs::rename(&lock, &original).expect("move held operation lock pathname"); + fs::write(&lock, b"").expect("publish regular replacement lock"); + fs::set_permissions(&lock, fs::Permissions::from_mode(0o600)).expect("replacement mode"); + let replacement_store = create_store(&temp); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let writer = thread::spawn(move || { + started_tx.send(()).expect("started signal"); + finished_tx + .send(create_invitation(&replacement_store)) + .expect("finished signal"); + }); + started_rx.recv().expect("replacement writer starts"); + assert!( + matches!( + finished_rx.recv_timeout(std::time::Duration::from_millis(100)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ), + "replacement operation lock must not split the journal write domain" + ); + drop(held); + let replacement = finished_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("replacement writer finishes after journal unlock"); + writer.join().expect("replacement writer"); + let replay = create_store(&temp); + assert!( + replay + .record(handoff.public_claims().invitation_id) + .expect("old event remains valid") + .is_some() + ); + assert!( + replay + .record(replacement.public_claims().invitation_id) + .expect("new event remains valid") + .is_some() + ); + assert_eq!( + authority_store.record(handoff.public_claims().invitation_id), + Err(BootstrapError::InvalidStatePath) + ); +} + #[cfg(feature = "cli-test-fixture")] #[test] fn crashed_lock_holder_releases_cross_process_operation_lock() { @@ -222,9 +301,9 @@ fn crashed_invitation_lock_holder_child() { let Some(path) = std::env::var_os("AGENET_INVITATION_LOCK_CRASH_CHILD") else { return; }; - let _guard = - agenet::bootstrap::acquire_invitation_operation_for_test(std::path::Path::new(&path)) - .expect("lock acquired"); + let store = InvitationStore::open(std::path::Path::new(&path)).expect("store opens"); + let _guard = agenet::bootstrap::hold_invitation_store_operation_for_test(&store) + .expect("operation and journal locks acquired"); std::process::abort(); } diff --git a/tests/two_device_evidence.rs b/tests/two_device_evidence.rs index 419eb29..65c0b05 100644 --- a/tests/two_device_evidence.rs +++ b/tests/two_device_evidence.rs @@ -89,6 +89,19 @@ fn semantics_reject_signed_active_role_and_local_requester_mismatches() { } } +#[test] +fn semantics_require_live_service_and_runtime_health_for_both_devices() { + for field in ["service_process_running", "runtime_health_verified"] { + let mut value = passing_value(); + value["devices"][0][field] = Value::Bool(false); + value["devices"][1][field] = Value::Bool(true); + assert_eq!( + verify_evidence(&bytes(&value)).unwrap_err(), + EvidenceError::RuntimeLivenessInvalid + ); + } +} + #[test] fn semantics_reject_metrics_and_contract_causality_mismatches() { let mut metrics = passing_value(); From 7db2556de52b3aa8c59a76bb037eb2c1aac0df36 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 11:19:45 +0800 Subject: [PATCH 43/67] [bug] Verify live acceptance health Root cause: Initial invitation replay read without the writer inode lock, and Device B evidence trusted persisted runtime metadata. Solution: Lock initial replay and verify both peers through exact self-mTLS health bound to one public process generation. Risks: Physical evidence and Linux overlay execution remain pending. Dependency: d5cbe966c88e5ec0212e1b84fa29d913317aee28 Links: docs/testing/two-device-acceptance.md Post-mortem: Require framed readers and liveness collectors to join the exact durable or authenticated coordination boundary. --- README.md | 14 +- ROADMAP.md | 9 + docs/testing/two-device-acceptance.md | 31 +-- plan/01-v4-multi-host-node-bootstrap.md | 16 +- src/bootstrap/journal.rs | 31 +-- src/cli/evidence.rs | 245 +++++++++++++++++++++++- src/cli/mod.rs | 32 +++- src/runtime/host.rs | 42 +++- src/runtime/mod.rs | 2 +- src/transport/directory.rs | 58 ++++-- src/transport/mod.rs | 5 + src/transport/node.rs | 131 +++++++++++-- tests/invitation_store.rs | 91 ++++++++- 13 files changed, 628 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index e00b8d8..75ae6f1 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,15 @@ private-overlay mTLS peer listener and starts a separate dynamic loopback-only pursuit listener. Its owner-only ready record contains a local endpoint and process generation, never the token. Its unauthenticated `/healthz` is loopback-only and returns only the Node ID, process generation, and readiness -booleans. Evidence collection requires that live response to match the ready -record and service metadata, and independently requires the platform user -service to report a running process. A token without the signed role fails -closed; a signed role without the token leaves the Directory available but -disables pursuits. Provider enrollment creates no local-control token. +booleans. Every production peer `/healthz` is mTLS-only and returns only its +public Node ID, process generation, runtime readiness, and current revocation +boolean. Evidence collection self-probes that exact configured peer endpoint +on both A and B with the validated local certificate/key/CA bundle, requires an +exact TLS Node/process match, and independently requires the platform user +service to report a running process. Device A also requires its local-control +health to match ready and service metadata. A token without the signed role +fails closed; a signed role without the token leaves the Directory available +but disables pursuits. Provider enrollment creates no local-control token. Bootstrap profile is never used as an authorization role or capability grant. A verified founding Directory additionally loads a typed founding-only diff --git a/ROADMAP.md b/ROADMAP.md index c996570..02ee09b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Task 14 review: verify live peer identity + +- **Change**: Locked the pinned invitation journal during initial replay and made physical evidence collection prove both A and B are live through their exact configured self-mTLS peer endpoints. +- **Files**: Invitation journal/replay race test; HostRuntime peer health; evidence collector and negative tests; production A/B pursuit test; Task 5 amendment, v4 plan, README, and physical runbook. +- **Root cause / classification**: **技术盲区 / 安全边界遗漏**. A fresh store could decode the journal while another process held the journal lock and had written only part of a framed record. Device B evidence also inferred runtime health from service-manager and persisted metadata without a cryptographically bound live request. +- **Solution**: Initial replay acquires the same bounded exclusive flock on the already-open, inode-verified journal before reading. HostRuntime injects one process generation into every production peer router; mTLS `/healthz` exposes only public Node/process/readiness/revocation fields and marks stale/revoked state non-current while preserving read-only audit visibility. The collector loads the validated local cert/key/CA bundle, disables proxy and redirects, expects the exact self Node ID, probes the exact configured endpoint, bounds and strictly parses the response, and matches it to credential and service metadata. Device A retains the additional loopback Requester probe. +- **Prevention**: Every framed journal reader must participate in the writer's inode lock domain, including first open. Every physical liveness claim requires an active authenticated request tied to the same durable process generation; platform status and ready metadata remain independent supporting observations, never substitutes. +- **Boundary**: All evidence is local loopback/mTLS preflight. No physical-device result, milestone, or Task 14 completion is claimed. + ## 2026-08-15 — Task 14 review: preserve lock and runtime liveness integrity - **Change**: Pinned the invitation operation-lock inode per store, added an exclusive lock on the pinned journal inode, and made evidence collection verify a live platform service plus exact loopback Requester health identity. diff --git a/docs/testing/two-device-acceptance.md b/docs/testing/two-device-acceptance.md index 5de82cf..806cd31 100644 --- a/docs/testing/two-device-acceptance.md +++ b/docs/testing/two-device-acceptance.md @@ -65,7 +65,9 @@ argument. Creating the invitation before starting A is the shortest operator sequence, but it is not required. A running Authority refreshes the same bounded, checksummed invitation journal under an owner-only cross-process operation -lock, so a later real CLI invitation is redeemable without restarting A. +lock, so a later real CLI invitation is redeemable without restarting A. An +initial opener also locks the already-open journal inode before replay, so it +cannot parse a concurrent writer's partial framed append. Retain the one-time handoff only in the controlling-TTY workflow until B immediately joins. @@ -81,9 +83,10 @@ agenet node status --output json agenet node doctor --output json ``` -Device B must have exactly `executor` and `verifier`, no local-control token, -and one private-overlay peer listener. Wait for its two signed Capability -Manifests to register with A. +Device B's credential has exactly `requester`, `executor`, and `verifier`, but +without a local-control token its active runtime roles are exactly `executor` +and `verifier`. It exposes one private-overlay peer listener. Wait for its two +signed Capability Manifests to register with A. ## 4. Run the real pursuit on Device A @@ -154,13 +157,19 @@ agenet evidence collect \ Use `device-b` on B. The collector verifies the local credential and emits only its public claims, the public certificate fingerprint, ready-process instance, network class, liveness booleans, and signed revocation epoch. On both devices -it requires the platform user-service manager to report a running process. On -A it also probes the unauthenticated loopback-only local `/healthz` with proxy -and redirects disabled and short timeouts, then exact-matches the public Node -and process IDs from health, ready state, and service metadata. Stale ready -files and dead or mismatched processes fail collection. It does not read `.env`, the Root -keystore, signing/TLS private keys, invitation state, prompts, or raw logs; it -never emits the raw credential or certificate. +it independently requires the platform user-service manager to report a +running process and makes a bounded, no-proxy/no-redirect self-mTLS request to +the exact configured peer `/healthz`. The client loads the validated local +certificate/key/CA bundle, expects its own exact TLS Node ID, and exact-matches +the public Node ID, process generation, runtime readiness, and current +revocation state against credential and service metadata. Device A additionally +probes the unauthenticated loopback-only Requester `/healthz` and matches the +same ready generation. Stale metadata, dead listeners, redirects, oversized or +unknown responses, certificate/Node/process mismatch, and stale revocation all +fail collection. No endpoint, address, raw response, or private material enters +the fragment. The collector does not read `.env`, the Root keystore, +invitation state, prompts, or raw logs; it never emits a raw credential, +certificate, key, or token. Transfer only the two fragments and the redacted pursuit result through a user-approved secure channel. On A, manually assemble them with the real diff --git a/plan/01-v4-multi-host-node-bootstrap.md b/plan/01-v4-multi-host-node-bootstrap.md index 9963a0c..1416be8 100644 --- a/plan/01-v4-multi-host-node-bootstrap.md +++ b/plan/01-v4-multi-host-node-bootstrap.md @@ -46,9 +46,14 @@ source-metrics pursuit through a loopback-only local control plane. immediately before reload or append; hold the journal inode's exclusive lock across replay, validation, append, flush, and sync. 8. Require evidence collection to observe a running platform service on both - devices. Device A additionally probes the loopback local-control health - response and exact-matches its public Node/process identity to both ready - state and service metadata. + devices and self-probe each exact configured peer endpoint over its + validated local mTLS identity. Exact-match public Node/process identity, + readiness, and current revocation state to credential and service metadata. + Device A additionally probes the loopback local-control health response and + matches the same ready generation. +9. Lock the already-open invitation journal inode during initial replay. An + opener must wait for a concurrent framed append to finish or return a stable + bounded lock error; it must never parse a half-frame. ## Acceptance criteria @@ -67,8 +72,9 @@ source-metrics pursuit through a loopback-only local control plane. provider may redeem it immediately; concurrent create/redeem, lock-holder crash, stale projection, corruption, and pathname replacement fail safely. - Replacing a regular owner-only operation-lock pathname cannot split the - journal write domain. A stale ready file, dead local listener, or mismatched - health process cannot produce an evidence fragment. + journal write domain, and initial replay cannot observe a half-frame. A dead + peer or local listener, stale metadata/revocation, redirect, oversized + response, or mismatched TLS Node/process cannot produce an evidence fragment. ## Risks diff --git a/src/bootstrap/journal.rs b/src/bootstrap/journal.rs index d8fc6bf..080a81f 100644 --- a/src/bootstrap/journal.rs +++ b/src/bootstrap/journal.rs @@ -44,15 +44,19 @@ where { pub(crate) fn open(path: &Path) -> Result<(Self, Vec), BootstrapError> { let (mut file, created) = open_journal_file(path)?; - let metadata = file.metadata().map_err(|_| BootstrapError::StorageFailed)?; - require_owner_only_regular(&metadata)?; + let lock_file = file + .try_clone() + .map_err(|_| BootstrapError::StorageFailed)?; + lock_bounded(&lock_file)?; + let _initial_replay_lock = JournalExclusiveLock { file: lock_file }; + verify_opened_path(path, &file)?; if created { file.write_all(HEADER_V4) .map_err(|_| BootstrapError::StorageFailed)?; persist(&mut file)?; sync_parent(path)?; } - let metadata = file.metadata().map_err(|_| BootstrapError::StorageFailed)?; + let metadata = verify_opened_path(path, &file)?; if metadata.len() > MAX_JOURNAL_BYTES { return Err(BootstrapError::InvalidJournal); } @@ -144,19 +148,20 @@ where } fn verify_path_identity(&self) -> Result { - let path_metadata = - std::fs::symlink_metadata(&self.path).map_err(|_| BootstrapError::InvalidStatePath)?; - let file_metadata = self - .file - .metadata() - .map_err(|_| BootstrapError::StorageFailed)?; - require_owner_only_regular(&path_metadata)?; - require_owner_only_regular(&file_metadata)?; - require_same_file(&path_metadata, &file_metadata)?; - Ok(file_metadata) + verify_opened_path(&self.path, &self.file) } } +fn verify_opened_path(path: &Path, file: &File) -> Result { + let path_metadata = + std::fs::symlink_metadata(path).map_err(|_| BootstrapError::InvalidStatePath)?; + let file_metadata = file.metadata().map_err(|_| BootstrapError::StorageFailed)?; + require_owner_only_regular(&path_metadata)?; + require_owner_only_regular(&file_metadata)?; + require_same_file(&path_metadata, &file_metadata)?; + Ok(file_metadata) +} + fn lock_bounded(file: &File) -> Result<(), BootstrapError> { let deadline = std::time::Instant::now() + LOCK_WAIT; loop { diff --git a/src/cli/evidence.rs b/src/cli/evidence.rs index 2c9c124..ef1977d 100644 --- a/src/cli/evidence.rs +++ b/src/cli/evidence.rs @@ -7,10 +7,11 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - bootstrap::{NodePaths, ServiceMetadataV3}, + bootstrap::{NodePaths, PersistedStartupBundle, ServiceMetadataV3, load_startup_bundle}, evidence::{EvidenceDevice, verify_evidence_path}, protocol::{CredentialChain, NodeRole, verify_credential_chain}, runtime::{Clock, inspect_revocation_cache_read_only}, + transport::PeerHealth, }; use super::{CliError, OutputFormat, output}; @@ -104,7 +105,23 @@ async fn collect(args: CollectArgs) -> Result<(), CliError> { if !service.runtime_ready || service.node_id.as_deref() != Some(claims.node_id.as_str()) { return Err(collect_failed()); } - let process_instance_id = service.process_instance_id.ok_or_else(collect_failed)?; + let process_instance_id = service + .process_instance_id + .as_deref() + .ok_or_else(collect_failed)? + .to_owned(); + let startup_role = match args.label { + DeviceLabel::DeviceA => NodeRole::Directory, + DeviceLabel::DeviceB => NodeRole::Executor, + }; + let startup = load_startup_bundle( + &paths, + &root, + startup_role, + Clock::now_ms(&crate::runtime::SystemClock), + ) + .map_err(|_| collect_failed())?; + probe_peer_health(&startup, &service, &claims.node_id, &process_instance_id).await?; let (active_roles, requester_local_enabled) = runtime_roles(&paths, args.label, &claims.node_id, &process_instance_id).await?; let revocation = inspect_revocation_cache_read_only( @@ -145,6 +162,65 @@ async fn collect(args: CollectArgs) -> Result<(), CliError> { ) } +pub(super) async fn probe_peer_health( + startup: &PersistedStartupBundle, + service: &ServiceMetadataV3, + node_id: &crate::protocol::NodeId, + process_instance_id: &str, +) -> Result<(), CliError> { + if startup.config.peer_port == 0 || startup.tls_identity.node_id != *node_id { + return Err(collect_failed()); + } + let socket = + std::net::SocketAddr::new(startup.config.network.bind_ip, startup.config.peer_port); + let endpoint = url::Url::parse(&format!("https://{socket}/")).map_err(|_| collect_failed())?; + if service.endpoint.as_deref() != Some(endpoint.as_str()) { + return Err(collect_failed()); + } + let client = crate::transport::build_peer_client( + &startup.tls_identity, + &startup.config.network, + node_id, + ) + .map_err(|_| collect_failed())?; + let response = client + .get(endpoint.join("healthz").map_err(|_| collect_failed())?) + .send() + .await + .map_err(|_| collect_failed())?; + let status = response.status(); + let content_length = response.content_length(); + if content_length.is_some_and(|length| length > 16 * 1024) { + return Err(collect_failed()); + } + let bytes = response.bytes().await.map_err(|_| collect_failed())?; + validate_peer_health_response(status, content_length, &bytes, node_id, process_instance_id) +} + +fn validate_peer_health_response( + status: reqwest::StatusCode, + content_length: Option, + bytes: &[u8], + node_id: &crate::protocol::NodeId, + process_instance_id: &str, +) -> Result<(), CliError> { + if !status.is_success() + || content_length.is_some_and(|length| length > 16 * 1024) + || bytes.len() > 16 * 1024 + { + return Err(collect_failed()); + } + let health: PeerHealth = serde_json::from_slice(bytes).map_err(|_| collect_failed())?; + if health.node_id != node_id.as_str() + || health.process_instance_id != process_instance_id + || !health.runtime_ready + || !health.revocation_current + { + return Err(collect_failed()); + } + Ok(()) +} + async fn runtime_roles( paths: &NodePaths, label: DeviceLabel, @@ -237,7 +313,7 @@ async fn probe_requester_health( Ok(()) } -fn read_root(paths: &NodePaths) -> Result { +pub(super) fn read_root(paths: &NodePaths) -> Result { let bytes = paths .read_material(&paths.root_public_key_file, 256) .map_err(|_| collect_failed())?; @@ -336,10 +412,39 @@ fn verify(args: VerifyArgs) -> Result<(), CliError> { #[cfg(test)] mod tests { + use std::{net::IpAddr, str::FromStr, time::Duration}; + + use age::secrecy::SecretString; use axum::{Json, Router, routing::get}; + use tempfile::TempDir; use super::*; + fn test_paths(root: &TempDir) -> NodePaths { + NodePaths::resolve( + crate::bootstrap::UserPlatform::MacOs, + &crate::bootstrap::NodePathEnvironment::new( + root.path().canonicalize().expect("canonical home"), + None, + None, + ), + ) + .expect("paths") + } + + async fn wait_service_metadata(paths: &NodePaths) -> ServiceMetadataV3 { + for _ in 0..200 { + if let Ok(bytes) = paths.read_material(&paths.service_metadata_file, 32 * 1024) + && let Ok(metadata) = ServiceMetadataV3::parse(&bytes) + && metadata.runtime_ready + { + return metadata; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("service metadata did not become ready") + } + async fn health_server( node_id: &str, process_instance_id: &str, @@ -415,4 +520,138 @@ mod tests { .expect("exact live process"); live_task.abort(); } + + #[tokio::test] + async fn peer_health_requires_live_exact_mtls_runtime() { + let _host_guard = crate::runtime::host_test_guard().await; + let root = TempDir::new().expect("tempdir"); + let paths = test_paths(&root); + crate::cli::provision_test_domain( + paths.clone(), + crate::bootstrap::network::NetworkBoundary { + kind: crate::bootstrap::network::OverlayKind::Loopback, + bind_ip: IpAddr::from_str("127.0.0.1").expect("loopback"), + allowed_cidrs: vec!["127.0.0.0/8".parse().expect("cidr")], + }, + SecretString::from("peer-health-test-passphrase".to_owned()), + ) + .expect("domain"); + let runtime = crate::runtime::HostRuntime::load(&paths) + .await + .expect("runtime"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let run_paths = paths.clone(); + let task = tokio::spawn(async move { + runtime + .run_until(&run_paths, async move { + let _ = shutdown_rx.await; + }) + .await + }); + let service = wait_service_metadata(&paths).await; + let trusted_root = read_root(&paths).expect("root"); + let startup = load_startup_bundle( + &paths, + &trusted_root, + NodeRole::Directory, + Clock::now_ms(&crate::runtime::SystemClock), + ) + .expect("startup bundle"); + let node_id = startup.tls_identity.node_id.clone(); + let process_instance_id = service.process_instance_id.as_deref().expect("process id"); + probe_peer_health(&startup, &service, &node_id, process_instance_id) + .await + .expect("exact live peer"); + + let mut stale = service.clone(); + stale.process_instance_id = Some("process:stale".to_owned()); + assert!( + probe_peer_health(&startup, &stale, &node_id, "process:stale") + .await + .is_err() + ); + let wrong_node = crate::protocol::NodeId::new("node:wrong").expect("node id"); + assert!( + probe_peer_health(&startup, &service, &wrong_node, process_instance_id) + .await + .is_err() + ); + + shutdown_tx.send(()).expect("shutdown"); + task.await.expect("join").expect("clean shutdown"); + assert!( + probe_peer_health(&startup, &service, &node_id, process_instance_id) + .await + .is_err() + ); + } + + #[test] + fn peer_health_rejects_redirect_oversize_unknown_and_stale_revocation() { + let node_id = crate::protocol::NodeId::new("node:expected").expect("node id"); + let exact = serde_json::to_vec(&PeerHealth { + node_id: node_id.as_str().to_owned(), + process_instance_id: "process:expected".to_owned(), + runtime_ready: true, + revocation_current: true, + }) + .expect("health JSON"); + validate_peer_health_response( + reqwest::StatusCode::OK, + Some(exact.len() as u64), + &exact, + &node_id, + "process:expected", + ) + .expect("exact health"); + assert!( + validate_peer_health_response( + reqwest::StatusCode::TEMPORARY_REDIRECT, + Some(exact.len() as u64), + &exact, + &node_id, + "process:expected", + ) + .is_err() + ); + assert!( + validate_peer_health_response( + reqwest::StatusCode::OK, + Some(16 * 1024 + 1), + &exact, + &node_id, + "process:expected", + ) + .is_err() + ); + let mut unknown: serde_json::Value = serde_json::from_slice(&exact).expect("health value"); + unknown["secret"] = serde_json::json!("must-not-be-accepted"); + assert!( + validate_peer_health_response( + reqwest::StatusCode::OK, + None, + &serde_json::to_vec(&unknown).expect("unknown JSON"), + &node_id, + "process:expected", + ) + .is_err() + ); + let stale = serde_json::to_vec(&PeerHealth { + node_id: node_id.as_str().to_owned(), + process_instance_id: "process:expected".to_owned(), + runtime_ready: true, + revocation_current: false, + }) + .expect("stale health JSON"); + assert!( + validate_peer_health_response( + reqwest::StatusCode::OK, + None, + &stale, + &node_id, + "process:expected", + ) + .is_err() + ); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 18f5e18..fbf57de 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -500,8 +500,13 @@ mod tests { .await .expect("B joins"); assert!(!b_paths.local_control_token_file.exists()); + let peer_port = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("reserve B peer port") + .local_addr() + .expect("B peer address") + .port(); let mut b_config = b_paths.read_config().expect("B config"); - b_config.peer_port = 0; + b_config.peer_port = peer_port; b_paths .write_config(&b_config) .expect("dynamic B peer port"); @@ -517,6 +522,31 @@ mod tests { .await }); wait_for(&b_paths.service_metadata_file, &mut b_task).await; + let b_service = crate::bootstrap::ServiceMetadataV3::parse( + &b_paths + .read_material(&b_paths.service_metadata_file, 32 * 1024) + .expect("B service metadata"), + ) + .expect("valid B service metadata"); + let b_root = super::evidence::read_root(&b_paths).expect("B trusted root"); + let b_startup = crate::bootstrap::load_startup_bundle( + &b_paths, + &b_root, + crate::protocol::NodeRole::Executor, + crate::runtime::Clock::now_ms(&crate::runtime::SystemClock), + ) + .expect("B startup bundle"); + super::evidence::probe_peer_health( + &b_startup, + &b_service, + &b_startup.tls_identity.node_id, + b_service + .process_instance_id + .as_deref() + .expect("B process instance"), + ) + .await + .expect("B live self-mTLS health"); let captured = Arc::new(Mutex::new(None)); let captured_request = Arc::clone(&captured); diff --git a/src/runtime/host.rs b/src/runtime/host.rs index 6464217..05fa059 100644 --- a/src/runtime/host.rs +++ b/src/runtime/host.rs @@ -23,8 +23,8 @@ use crate::{ CapabilityManifest, CredentialChain, NodeRole, RevocationDecision, SideEffectProfile, }, transport::{ - PeerClient, RevocationClient, base_router_with_revocation, - directory_router_with_revocation, provider_router_with_revocation, + PeerClient, RevocationClient, base_router_with_revocation_and_process, + directory_router_with_revocation_and_process, provider_router_with_revocation_and_process, requester_local_control_router, requester_peer_router_with_revocation, }, }; @@ -235,7 +235,9 @@ impl HostRuntime { let endpoint = Url::parse(&format!("https://{actual}/")) .map_err(|_| RuntimeError::UnsupportedNonLoopbackTransport)?; let handle = axum_server::Handle::new(); - let (app, registration, local_control) = self.router(paths, &endpoint).await?; + let process_instance_id = format!("process:{}", uuid::Uuid::new_v4()); + let (app, registration, local_control) = + self.router(paths, &endpoint, &process_instance_id).await?; let tls_identity = self.bundle.tls_identity.clone(); let revocations = Arc::clone(&self.revocations); let boundary = self.bundle.config.network.clone(); @@ -276,7 +278,6 @@ impl HostRuntime { remove_ready(paths)?; return Err(error); } - let process_instance_id = format!("process:{}", uuid::Uuid::new_v4()); let mut local_server = match local_control { Some(plan) => match LocalControlServer::start( paths, @@ -332,6 +333,7 @@ impl HostRuntime { &self, paths: &NodePaths, peer_endpoint: &Url, + process_instance_id: &str, ) -> Result< ( axum::Router, @@ -343,11 +345,12 @@ impl HostRuntime { if self.roles.contains(&NodeRole::Directory) { let identity = self.identity(NodeRole::Directory)?; let guard = RevocationGuard::new(self.revocations.as_ref().clone()); - let mut router = directory_router_with_revocation( + let mut router = directory_router_with_revocation_and_process( DirectoryRegistry::new(), identity.clone(), u64::try_from(self.clock.now_ms()).unwrap_or(u64::MAX), guard.clone(), + process_instance_id.to_owned(), ); if let Some(founding) = self.founding.as_ref() { router = router.merge(crate::transport::lifecycle_authority_router( @@ -424,9 +427,10 @@ impl HostRuntime { } let identity = self.identity(NodeRole::Requester)?; return Ok(( - base_router_with_revocation( + base_router_with_revocation_and_process( identity, RevocationGuard::new(self.revocations.as_ref().clone()), + process_instance_id.to_owned(), ), None, None, @@ -462,9 +466,10 @@ impl HostRuntime { RevocationGuard::new(self.revocations.as_ref().clone()), )?; Ok(( - provider_router_with_revocation( + provider_router_with_revocation_and_process( service, RevocationGuard::new(self.revocations.as_ref().clone()), + process_instance_id.to_owned(), ), Some((identity, client, provider_roles)), None, @@ -1056,6 +1061,9 @@ mod tests { ) .expect("domain"); let runtime = HostRuntime::load(&paths).await.expect("runtime"); + let peer_identity = runtime.bundle.tls_identity.clone(); + let peer_boundary = runtime.bundle.config.network.clone(); + let peer_node_id = runtime.bundle.tls_identity.node_id.clone(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let run_paths = paths.clone(); let task = tokio::spawn(async move { @@ -1085,6 +1093,26 @@ mod tests { .is_some_and(|value| value.starts_with("process:")) ); + let peer_endpoint = url::Url::parse(metadata.endpoint.as_deref().expect("peer endpoint")) + .expect("valid peer endpoint"); + let peer_client = + crate::transport::build_peer_client(&peer_identity, &peer_boundary, &peer_node_id) + .expect("peer client"); + let peer_health = peer_client + .get(peer_endpoint.join("healthz").expect("peer health url")) + .send() + .await + .expect("peer health response"); + assert_eq!(peer_health.status(), StatusCode::OK); + let peer_health: serde_json::Value = peer_health.json().await.expect("peer health JSON"); + assert_eq!(peer_health["node_id"], peer_node_id.as_str()); + assert_eq!( + peer_health["process_instance_id"], + metadata.process_instance_id.as_deref().expect("process id") + ); + assert_eq!(peer_health["runtime_ready"], true); + assert_eq!(peer_health["revocation_current"], true); + let endpoint = ready .endpoint .join("local/v0/pursuits") diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index a938b00..abdb565 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -18,7 +18,7 @@ pub use artifact_access::ArtifactAccessService; pub use clock::{Clock, FixedClock, SystemClock}; pub use directory::DirectoryRegistry; pub use error::RuntimeError; -#[cfg(all(test, feature = "cli-test-fixture"))] +#[cfg(test)] pub(crate) use host::host_test_guard; pub use host::{HostRuntime, LocalControlReadyV1, load_local_control_ready}; pub use identity::NodeIdentity; diff --git a/src/transport/directory.rs b/src/transport/directory.rs index a7ee8e7..9c5763c 100644 --- a/src/transport/directory.rs +++ b/src/transport/directory.rs @@ -24,6 +24,7 @@ struct DirectoryHttpState { registry: DirectoryRegistry, identity: NodeIdentity, revocations: Option, + process_instance_id: Option, } pub fn directory_router( @@ -31,7 +32,7 @@ pub fn directory_router( identity: NodeIdentity, now_unix_ms: u64, ) -> Router { - directory_router_inner(registry, identity, now_unix_ms, None) + directory_router_inner(registry, identity, now_unix_ms, None, None) } pub fn directory_router_with_revocation( @@ -40,7 +41,23 @@ pub fn directory_router_with_revocation( now_unix_ms: u64, revocations: RevocationGuard, ) -> Router { - directory_router_inner(registry, identity, now_unix_ms, Some(revocations)) + directory_router_inner(registry, identity, now_unix_ms, Some(revocations), None) +} + +pub(crate) fn directory_router_with_revocation_and_process( + registry: DirectoryRegistry, + identity: NodeIdentity, + now_unix_ms: u64, + revocations: RevocationGuard, + process_instance_id: String, +) -> Router { + directory_router_inner( + registry, + identity, + now_unix_ms, + Some(revocations), + Some(process_instance_id), + ) } fn directory_router_inner( @@ -48,11 +65,13 @@ fn directory_router_inner( identity: NodeIdentity, _now_unix_ms: u64, revocations: Option, + process_instance_id: Option, ) -> Router { let state = Arc::new(DirectoryHttpState { registry, identity, revocations, + process_instance_id, }); Router::new() .route("/healthz", get(health)) @@ -114,9 +133,9 @@ async fn depart( } } -async fn health(State(state): State>) -> Json { +async fn health(State(state): State>) -> Response { let Some(guard) = &state.revocations else { - return Json(json!({"status": "ok"})); + return Json(json!({"status": "ok"})).into_response(); }; let now_ms = state.identity.now_ms(); let decision = guard.read_only( @@ -125,15 +144,28 @@ async fn health(State(state): State>) -> Json { - Json(json!({"status": "ok", "revocation": "current"})) - } - RevocationDecision::Revoked => { - Json(json!({"status": "degraded", "code": "CredentialRevoked"})) - } - RevocationDecision::Stale => { - Json(json!({"status": "degraded", "code": "RevocationStateStale"})) - } + RevocationDecision::CurrentAndAllowed => match &state.process_instance_id { + Some(process_instance_id) => { + super::node::peer_health(state.identity.node_id(), process_instance_id, true) + } + None => Json(json!({"status": "ok", "revocation": "current"})).into_response(), + }, + RevocationDecision::Revoked => match &state.process_instance_id { + Some(process_instance_id) => { + super::node::peer_health(state.identity.node_id(), process_instance_id, false) + } + None => { + Json(json!({"status": "degraded", "code": "CredentialRevoked"})).into_response() + } + }, + RevocationDecision::Stale => match &state.process_instance_id { + Some(process_instance_id) => { + super::node::peer_health(state.identity.node_id(), process_instance_id, false) + } + None => { + Json(json!({"status": "degraded", "code": "RevocationStateStale"})).into_response() + } + }, } } diff --git a/src/transport/mod.rs b/src/transport/mod.rs index e85bc1e..89a6d1e 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -7,16 +7,21 @@ mod revocation; pub(crate) mod tls; pub use client::{HttpStats, PeerClient, TransportError}; +pub(crate) use directory::directory_router_with_revocation_and_process; pub use directory::{directory_router, directory_router_with_revocation}; pub use enrollment::{ EnrollmentClient, EnrollmentTransportError, enrollment_router, enrollment_tls_config, }; pub(crate) use lifecycle::lifecycle_authority_router; +pub use node::PeerHealth; pub use node::{ artifact_router, artifact_router_with_revocation, base_router_with_revocation, provider_router, provider_router_with_revocation, requester_local_control_router, requester_peer_router_with_revocation, requester_router, requester_router_with_revocation, }; +pub(crate) use node::{ + base_router_with_revocation_and_process, provider_router_with_revocation_and_process, +}; pub use revocation::{RevocationClient, RevocationTransportError, authority_revocation_router}; pub(crate) use tls::validate_persisted_peer_identity; pub use tls::{ diff --git a/src/transport/node.rs b/src/transport/node.rs index fedb85e..675fc8f 100644 --- a/src/transport/node.rs +++ b/src/transport/node.rs @@ -8,6 +8,7 @@ use axum::{ routing::{get, post}, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; use serde_json::json; use zeroize::Zeroizing; @@ -28,14 +29,32 @@ use super::MAX_JSON_BODY_BYTES; struct BaseHttpState { identity: NodeIdentity, revocations: RevocationGuard, + process_instance_id: Option, } pub fn base_router_with_revocation(identity: NodeIdentity, revocations: RevocationGuard) -> Router { + base_router_inner(identity, revocations, None) +} + +pub(crate) fn base_router_with_revocation_and_process( + identity: NodeIdentity, + revocations: RevocationGuard, + process_instance_id: String, +) -> Router { + base_router_inner(identity, revocations, Some(process_instance_id)) +} + +fn base_router_inner( + identity: NodeIdentity, + revocations: RevocationGuard, + process_instance_id: Option, +) -> Router { Router::new() .route("/healthz", get(base_health)) .with_state(Arc::new(BaseHttpState { identity, revocations, + process_instance_id, })) } @@ -46,17 +65,30 @@ async fn base_health(State(state): State>) -> Response { state.identity.node_id(), ); match decision { - crate::protocol::RevocationDecision::CurrentAndAllowed => ( - StatusCode::OK, - Json(json!({"status": "ok", "requester": "disabled"})), - ) - .into_response(), - crate::protocol::RevocationDecision::Revoked => { - error(StatusCode::FORBIDDEN, "CredentialRevoked") - } - crate::protocol::RevocationDecision::Stale => { - error(StatusCode::CONFLICT, "RevocationStateStale") + crate::protocol::RevocationDecision::CurrentAndAllowed => { + match &state.process_instance_id { + Some(process_instance_id) => { + peer_health(state.identity.node_id(), process_instance_id, true) + } + None => ( + StatusCode::OK, + Json(json!({"status": "ok", "requester": "disabled"})), + ) + .into_response(), + } } + crate::protocol::RevocationDecision::Revoked => match &state.process_instance_id { + Some(process_instance_id) => { + peer_health(state.identity.node_id(), process_instance_id, false) + } + None => error(StatusCode::FORBIDDEN, "CredentialRevoked"), + }, + crate::protocol::RevocationDecision::Stale => match &state.process_instance_id { + Some(process_instance_id) => { + peer_health(state.identity.node_id(), process_instance_id, false) + } + None => error(StatusCode::CONFLICT, "RevocationStateStale"), + }, } } @@ -64,22 +96,35 @@ async fn base_health(State(state): State>) -> Response { struct ProviderHttpState { service: ProviderService, revocations: Option, + process_instance_id: Option, } pub fn provider_router(service: ProviderService) -> Router { - provider_router_inner(service, None) + provider_router_inner(service, None, None) } pub fn provider_router_with_revocation( service: ProviderService, revocations: RevocationGuard, ) -> Router { - provider_router_inner(service, Some(revocations)) + provider_router_inner(service, Some(revocations), None) +} + +pub(crate) fn provider_router_with_revocation_and_process( + service: ProviderService, + revocations: RevocationGuard, + process_instance_id: String, +) -> Router { + provider_router_inner(service, Some(revocations), Some(process_instance_id)) } -fn provider_router_inner(service: ProviderService, revocations: Option) -> Router { +fn provider_router_inner( + service: ProviderService, + revocations: Option, + process_instance_id: Option, +) -> Router { Router::new() - .route("/healthz", get(health)) + .route("/healthz", get(provider_health)) .route("/v0/contracts/propose", post(provider_propose)) .route("/v0/contracts/events/append", post(provider_append)) .route("/v0/contracts/events/query", post(provider_query)) @@ -90,9 +135,67 @@ fn provider_router_inner(service: ProviderService, revocations: Option>) -> Response { + let Some(guard) = &state.revocations else { + return health().await.into_response(); + }; + match guard.read_only( + state.service.identity().now_ms(), + &state.service.identity().claims().authority_id, + state.service.identity().node_id(), + ) { + crate::protocol::RevocationDecision::CurrentAndAllowed => { + match &state.process_instance_id { + Some(process_instance_id) => peer_health( + state.service.identity().node_id(), + process_instance_id, + true, + ), + None => health().await.into_response(), + } + } + crate::protocol::RevocationDecision::Revoked + | crate::protocol::RevocationDecision::Stale => match &state.process_instance_id { + Some(process_instance_id) => peer_health( + state.service.identity().node_id(), + process_instance_id, + false, + ), + None => health().await.into_response(), + }, + } +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PeerHealth { + pub node_id: String, + pub process_instance_id: String, + pub runtime_ready: bool, + pub revocation_current: bool, +} + +pub(crate) fn peer_health( + node_id: &crate::protocol::NodeId, + process_instance_id: &str, + revocation_current: bool, +) -> Response { + ( + StatusCode::OK, + Json(PeerHealth { + node_id: node_id.as_str().to_owned(), + process_instance_id: process_instance_id.to_owned(), + runtime_ready: true, + revocation_current, + }), + ) + .into_response() +} + async fn provider_propose( State(state): State>, payload: Result, JsonRejection>, diff --git a/tests/invitation_store.rs b/tests/invitation_store.rs index 455e1dd..1edfa99 100644 --- a/tests/invitation_store.rs +++ b/tests/invitation_store.rs @@ -2,6 +2,7 @@ use std::{ collections::BTreeSet, fs, io::Write, + os::fd::AsRawFd, os::unix::fs::{MetadataExt, PermissionsExt, symlink}, sync::{Arc, Barrier, Mutex}, thread, @@ -16,6 +17,7 @@ use agenet::{ }; use proptest::prelude::*; use reqwest::Url; +use sha2::{Digest, Sha256}; use tempfile::TempDir; use uuid::Uuid; @@ -236,13 +238,13 @@ fn journal_lock_serializes_a_replacement_store_during_held_operation() { fs::rename(&lock, &original).expect("move held operation lock pathname"); fs::write(&lock, b"").expect("publish regular replacement lock"); fs::set_permissions(&lock, fs::Permissions::from_mode(0o600)).expect("replacement mode"); - let replacement_store = create_store(&temp); let (started_tx, started_rx) = std::sync::mpsc::channel(); let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let state_dir = temp.path().to_path_buf(); let writer = thread::spawn(move || { started_tx.send(()).expect("started signal"); finished_tx - .send(create_invitation(&replacement_store)) + .send(InvitationStore::open(&state_dir)) .expect("finished signal"); }); started_rx.recv().expect("replacement writer starts"); @@ -253,11 +255,17 @@ fn journal_lock_serializes_a_replacement_store_during_held_operation() { ), "replacement operation lock must not split the journal write domain" ); + assert_eq!( + finished_rx + .recv_timeout(std::time::Duration::from_secs(3)) + .expect("replacement opener returns bounded error") + .err(), + Some(BootstrapError::StateLocked) + ); + writer.join().expect("replacement opener"); drop(held); - let replacement = finished_rx - .recv_timeout(std::time::Duration::from_secs(2)) - .expect("replacement writer finishes after journal unlock"); - writer.join().expect("replacement writer"); + let replacement_store = create_store(&temp); + let replacement = create_invitation(&replacement_store); let replay = create_store(&temp); assert!( replay @@ -277,6 +285,77 @@ fn journal_lock_serializes_a_replacement_store_during_held_operation() { ); } +#[test] +fn initial_replay_waits_for_a_framed_append_to_finish() { + let temp = TempDir::new().expect("temporary directory"); + let store = create_store(&temp); + let handoff = create_invitation(&store); + let payload = serde_json::to_vec(&serde_json::json!({ + "event": "authentication_failed", + "invitation_id": handoff.public_claims().invitation_id + })) + .expect("event payload"); + let length = u32::try_from(payload.len()) + .expect("bounded payload") + .to_be_bytes(); + let checksum = Sha256::digest(&payload); + let journal_path = temp.path().join(JOURNAL_FILE); + let mut journal = fs::OpenOptions::new() + .append(true) + .open(&journal_path) + .expect("journal writer"); + // SAFETY: the test owns this open descriptor until it explicitly unlocks. + assert_eq!( + unsafe { libc::flock(journal.as_raw_fd(), libc::LOCK_EX) }, + 0 + ); + journal.write_all(&length).expect("frame length"); + let split = payload.len() / 2; + journal + .write_all(&payload[..split]) + .expect("partial frame payload"); + journal.flush().expect("partial frame visible"); + + let path = temp.path().to_path_buf(); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let opener = thread::spawn(move || { + finished_tx + .send(InvitationStore::open(&path)) + .expect("open result"); + }); + assert!( + matches!( + finished_rx.recv_timeout(std::time::Duration::from_millis(100)), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) + ), + "initial replay must not parse a journal while its frame is incomplete" + ); + journal + .write_all(&payload[split..]) + .and_then(|()| journal.write_all(&checksum)) + .expect("complete frame"); + journal.sync_data().expect("durable complete frame"); + // SAFETY: the descriptor remains open and currently owns the flock. + assert_eq!( + unsafe { libc::flock(journal.as_raw_fd(), libc::LOCK_UN) }, + 0 + ); + + let reopened = finished_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .expect("open completes after writer") + .expect("completed frame replays"); + opener.join().expect("opener"); + assert_eq!( + reopened + .record(handoff.public_claims().invitation_id) + .expect("record") + .expect("invitation") + .failed_attempts, + 1 + ); +} + #[cfg(feature = "cli-test-fixture")] #[test] fn crashed_lock_holder_releases_cross_process_operation_lock() { From f7cae6ffd1c2b0747d7a96b10de81c79ff9ac266 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 17:01:06 +0800 Subject: [PATCH 44/67] [doc] Design preview release surfaces Root cause: NA Solution: Define one immutable preview release, WSL2 Agent bootstrap Skill, and bilingual GPT Sites surface before physical acceptance. Risks: WSL and physical-device tests may require versioned corrections. Dependency: AgenNet host preview at 7db2556. Links: docs/superpowers/specs/ 2026-08-15-preview-release-skill-sites-design.md --- ROADMAP.md | 9 + ...8-15-preview-release-skill-sites-design.md | 248 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-15-preview-release-skill-sites-design.md diff --git a/ROADMAP.md b/ROADMAP.md index 02ee09b..fcc7a96 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Prioritize the preview release, bootstrap Skill, and GPT Sites + +- **Change**: Reordered the remaining Developer Preview work so a fixed GitHub Release, verified macOS/Linux/WSL2 installer, Agent bootstrap guide and Skill, and bilingual GPT Sites documentation publish before the physical two-device acceptance run. +- **Files**: `docs/superpowers/specs/2026-08-15-preview-release-skill-sites-design.md`; forthcoming amendments to installation and public-site plans. +- **Decision reason**: The next physical test will be performed from an Agent running inside WSL2 and must exercise the same public one-sentence installation path future users receive. Publishing immutable, manifest-driven surfaces first makes that test representative instead of relying on repository-local commands. +- **Security boundary**: The installer installs only a checksum-verified binary and never handles enrollment secrets. The Agent stops at every Invitation/passphrase/key boundary and delegates secret entry to the human's controlling TTY. Native Windows remains unsupported; WSL2 is the Windows-facing environment. +- **Release boundary**: The first tag is `v0.2.0-preview.1`, not `v0.2.0`; all public copy must retain **Developer Preview — physical acceptance pending** until the existing Task 14 gate passes. +- **Prevention**: Release artifacts, installer, Skill references, raw Agent guides, and Sites commands must be generated from one strict immutable release manifest so public surfaces cannot drift. + ## 2026-08-15 — Task 14 review: verify live peer identity - **Change**: Locked the pinned invitation journal during initial replay and made physical evidence collection prove both A and B are live through their exact configured self-mTLS peer endpoints. diff --git a/docs/superpowers/specs/2026-08-15-preview-release-skill-sites-design.md b/docs/superpowers/specs/2026-08-15-preview-release-skill-sites-design.md new file mode 100644 index 0000000..3bd88c6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-preview-release-skill-sites-design.md @@ -0,0 +1,248 @@ +# AgenNet Preview Release, Bootstrap Skill, and Sites Design + +**Status:** Approved for written-spec review +**Date:** 2026-08-15 +**Release:** `v0.2.0-preview.1` +**Implementation base:** `7db2556de52b3aa8c59a76bb037eb2c1aac0df36` +**Repository:** `Nexa-Language/AgenNet` + +## 1. Purpose + +Publish the first installable AgenNet Developer Preview before the pending +two-physical-device acceptance run. A human or an Agent running inside WSL2 +must be able to install one fixed release, enter the existing secure bootstrap +flow, and find the same commands in a public bilingual documentation site. + +The release, installer, Agent guide, Skill, and website must consume one +versioned release manifest. No surface may silently select a moving branch, +reinterpret enrollment, or claim that the physical acceptance gate has passed. + +## 2. Product Boundary + +The first public release supports: + +- macOS arm64 and x86_64; +- Linux arm64 and x86_64; +- Windows through a WSL2 Linux environment; +- Tailscale or WireGuard networking configured independently by the operator; +- the existing `source.metrics.v1` and `source.metrics.verify.v1` preview flow. + +It does not support native Windows services, PowerShell-native enrollment, +public-Internet discovery, arbitrary shell execution, automatic overlay setup, +unattended secret entry, automatic updates, or a stable-release compatibility +promise. Public copy must use **AgenNet** and label this version **Developer +Preview — physical acceptance pending**. + +## 3. Selected Architecture + +### 3.1 Manifest-driven release + +GitHub Release `v0.2.0-preview.1` is the source of truth. It publishes four +native archives, checksums, GitHub artifact attestations, a strict +`release-manifest-v1.json`, a fixed-version installer, the raw Agent bootstrap +guides, and the packaged bootstrap Skill. + +Every downstream surface is generated from the manifest: + +```text +Git tag + commit + -> native archives + checksums + attestations + -> release-manifest-v1.json + -> install.sh + -> Agent bootstrap guides and Skill references + -> GPT Sites install and documentation pages +``` + +The manifest uses deterministic ordering and contains the exact version, full +commit SHA, target names, archive names, HTTPS download URLs, SHA-256 digests, +sizes, and attestation subjects. Unknown fields, missing targets, duplicate +filenames, non-GitHub hosts, version/path mismatch, and moving URLs fail closed. + +### 3.2 Verified installer + +The public convenience command is: + +```bash +curl -fsSL \ + https://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0-preview.1/install.sh \ + | sh +``` + +The installer only installs the binary. It must: + +1. detect supported macOS, Linux, or WSL2 architecture; +2. fetch the fixed-version manifest and matching archive; +3. validate project, version, target, URL, archive size, and SHA-256; +4. reject redirects to unapproved hosts and unsafe archive entries; +5. install atomically to the current user's `~/.local/bin/agenet`; +6. run `agenet --version` and report the next non-secret command; +7. avoid `sudo`, system-wide paths, invitation input, API keys, passphrases, + node credentials, private network configuration, and automatic enrollment. + +The installer is idempotent for the same exact version. An existing different +binary is preserved unless the operator explicitly approves replacement. WSL2 +without a usable systemd user session may install the binary, but `doctor` +must explain that the service cannot start rather than claiming a healthy node. + +### 3.3 Agent bootstrap guide and Skill + +Two discovery paths are required. + +For an Agent that does not yet have the Skill, the user sends one sentence: + +> Read the official AgenNet node bootstrap guide at the published fixed URL, +> install AgenNet in this WSL2 environment, and prepare it as a Provider node. +> Let me enter every invitation and password only in my local TTY. + +The bilingual raw guide is a small, stable, machine-readable Markdown document. +It directs the Agent to the fixed release and then installs or reads the +`agenet-node-bootstrap` Skill. + +For an Agent with the Skill installed, the user sends: + +> Use `$agenet-node-bootstrap` to join this WSL2 machine to my AgenNet as a +> Provider node. + +The Skill is concise and imperative. It may inspect public diagnostics, install +the verified binary, check WSL2/systemd/Tailscale or WireGuard readiness, start +the CLI flow, and summarize public health. It must delegate all state mutation +to `agenet` and must never request, read, echo, paste, log, or persist an +Invitation, Root passphrase, model key, signing key, TLS key, or local control +token. At a secret boundary it stops and asks the human to run the exact command +in a controlling TTY. + +The Skill package contains only `SKILL.md`, generated UI metadata, a bounded +public reference, and deterministic non-secret validation helpers when needed. +It is tested with RED/GREEN pressure scenarios for secret handling, moving +versions, unsupported native Windows, missing systemd, ambiguous overlay +addresses, failed checksum validation, and an already-managed node. + +### 3.4 GPT Sites website + +One public Sites project hosts a Chinese-default, English-mirrored landing and +documentation experience: + +```text +/ +/docs +/docs/install +/docs/create-domain +/docs/join-node +/docs/agent-setup +/docs/run-pursuit +/docs/lifecycle +/docs/security +/docs/limitations +/en/... +/bootstrap/v0.2/agent-bootstrap.md +/bootstrap/v0.2/agent-bootstrap.en.md +/bootstrap/v0.2/manifest.json +``` + +Commands, versions, URLs, hashes, and support status come from generated +release data. Localized prose may differ, but command semantics may not. +The static bootstrap routes expose only public release metadata and guides. +The site never accepts invitations, credentials, user accounts, telemetry, or +live node status. + +The landing page uses the approved high-end Field Study direction: a dark +mineral atmosphere, slow analytical light ribbons, an ordered point field, +subtle pointer displacement, precise typography, hairline protocol notation, +and restrained motion. WebGL2 is an enhancement behind semantic HTML. The CSS +fallback remains complete; reduced-motion mode renders a stable frame; context +loss or shader failure removes the canvas without breaking content. + +The first viewport must state what AgenNet does, show the Developer Preview +boundary, and provide two primary actions: install and read the docs. It must +not show fake node counts, fake uptime, fabricated partners, or physical-test +claims. + +## 4. User Flows + +### 4.1 Human installation + +1. Open the install page. +2. Select macOS, Linux, or WSL2. +3. Copy the fixed-version one-line installer. +4. Verify the reported version and commit. +5. Follow Domain creation or node-join documentation. +6. Enter secrets only in the local TTY. +7. Run `agenet node doctor --json` and review public status. + +### 4.2 WSL2 Agent installation + +1. Start the Agent inside WSL2. +2. Send the fixed bootstrap sentence. +3. The Agent reads the public guide, verifies the release, and installs the + Skill and binary. +4. The Agent verifies WSL2, systemd user service, overlay ownership, and binary + provenance. +5. At enrollment, the Agent stops and displays the local TTY command. +6. The human enters the Invitation privately. +7. The Agent resumes only from public CLI state, starts the service, runs + doctor, and reports the Node ID, version, active roles, service status, and + health without secrets. + +## 5. Release and Publishing Sequence + +1. Implement and validate the strict release manifest. +2. Build reproducible four-target archives on native GitHub runners. +3. Publish checksums and artifact attestations. +4. Implement and test the fixed-version installer. +5. Write and forward-test the raw Agent guides and bootstrap Skill. +6. Create GitHub Release `v0.2.0-preview.1` from the exact reviewed commit. +7. Generate site release data from that published manifest. +8. Build the bilingual Sites landing and documentation routes. +9. Validate accessibility, reduced motion, WebGL fallback, responsive layout, + exact commands, public metadata, and secret scans. +10. Publish the Sites project publicly and verify the deployed bootstrap URLs. +11. Run the one-sentence installation from a fresh Agent inside WSL2. +12. Continue the existing physical two-device acceptance gate; do not rewrite + the preview release as stable evidence. + +## 6. Failure and Recovery Semantics + +- Unsupported OS or architecture fails before downloading an archive. +- Manifest, checksum, archive, version, or attestation mismatch leaves the + existing installation unchanged. +- Interrupted installation leaves no selected partial binary. +- Missing WSL2 systemd support is a typed readiness failure, not a silent + background-process fallback. +- A non-interactive secret boundary stops with a local TTY instruction. +- Site generation fails when the release is unpublished, incomplete, or does + not match its schema; components may not hardcode replacement values. +- Site publishing failures retain the last successful deployment. +- A later preview version uses a new immutable URL and manifest; it does not + mutate the files under `v0.2.0-preview.1`. + +## 7. Verification Gates + +The release is publishable only when all of the following pass: + +- existing Rust default and all-feature gates; +- deterministic manifest/schema tests; +- archive traversal, symlink, mode, version, and ordering tests; +- installer tests for supported targets, WSL2, checksum failure, interruption, + replacement refusal, and secret-shaped input rejection; +- clean macOS and Linux/WSL2 install smoke tests; +- Agent-guide and Skill RED/GREEN scenarios; +- Skill package validation and UI metadata parity; +- bilingual command-parity and generated-data drift checks; +- Sites production build, semantic/accessibility checks, reduced-motion and + no-WebGL fallbacks, and bounded renderer tests; +- public deployment bootstrap URL checks; +- scans proving no Invitation, credential, passphrase, key, local absolute + path, private overlay address, or raw evidence entered the release or site. + +## 8. Honest Status and Deferred Work + +Publishing `v0.2.0-preview.1` proves that a fixed, verified installation surface +exists. It does not prove native Windows support, physical multi-host success, +Internet-scale routing, failover, replication, sandboxed arbitrary execution, +or general software-engineering capability. + +The existing physical acceptance plan remains the next protocol milestone. +After it passes, AgenNet may publish another preview or release candidate. Any +change to installer behavior, Skill secret boundaries, manifest schema, +supported platforms, or public status must be versioned and recorded in +`ROADMAP.md`; these decisions remain deliberately revisable. From 195525f28493c084f71aeb189ca4079e15a6e5b1 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 17:09:05 +0800 Subject: [PATCH 45/67] [doc] Plan preview release publication Root cause: NA Solution: Split the immutable release, bootstrap Skill, GPT Sites, and WSL acceptance into independently gated implementation plans. Risks: Native runner or Sites availability can delay publication. Dependency: Approved release surfaces design at f7cae6f. Links: plan/04-v1-preview-release-orchestration.md --- ROADMAP.md | 1 + ...8-15-preview-release-skill-sites-design.md | 2 +- plan/02-v2-installation-surfaces.md | 502 ++++++++++++++++++ plan/03-v2-public-sites.md | 420 +++++++++++++++ plan/04-v1-preview-release-orchestration.md | 290 ++++++++++ 5 files changed, 1214 insertions(+), 1 deletion(-) create mode 100644 plan/02-v2-installation-surfaces.md create mode 100644 plan/03-v2-public-sites.md create mode 100644 plan/04-v1-preview-release-orchestration.md diff --git a/ROADMAP.md b/ROADMAP.md index fcc7a96..a9a29af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,6 +8,7 @@ - **Security boundary**: The installer installs only a checksum-verified binary and never handles enrollment secrets. The Agent stops at every Invitation/passphrase/key boundary and delegates secret entry to the human's controlling TTY. Native Windows remains unsupported; WSL2 is the Windows-facing environment. - **Release boundary**: The first tag is `v0.2.0-preview.1`, not `v0.2.0`; all public copy must retain **Developer Preview — physical acceptance pending** until the existing Task 14 gate passes. - **Prevention**: Release artifacts, installer, Skill references, raw Agent guides, and Sites commands must be generated from one strict immutable release manifest so public surfaces cannot drift. +- **Implementation plans**: `plan/02-v2-installation-surfaces.md` supersedes the installation details in `02-v1`; `plan/03-v2-public-sites.md` replaces the prior Astro/GitHub Pages host with a Vinext GPT Sites project; `plan/04-v1-preview-release-orchestration.md` controls immutable tag publication, public deployment, and the fresh WSL2 Agent acceptance. ## 2026-08-15 — Task 14 review: verify live peer identity diff --git a/docs/superpowers/specs/2026-08-15-preview-release-skill-sites-design.md b/docs/superpowers/specs/2026-08-15-preview-release-skill-sites-design.md index 3bd88c6..de921e3 100644 --- a/docs/superpowers/specs/2026-08-15-preview-release-skill-sites-design.md +++ b/docs/superpowers/specs/2026-08-15-preview-release-skill-sites-design.md @@ -1,6 +1,6 @@ # AgenNet Preview Release, Bootstrap Skill, and Sites Design -**Status:** Approved for written-spec review +**Status:** Approved for implementation planning **Date:** 2026-08-15 **Release:** `v0.2.0-preview.1` **Implementation base:** `7db2556de52b3aa8c59a76bb037eb2c1aac0df36` diff --git a/plan/02-v2-installation-surfaces.md b/plan/02-v2-installation-surfaces.md new file mode 100644 index 0000000..15ec061 --- /dev/null +++ b/plan/02-v2-installation-surfaces.md @@ -0,0 +1,502 @@ +# AgenNet Preview Installation Surfaces Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish one immutable `v0.2.0-preview.1` release that a human or an +Agent inside WSL2 can install with one verified command without exposing +enrollment secrets. + +**Architecture:** A strict Rust release manifest describes four native +archives. GitHub Actions builds, attests, and publishes those archives; a +generated fixed-version POSIX installer selects and verifies one archive. Raw +Agent guides and the `agenet-node-bootstrap` Skill delegate all mutations to +the installed CLI and stop at controlling-TTY secret boundaries. + +**Tech Stack:** Rust 1.97.1, Serde, JSON Schema, POSIX shell, GitHub Actions, +GitHub Releases and artifact attestations, macOS arm64/x86_64, Linux/WSL2 +arm64/x86_64, Codex Agent Skills. + +## Global Constraints + +- Release exactly `v0.2.0-preview.1`; never use `latest` or a moving branch. +- Display name is **AgenNet**; executable, crate, and paths use `agenet`. +- Native Windows is unsupported; WSL2 consumes a Linux artifact. +- The installer installs only the binary and never accepts an Invitation, + passphrase, API key, signing key, TLS key, token, or overlay credential. +- Install under `~/.local/bin` without `sudo` or system-wide mutation. +- Existing different binaries are preserved unless the human explicitly + approves replacement. +- Public copy says **Developer Preview — physical acceptance pending**. +- Every production behavior follows RED → GREEN → REFACTOR. +- All third-party Actions are pinned to full commit SHAs. +- Commit messages use the repository five-section format. + +--- + +## Task 1: Define the strict release manifest + +**Files:** + +- Create: `src/release/mod.rs` +- Create: `src/release/manifest.rs` +- Create: `src/bin/agenet-release-manifest.rs` +- Create: `schemas/release-manifest-v1.schema.json` +- Create: `tests/release_manifest.rs` +- Modify: `src/lib.rs` +- Modify: `Cargo.toml` + +**Interfaces:** + +- Produces: + +```rust +pub const PREVIEW_VERSION: &str = "0.2.0-preview.1"; + +pub enum ReleaseTarget { + MacosArm64, + MacosX86_64, + LinuxArm64, + LinuxX86_64, +} + +pub struct ReleaseArtifact { + pub file_name: String, + pub download_url: String, + pub sha256: String, + pub size_bytes: u64, + pub attestation_subject: String, +} + +pub struct ReleaseManifestV1 { + pub schema_version: u32, + pub project: String, + pub version: String, + pub git_commit: String, + pub published_at: String, + pub artifacts: BTreeMap, +} +``` + +- Consumers: archive publisher, installer renderer, Agent guide generator, and + Sites data synchronizer. + +- [ ] **Step 1: Write manifest behavior tests** + +Add tests for a complete four-target manifest and rejection of an unknown +field, missing target, duplicate filename, invalid SHA-256, short commit SHA, +wrong project spelling, non-SemVer version, non-HTTPS URL, non-GitHub host, +version/path mismatch, traversal, and unstable key ordering. + +```rust +#[test] +fn rejects_release_url_for_a_different_version() { + let mut manifest = valid_manifest(); + manifest.artifacts.get_mut(&ReleaseTarget::LinuxX86_64) + .unwrap().download_url = + "https://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0/agenet.tar.gz".into(); + assert_eq!(manifest.validate(), Err(ReleaseError::VersionUrlMismatch)); +} +``` + +- [ ] **Step 2: Run RED** + +Run: + +```bash +cargo test --test release_manifest +``` + +Expected: compilation fails because `agenet::release` does not exist. + +- [ ] **Step 3: Implement the minimal strict model and offline generator** + +Use `deny_unknown_fields`, deterministic `BTreeMap` serialization, exact +GitHub release URL validation, bounded archive metadata, lowercase 64-character +SHA-256, and atomic output. The CLI takes explicit archive paths, version, +commit, publication time, and output path; it performs no network requests. + +- [ ] **Step 4: Verify GREEN and schema parity** + +Run: + +```bash +cargo test --test release_manifest +cargo run --bin agenet-release-manifest -- --help +cargo fmt --check +cargo clippy --bin agenet-release-manifest --test release_manifest -- -D warnings +``` + +Expected: all pass; two identical generator runs are byte-for-byte equal. + +- [ ] **Step 5: Commit** + +```text +[feat][Release][1/6] Define preview manifest + +Root cause: NA +Solution: Add a strict deterministic four-platform release manifest. +Risks: New targets require a schema-versioned manifest revision. +Dependency: Host preview commit 7db2556. +Links: plan/02-v2-installation-surfaces.md +``` + +## Task 2: Build reproducible native archives + +**Files:** + +- Create: `LICENSE` +- Create: `scripts/package-release.sh` +- Create: `scripts/check-release-archive.sh` +- Create: `tests/scripts/release-archive.sh` +- Create: `.github/workflows/release.yml` +- Create: `packaging/release.md` + +**Interfaces:** + +- Consumes: `ReleaseTarget` names and `PREVIEW_VERSION` from Task 1. +- Produces exactly these archives: + +```text +agenet-v0.2.0-preview.1-aarch64-apple-darwin.tar.gz +agenet-v0.2.0-preview.1-x86_64-apple-darwin.tar.gz +agenet-v0.2.0-preview.1-aarch64-unknown-linux-gnu.tar.gz +agenet-v0.2.0-preview.1-x86_64-unknown-linux-gnu.tar.gz +``` + +Each archive contains exactly: + +```text +agenet-v0.2.0-preview.1-{rust_target}/ +├── agenet +├── LICENSE +├── README.md +└── RELEASE-METADATA.json +``` + +- [ ] **Step 1: Write failing archive fixtures and checker tests** + +The shell test creates valid and invalid archives. Reject absolute paths, +`../`, symlinks, unexpected files, non-executable binary mode, non-regular +members, version/commit mismatch, unsafe owner metadata, and unstable ordering. + +```bash +if scripts/check-release-archive.sh "$fixture/traversal.tar.gz"; then + echo "traversal archive was accepted" >&2 + exit 1 +fi +``` + +- [ ] **Step 2: Run RED** + +Run `bash tests/scripts/release-archive.sh`. + +Expected: fail because packager and checker do not exist. + +- [ ] **Step 3: Implement deterministic packaging** + +Require explicit `--binary`, `--target`, `--version`, `--commit`, and +`--output-dir`. Normalize timestamps, uid/gid, member order, directory mode, +and file modes. Never mutate the source binary. Smoke-test the staged binary +with `--version` before archiving. + +- [ ] **Step 4: Add the native GitHub matrix** + +Use native GitHub runners for the four targets. Before writing the workflow, +resolve each official Action tag to a full commit SHA using its upstream +repository and record the tag in a comment. Jobs run format, Clippy, +target-appropriate tests, release build, binary version check, archive checker, +and artifact upload. The publish job alone receives `contents: write` and +`id-token: write` for attestations. + +- [ ] **Step 5: Verify GREEN** + +Run: + +```bash +bash tests/scripts/release-archive.sh +bash scripts/package-release.sh --help +actionlint .github/workflows/release.yml +cargo test --all-targets +``` + +Expected: archive tests and Rust tests pass; `tar -tvf` shows only the exact +four-member contract with normalized metadata. + +- [ ] **Step 6: Commit** + +```text +[feat][Release][2/6] Build native preview archives + +Root cause: NA +Solution: Add deterministic native packaging and a pinned release matrix. +Risks: Native runner availability can delay one platform artifact. +Dependency: Release manifest step 1. +Links: packaging/release.md +``` + +## Task 3: Publish checksums, attestations, and generated installer + +**Files:** + +- Create: `src/bin/agenet-render-installer.rs` +- Create: `scripts/install.sh.template` +- Create: `scripts/verify-release.sh` +- Create: `tests/installer.rs` +- Create: `tests/scripts/installer-smoke.sh` +- Modify: `.github/workflows/release.yml` + +**Interfaces:** + +- Consumes: a validated `ReleaseManifestV1` and the four archives. +- Produces: `install.sh`, `SHA256SUMS`, manifest, artifact attestations, and a + draft GitHub Release before final publication. + +- [ ] **Step 1: Write installer rendering and shell behavior tests** + +Assert exact embedded version/target/checksum entries. Test macOS/Linux/WSL2 +selection, unsupported native Windows signatures, unknown architecture, +checksum mismatch, unsafe archive, interrupted staging, same-version +idempotency, different-binary preservation, unwritable destination, and PATH +instructions. Feed secret-shaped sentinels and prove stdout/stderr do not echo +them and no enrollment command is run. + +```rust +#[test] +fn rendered_installer_contains_no_moving_release_url() { + let text = render_installer(&valid_manifest()).unwrap(); + assert!(text.contains("/download/v0.2.0-preview.1/")); + assert!(!text.contains("/latest/")); + assert!(!text.contains("/heads/")); +} +``` + +- [ ] **Step 2: Run RED** + +Run: + +```bash +cargo test --test installer +bash tests/scripts/installer-smoke.sh +``` + +Expected: missing renderer and generated installer failures. + +- [ ] **Step 3: Implement the generated installer** + +Generate a POSIX shell script with immutable archive URLs and checksums. Use a +private temporary directory, HTTPS-only curl settings, bounded downloads, +`sha256sum` or `shasum -a 256`, archive preflight, a same-directory temporary +binary, version smoke test, and atomic rename into `~/.local/bin`. Do not add an +environment override for release hosts or secret inputs. + +- [ ] **Step 4: Finish GitHub publication** + +The tag workflow builds all archives, generates and verifies the manifest, +renders the installer, generates `SHA256SUMS`, creates attestations, uploads all +public assets, and publishes a prerelease only if the exact four-target set is +complete. Re-running the same tag must compare assets and fail on divergence. + +- [ ] **Step 5: Verify GREEN** + +Run focused tests twice, shellcheck the scripts, scan output for sentinels, and +perform clean-prefix installs on macOS plus a Linux container that represents +the WSL filesystem/user boundary without claiming physical WSL acceptance. + +- [ ] **Step 6: Commit** + +```text +[feat][Release][3/6] Generate verified installer + +Root cause: NA +Solution: Render one immutable checksum-verifying user installer. +Risks: A missing platform checksum blocks the complete prerelease. +Dependency: Native archives step 2. +Links: plan/02-v2-installation-surfaces.md +``` + +## Task 4: Publish canonical human and Agent guides + +**Files:** + +- Create: `docs/install/index.md` +- Create: `docs/install/index.en.md` +- Create: `docs/bootstrap/agent-node-setup.md` +- Create: `docs/bootstrap/agent-node-setup.en.md` +- Create: `src/bin/agenet-sync-public-guides.rs` +- Create: `tests/public_guides.rs` + +**Interfaces:** + +- Consumes: validated release manifest and public CLI help output. +- Produces: bilingual human guides, raw Agent guides, and normalized generated + copies for the Sites project. + +- [ ] **Step 1: Write guide contract tests** + +Reject moving URLs, version mismatch, native-Windows claims, `sudo`, secret +arguments/env/stdin, local absolute paths, private IPs, test sentinels, command +drift, missing WSL2/systemd diagnostics, and missing physical-pending status. + +- [ ] **Step 2: Run RED** + +Run `cargo test --test public_guides` and confirm missing canonical guides. + +- [ ] **Step 3: Write the minimal canonical guides and synchronizer** + +The Agent guide is imperative and bounded. It installs, checks public state, +and stops before `node join` so the human uses the local controlling TTY. It +never asks the user to paste an Invitation into chat. The synchronizer consumes +the manifest and CLI help, normalizes line endings, and writes atomically. + +- [ ] **Step 4: Verify GREEN and idempotency** + +Run the synchronizer twice and require a clean diff, then run guide tests and a +secret/path scan. + +- [ ] **Step 5: Commit** + +```text +[doc][Release][4/6] Publish bootstrap guides + +Root cause: NA +Solution: Generate bilingual human and Agent guides from release truth. +Risks: CLI changes intentionally break the guide parity gate. +Dependency: Verified installer step 3. +Links: docs/bootstrap/agent-node-setup.md +``` + +## Task 5: Create and pressure-test the bootstrap Skill + +**Files:** + +- Create: `skills/agenet-node-bootstrap/SKILL.md` +- Create: `skills/agenet-node-bootstrap/agents/openai.yaml` +- Create: `skills/agenet-node-bootstrap/references/public-status.md` +- Create: `skills/agenet-node-bootstrap/scripts/check-public-readiness.sh` +- Create: `tests/skills/agenet-node-bootstrap-scenarios.md` +- Create: `tests/scripts/skill-readiness.sh` + +**Interfaces:** + +- Consumes: canonical Agent guide, fixed release version, and public CLI + diagnostics. +- Produces: a validated Agent Skill package and the exact one-sentence trigger. + +- [ ] **Step 1: Establish RED pressure scenarios without the Skill** + +Run fresh-context scenarios covering time pressure plus: a user pasting an +Invitation into chat, an Agent asking to receive it, use of `latest`, native +Windows, missing systemd, two overlay IPs, checksum failure, existing managed +state, and a request to bypass TTY. Record the exact unsafe or ambiguous +baseline behavior in the scenario artifact. + +- [ ] **Step 2: Initialize the Skill package** + +Use `skill-creator`'s `init_skill.py` with the exact name +`agenet-node-bootstrap`, scripts and references resources, and generated UI +metadata. Delete all placeholders before validation. + +- [ ] **Step 3: Write the minimal Skill and readiness helper** + +The Skill delegates mutation to `agenet`, reads only public diagnostics, pins +`v0.2.0-preview.1`, distinguishes WSL2 from native Windows, and uses this +required secret-boundary response shape: + +```text +Action required in your local terminal: + agenet node join +Do not paste the invitation or terminal output into this chat. +Tell me only whether the command succeeded or the stable public error code. +``` + +The helper checks OS, WSL2, architecture, command provenance, systemd user +availability, Tailscale/WireGuard public readiness, and existing AgenNet state; +it never reads private state files. + +- [ ] **Step 4: Run GREEN and loophole scenarios** + +Run the same fresh scenarios with the Skill, then add variations for a fake +guide URL, an expired invitation, a request to print private config, and an +already healthy node. The Agent must converge on the safe workflow without +inventing commands. + +- [ ] **Step 5: Validate the package** + +Run `quick_validate.py`, the readiness shell tests, word count, metadata parity, +guide parity, executable-mode checks, and scans for placeholders, secrets, +moving URLs, native-Windows claims, and local paths. + +- [ ] **Step 6: Commit** + +```text +[feat][Skill][5/6] Add node bootstrap Skill + +Root cause: NA +Solution: Teach Agents the fixed CLI flow and local TTY secret boundary. +Risks: Agent runtimes differ in Skill discovery and terminal control. +Dependency: Canonical Agent guide step 4. +Links: skills/agenet-node-bootstrap/SKILL.md +``` + +## Task 6: Seal the preview release candidate + +**Files:** + +- Modify: `README.md` +- Modify: `ROADMAP.md` +- Create: `docs/releases/v0.2.0-preview.1.md` +- Create: `scripts/preflight-preview-release.sh` +- Create: `tests/scripts/preview-release-preflight.sh` + +**Interfaces:** + +- Consumes: Tasks 1–5 and the existing host-runtime test gates. +- Produces: a release-ready commit; it does not create the public tag yet. + +- [ ] **Step 1: Write the failing aggregate preflight test** + +Require exact version agreement across Cargo metadata, manifest generator, +installer, guides, Skill, release notes, workflow, and README. Reject dirty +generated files, unpinned Actions, secret-like tracked data, empty evidence, +missing licenses, and a stable/physical-complete claim. + +- [ ] **Step 2: Run RED** + +Run `bash tests/scripts/preview-release-preflight.sh` and record each missing +surface. + +- [ ] **Step 3: Complete public status and release notes** + +Explain exactly what works, WSL2 versus native Windows, installation and +uninstall boundaries, source-metrics limitation, private-overlay prerequisite, +physical-pending status, and how to report a stable public error without logs +or secrets. + +- [ ] **Step 4: Run final GREEN gates** + +Run: + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets +cargo test --all-targets --all-features +bash tests/scripts/preview-release-preflight.sh +``` + +Expected: all pass on the exact release candidate with a clean worktree. + +- [ ] **Step 5: Commit** + +```text +[chore][Release][6/6] Seal preview candidate + +Root cause: NA +Solution: Gate one consistent preview candidate across every public surface. +Risks: Physical two-device acceptance remains pending after publication. +Dependency: Release and Skill steps 1-5. +Links: docs/releases/v0.2.0-preview.1.md +``` diff --git a/plan/03-v2-public-sites.md b/plan/03-v2-public-sites.md new file mode 100644 index 0000000..31b59c9 --- /dev/null +++ b/plan/03-v2-public-sites.md @@ -0,0 +1,420 @@ +# AgenNet GPT Sites and Documentation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. Use `sites:sites-building` for the +> implementation and `sites:sites-hosting` only after the exact build passes. + +**Goal:** Publish a high-end bilingual AgenNet landing and documentation site +whose installation commands and Agent guide are generated from the immutable +`v0.2.0-preview.1` release. + +**Architecture:** A Sites-compatible Vinext project renders semantic landing +and documentation routes. A deterministic sync step validates the canonical +release manifest and guides, then emits typed site data plus raw public +bootstrap files. A bounded WebGL2 field enhances a complete CSS atmosphere; +all content, installation, and documentation remain usable without WebGL or +motion. + +**Tech Stack:** OpenAI Sites starter, Vinext, React, TypeScript, Vite, +`@openai/sites-vite-plugin`, CSS, WebGL2, Vitest, Playwright, GPT Sites hosting. + +## Global Constraints + +- Use **AgenNet** in public display copy. +- Chinese is the default locale; every public document has an English mirror. +- Show `v0.2.0-preview.1` and **Developer Preview — physical acceptance + pending**; do not claim stable, physical, or Internet-scale validation. +- Commands, URLs, hashes, targets, and versions are generated from the release + manifest and canonical guides; components may not hardcode them. +- No account, invitation form, credential input, telemetry, live-node status, + private IP, local path, or raw evidence is hosted. +- WebGL2 is optional; semantic content and CSS fallback are complete. +- Respect reduced motion, keyboard access, touch, contrast, and responsive + layout. +- Use one Sites project and one stable preview/deployment tab. +- Commit messages use the five-section format. + +--- + +## Task 1: Initialize the Sites project and bilingual route shell + +**Files:** + +- Create: `site/` through the bundled Sites initializer +- Modify: `site/app/layout.tsx` +- Modify: `site/app/page.tsx` +- Create: `site/app/en/page.tsx` +- Create: `site/app/docs/[[...slug]]/page.tsx` +- Create: `site/app/en/docs/[[...slug]]/page.tsx` +- Create: `site/app/globals.css` +- Create: `site/components/SiteHeader.tsx` +- Create: `site/components/SiteFooter.tsx` +- Create: `site/content/navigation.ts` +- Create: `site/tests/routes.test.tsx` + +**Interfaces:** + +- Produces one semantic layout, locale-aware navigation, root routes, and docs + route shells consumed by later tasks. + +```ts +export type Locale = 'zh' | 'en'; + +export interface NavigationItem { + key: 'install' | 'docs' | 'github'; + href: string; + label: Record; +} +``` + +- [ ] **Step 1: Initialize once and inspect the minimal Sites files** + +Run the bundled `scripts/init-site.sh` with `site/` as the target. Preserve its +package manager, lockfile, Vinext structure, Vite Sites plugin, and +`.openai/hosting.json`. Do not initialize a second frontend. + +- [ ] **Step 2: Write failing semantic route tests** + +Require one `h1`, one `main`, header/nav/footer landmarks, correct locale links, +Developer Preview status, install/docs actions, no fake metrics, and no +starter `codex-preview` metadata. + +```tsx +it('shows the honest preview status in both locales', async () => { + expect(renderRoute('/').getByText('物理设备验收待完成')).toBeVisible(); + expect(renderRoute('/en').getByText('Physical acceptance pending')).toBeVisible(); +}); +``` + +- [ ] **Step 3: Run RED** + +Run the site test command selected by the starter. Expected: route modules and +product copy are missing. + +- [ ] **Step 4: Implement the smallest recognizable first slice** + +Replace `SkeletonPreview` with a semantic AgenNet header, exact product name, +one coordination statement, status badge, and install/docs actions. Add a +complete dark CSS fallback but no WebGL implementation yet. Remove starter +metadata and unused skeleton imports. + +- [ ] **Step 5: Start and hand off the first meaningful preview** + +Keep `npm run dev` alive, make one lightweight request to the exact local URL, +and open the compiled product slice in Codex with one stable tab ID. Do not +perform browser QA or additional planned source edits before this handoff. + +- [ ] **Step 6: Complete route shells and verify GREEN** + +Add locale-aware header/footer and docs shells, then run unit tests and the +production build. + +- [ ] **Step 7: Commit** + +```text +[feat][Site][1/5] Establish Sites shell + +Root cause: NA +Solution: Add one semantic bilingual Vinext shell for AgenNet. +Risks: Content routes remain incomplete until generated data lands. +Dependency: Preview release plan task 1. +Links: plan/03-v2-public-sites.md +``` + +## Task 2: Synchronize immutable release and documentation data + +**Files:** + +- Create: `site/scripts/sync-public-data.ts` +- Create: `site/lib/release-data.ts` +- Create: `site/generated/release.ts` +- Create: `site/generated/docs.ts` +- Create: `site/public/bootstrap/v0.2/manifest.json` +- Create: `site/public/bootstrap/v0.2/agent-bootstrap.md` +- Create: `site/public/bootstrap/v0.2/agent-bootstrap.en.md` +- Create: `site/tests/generated-data.test.ts` +- Modify: `site/package.json` + +**Interfaces:** + +- Consumes: the validated canonical release manifest and guides from + `plan/02-v2-installation-surfaces.md`. +- Produces: + +```ts +export interface PublicReleaseData { + project: 'AgenNet'; + version: '0.2.0-preview.1'; + status: 'developer-preview-physical-pending'; + installerUrl: string; + artifacts: ReadonlyArray<{ + target: 'macos-arm64' | 'macos-x86_64' | 'linux-arm64' | 'linux-x86_64'; + url: string; + sha256: string; + sizeBytes: number; + }>; +} +``` + +- [ ] **Step 1: Write failing generated-data tests** + +Require byte-for-byte public manifest parity, fixed installer URL, four targets, +strict preview status, guide command parity, normalized line endings, and no +moving URL, local path, private IP, secret-shaped token, raw evidence, or +native-Windows claim. + +- [ ] **Step 2: Run RED** + +Run `npm test -- generated-data` and confirm generated files are missing. + +- [ ] **Step 3: Implement strict synchronization** + +Parse and validate the canonical manifest before writing. Copy only approved +public fields. Generate TypeScript and raw static files atomically, sort data, +normalize line endings, and refuse unpublished or version-mismatched input. +Add `prebuild` and `check:generated`; running sync twice must produce no diff. + +- [ ] **Step 4: Verify GREEN** + +Run sync twice, generated tests, `check:generated`, the site build, and scans +for paths/IPs/secrets/moving URLs. + +- [ ] **Step 5: Commit** + +```text +[feat][Site][2/5] Sync preview release data + +Root cause: NA +Solution: Generate all public commands and bootstrap files from release truth. +Risks: Site builds intentionally stop when the release is incomplete. +Dependency: Release manifest and canonical guides. +Links: site/public/bootstrap/v0.2/manifest.json +``` + +## Task 3: Build the Field Study hero and bounded particle renderer + +**Files:** + +- Create: `site/components/Hero.tsx` +- Create: `site/components/StatusBadge.tsx` +- Create: `site/components/field/FieldCanvas.tsx` +- Create: `site/components/field/renderer.ts` +- Create: `site/components/field/shaders.ts` +- Create: `site/components/field/capability.ts` +- Create: `site/styles/tokens.css` +- Create: `site/styles/field-study.css` +- Create: `site/tests/field-math.test.ts` +- Create: `site/e2e/field-canvas.spec.ts` +- Modify: `site/app/page.tsx` +- Modify: `site/app/en/page.tsx` + +**Interfaces:** + +```ts +export type FieldQuality = 'full' | 'reduced' | 'static'; + +export interface FieldRenderer { + resize(width: number, height: number, dpr: number): void; + setPointer(x: number, y: number, active: boolean): void; + render(timeSeconds: number): void; + dispose(): void; +} +``` + +- [ ] **Step 1: Write deterministic math and browser RED tests** + +Test stable lattice coordinates, pointer normalization/falloff, DPR cap 1.5, +quality selection, finite boundary values, one canvas mount, shader compilation, +nonempty point field, pointer frame change, visibility pause, context-loss +fallback, reduced-motion single frame, teardown, and zero console/WebGL errors. + +- [ ] **Step 2: Run RED** + +Run focused unit and Playwright tests. Expected: renderer modules are missing. + +- [ ] **Step 3: Implement visual tokens and complete fallback** + +Define the dark mineral palette, precise typography, spacing, hairlines, focus +ring, selection, staged entrance, grid/grain/radial light, and bottom fade. Use +CSS layers rather than a generated SVG. Disable nonessential motion under +`prefers-reduced-motion`. + +- [ ] **Step 4: Implement one bounded WebGL2 renderer** + +Render an ordered point lattice and two or three analytic slow ribbons. Use one +RAF loop, a stable seed, passive pointer events, smooth local displacement, +visibility suspension, `ResizeObserver`, full cleanup, static/reduced modes, +and no telemetry or device fingerprinting. Never allocate per-frame particles. + +- [ ] **Step 5: Verify GREEN and sustained behavior** + +Run unit/E2E tests at desktop/mobile/reduced-motion/no-WebGL modes. Run a +60-second renderer test and assert bounded memory, no accumulating listeners, +and no non-finite shader input; report the measured runner budget without a +universal FPS claim. + +- [ ] **Step 6: Commit** + +```text +[feat][Site][3/5] Render the AgenNet field + +Root cause: NA +Solution: Add bounded light ribbons and an ordered responsive point field. +Risks: GPU output varies subtly while the CSS fallback stays authoritative. +Dependency: Sites shell task 1. +Links: docs/superpowers/specs/ +2026-08-15-preview-release-skill-sites-design.md +``` + +## Task 4: Build bilingual installation and usage documentation + +**Files:** + +- Create: `site/content/docs.ts` +- Create: `site/components/docs/DocsLayout.tsx` +- Create: `site/components/docs/DocsSidebar.tsx` +- Create: `site/components/docs/InstallPanel.tsx` +- Create: `site/components/docs/CopyButton.tsx` +- Create: `site/components/docs/Callout.tsx` +- Create: `site/components/docs/CodeBlock.tsx` +- Create: `site/tests/docs-content.test.tsx` +- Create: `site/e2e/docs.spec.ts` +- Modify: bilingual docs route modules + +**Interfaces:** + +```ts +export type DocSlug = + | 'index' + | 'install' + | 'create-domain' + | 'join-node' + | 'agent-setup' + | 'run-pursuit' + | 'lifecycle' + | 'security' + | 'limitations'; + +export interface DocRecord { + slug: DocSlug; + locale: Locale; + title: string; + description: string; + sections: readonly DocSection[]; +} + +export interface DocSection { + id: string; + heading: string; + blocks: readonly DocBlock[]; +} + +export type DocBlock = + | { kind: 'paragraph'; text: string } + | { kind: 'command'; commandKey: string } + | { kind: 'callout'; tone: 'info' | 'warning'; text: string }; +``` + +- [ ] **Step 1: Write content and interaction RED tests** + +Require every slug in both locales, exact command parity, correct language +alternates and detail metadata, keyboard sidebar, touch layout, copy success +and failure feedback, fixed installer selection for macOS/Linux/WSL2, secret +warnings beside enrollment, and limitations/status on every installation path. + +- [ ] **Step 2: Run RED** + +Run focused docs tests and confirm records/components are missing. + +- [ ] **Step 3: Implement typed docs from canonical data** + +Build concise guides for installation, Domain creation, node join, the Agent +sentence and Skill, pursuit, lifecycle, security, and limitations. Commands +come from generated data or canonical guide records. Documentation never asks +for an Invitation in a browser or chat. + +- [ ] **Step 4: Implement accessible navigation and local copy controls** + +Add responsive docs navigation, anchored headings, skip link, visible focus, +copy buttons with live-region feedback, locale switch preserving the slug, and +install tabs. Use no analytics, persistence, or remote code execution. + +- [ ] **Step 5: Verify GREEN** + +Run unit/E2E tests, keyboard checks, 390×844 and 1440×1000 layouts, no-JS +content checks, localized detail metadata validation, and the production build. + +- [ ] **Step 6: Commit** + +```text +[feat][Site][4/5] Publish bilingual usage docs + +Root cause: NA +Solution: Add typed install, bootstrap, lifecycle, and security documentation. +Risks: New CLI commands intentionally require regenerated content. +Dependency: Generated public data task 2. +Links: plan/03-v2-public-sites.md +``` + +## Task 5: Seal and package the Sites release + +**Files:** + +- Create: `site/public/og.png` +- Modify: `site/app/layout.tsx` +- Modify: `site/.openai/hosting.json` +- Create: `site/tests/publication.test.ts` +- Create: `site/scripts/preflight-site.ts` +- Modify: `README.md` +- Modify: `ROADMAP.md` + +**Interfaces:** + +- Consumes: Tasks 1–4 and the published preview manifest. +- Produces: the exact validated Sites source and package consumed by + `sites:sites-hosting`. + +- [ ] **Step 1: Write the failing publication preflight** + +Require all routes, raw bootstrap files, generated-data cleanliness, site +metadata, public preview status, accessibility landmarks, reduced-motion +fallback, zero starter artifacts, no secrets/private paths/IPs/evidence, and a +valid `.openai/hosting.json` containing only the Sites project ID. + +- [ ] **Step 2: Run RED** + +Run publication tests and record missing social metadata and hosting state. + +- [ ] **Step 3: Generate one cohesive social preview card** + +After headline, palette, typography, and motifs are frozen, make exactly one +`imagegen` request for a landscape AgenNet social card containing the correct +project name and preview positioning. Inspect text; retry once only if +unusable. Save the accepted card as `public/og.png`; otherwise omit the image. + +- [ ] **Step 4: Complete metadata and Sites packaging** + +Use host-derived absolute URLs for root Open Graph/X metadata. Detail docs use +record-specific titles/descriptions and explicitly clear inherited images. +Create or reuse the Sites project, persist only `project_id`, build once, and +package with the Sites plugin helper. + +- [ ] **Step 5: Run final GREEN gates** + +Run tests, `npm run build`, generated checks, Playwright scenarios, publication +preflight, archive validation, and scans. Keep the preview server alive until +hosting finishes. + +- [ ] **Step 6: Commit** + +```text +[chore][Site][5/5] Seal GPT Sites release + +Root cause: NA +Solution: Validate and package the exact bilingual preview site for hosting. +Risks: Public deployment still requires an explicit Sites access decision. +Dependency: Site tasks 1-4 and published preview manifest. +Links: plan/04-v1-preview-release-orchestration.md +``` diff --git a/plan/04-v1-preview-release-orchestration.md b/plan/04-v1-preview-release-orchestration.md new file mode 100644 index 0000000..7295f41 --- /dev/null +++ b/plan/04-v1-preview-release-orchestration.md @@ -0,0 +1,290 @@ +# AgenNet Preview Release and Sites Publication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> `superpowers:subagent-driven-development` (recommended) or +> `superpowers:executing-plans` to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. Use `github:yeet` for source branch +> publication and `sites:sites-hosting` for the public documentation site. + +**Goal:** Publish `v0.2.0-preview.1`, deploy the bilingual GPT Sites experience, +and prove the public one-sentence bootstrap path from a fresh Agent inside +WSL2 without claiming the later physical AgenNet acceptance milestone. + +**Architecture:** The reviewed release candidate is tagged once and GitHub +Actions publishes immutable artifacts. The validated Sites source consumes the +published manifest and is deployed publicly through GPT Sites. A fresh WSL2 +Agent then follows the public raw guide and Skill; only redacted public status +is retained as installation-surface evidence. + +**Tech Stack:** Git, GitHub Releases and Actions, GPT Sites hosting, WSL2, +AgenNet CLI and Skill. + +## Global Constraints + +- Publish only from a clean reviewed commit on `feat/release-skill-sites`. +- Tag exactly `v0.2.0-preview.1`; never move or recreate the tag. +- Do not publish when any archive, checksum, attestation, guide, Skill, or site + build is missing or inconsistent. +- Site access is public because the user requested documentation for everyone; + do not publish invitations, private endpoints, credentials, or evidence. +- WSL2 Agent testing never sends an Invitation, passphrase, key, token, private + IP, CIDR, or raw diagnostic log into chat. +- Passing WSL installation proves the public installation surface only. The + existing two-physical-device Task 14 remains pending. +- Every failure preserves the last known good release/site and records a + versioned correction rather than mutating published assets. + +--- + +## Task 1: Publish the GitHub prerelease + +**Files:** + +- Consume: release candidate and preflight artifacts from + `plan/02-v2-installation-surfaces.md` +- Modify: `ROADMAP.md` only after publication succeeds +- Create: `.superpowers/sdd/02-v1-release-skill-sites/release-report.md` + as ignored local evidence + +**Interfaces:** + +- Produces immutable public URLs under: + +```text +https://github.com/Nexa-Language/AgenNet/releases/tag/v0.2.0-preview.1 +https://github.com/Nexa-Language/AgenNet/releases/download/ +v0.2.0-preview.1/{manifest-approved-asset-name} +``` + +- [ ] **Step 1: Verify the exact release candidate** + +Run default/all-feature Rust gates, archive/installer/guide/Skill preflights, +`git diff --check`, secret scans, `git fsck`, and confirm local/remote branch +HEAD equality. Record the exact full commit SHA. + +- [ ] **Step 2: Push the reviewed source branch** + +Use SSH `git push -u origin feat/release-skill-sites`. Do not use force push or +alter Git configuration. Confirm the remote branch resolves to the exact local +SHA. + +- [ ] **Step 3: Create and push one annotated tag** + +First assert that neither local nor remote already contains the tag. Then run: + +```bash +git tag -a v0.2.0-preview.1 \ + -m "AgenNet v0.2.0-preview.1 Developer Preview" +git push origin refs/tags/v0.2.0-preview.1 +``` + +If either command reports an existing divergent tag, stop; never delete or +move it automatically. + +- [ ] **Step 4: Wait for the tag workflow and inspect every asset** + +Require all four native archives, `release-manifest-v1.json`, `SHA256SUMS`, +`install.sh`, both raw Agent guides, the Skill package, release notes, and four +attestations. Download to a temporary directory, rerun offline verification, +and compare manifest commit/version with the tag. + +- [ ] **Step 5: Record the release result** + +Update ROADMAP only after the public prerelease and downloads pass. The report +contains public asset names, sizes, hashes, workflow conclusion, and status; +it contains no credential, IP, local path, or source credential. + +- [ ] **Step 6: Commit the publication record** + +```text +[chore][Release] Record preview publication + +Root cause: NA +Solution: Record the verified immutable v0.2.0-preview.1 prerelease. +Risks: Physical two-device acceptance remains pending. +Dependency: GitHub tag v0.2.0-preview.1. +Links: GitHub release v0.2.0-preview.1. +``` + +## Task 2: Publish the GPT Sites landing and documentation + +**Files:** + +- Consume: exact validated source from `plan/03-v2-public-sites.md` +- Modify: `site/.openai/hosting.json` with `project_id` only +- Modify: `ROADMAP.md` after deployment succeeds +- Create: ignored deployment report under + `.superpowers/sdd/02-v1-release-skill-sites/` + +**Interfaces:** + +- Produces one public Sites URL and stable raw bootstrap URLs: + +```rust +pub struct PublishedSiteV1 { + pub base_url: String, + pub agent_guide_zh_url: String, + pub agent_guide_en_url: String, + pub manifest_url: String, + pub release_version: String, +} +``` + +- [ ] **Step 1: Synchronize from the published manifest and rebuild once** + +Fetch the public fixed manifest, validate it, regenerate site data, require a +clean generated diff, and run the exact production build plus publication +preflight. Do not substitute local draft manifest data. + +- [ ] **Step 2: Create or reuse the Sites project** + +Call `create_site` once for a new project, persist only `project_id` in +`.openai/hosting.json`, and retain the returned source credential in memory. +If quota or permission fails, stop without changing the slug or access level. + +- [ ] **Step 3: Commit and push exact validated site source** + +Commit the hosting metadata with the five-section format. Push through the +temporary per-command HTTP authorization header returned by Sites; never store +the credential in a remote URL, Git config, file, log, or final response. Use +the pushed branch-head SHA as the Sites `commit_sha`. + +- [ ] **Step 4: Package, save, and publicly deploy one version** + +Use the Sites plugin `scripts/package-site.sh`, inspect required worker/static +outputs, save one version, and deploy publicly. The user has explicitly asked +for a public teaching site; if the connector still presents an access-level +approval gate, request that exact public approval before deployment. + +- [ ] **Step 5: Poll to a terminal result and open the deployed URL** + +Poll `get_deployment_status` until succeeded or failed. On success, reuse the +single preview browser tab and navigate it to the deployed URL. Verify the +landing, install page, both raw Agent guide URLs, public manifest, and one +Chinese plus one English detail route using bounded HTTP checks. + +- [ ] **Step 6: Record deployment without implementation details** + +Update ROADMAP with the public URL, visible status, route set, and release +version. Keep source credentials, project internals, archives, and temporary +paths out of user-facing output. + +## Task 3: Run the fresh WSL2 Agent one-sentence acceptance + +**Files:** + +- Create: `docs/testing/wsl-agent-install-acceptance.md` +- Create: `schemas/wsl-agent-install-evidence-v1.schema.json` +- Create: `scripts/verify-wsl-agent-install-evidence.sh` +- Create: ignored redacted evidence under `.local/evidence/` +- Modify: `ROADMAP.md` after a passing run + +**Interfaces:** + +- Consumes: public Sites Agent guide URL, Skill package, GitHub prerelease, and + a fresh Agent process running inside WSL2. +- Produces redacted installation-surface evidence: + +```rust +pub struct WslAgentInstallEvidenceV1 { + pub schema_version: u32, // exactly 1 + pub result: String, // exactly "pass" + pub environment: String, // exactly "wsl2" + pub release: String, // exactly "0.2.0-preview.1" + pub git_commit: String, // equals the Task 1 published commit + pub binary_verified: bool, + pub skill_loaded: bool, + pub systemd_user_ready: bool, + pub secret_boundary_respected: bool, + pub node_join_completed_locally: bool, + pub service_running: bool, + pub doctor_public_status: String, // exactly "healthy" +} +``` + +The final schema substitutes the exact public commit at generation time and +rejects unknown fields; it never stores Node ID, IP, invitation, paths, logs, +tokens, credentials, or hashes of secrets. + +- [ ] **Step 1: Write the evidence schema and failing verifier cases** + +Reject wrong environment/version/commit, native-Windows claims, missing Skill, +unverified binary, no systemd, skipped secret boundary, failed join, stopped +service, unhealthy doctor, unknown fields, private IP/path patterns, and +secret-shaped strings. + +- [ ] **Step 2: Start a genuinely fresh Agent inside WSL2** + +The Agent must not receive repository files, local unpublished guides, expected +command output, or a preinstalled AgenNet Skill. Construct the sentence from +the validated `PublishedSiteV1.agent_guide_zh_url` returned by Task 2, and pass +the resulting literal URL to the Agent: + +```text +阅读 AgenNet 官方节点安装指南「Task 2 已验证的中文指南 URL」,把这台 +WSL2 机器配置成 Provider 节点;所有 Invitation 和密码只让我在本机 +TTY 输入。 +``` + +- [ ] **Step 3: Observe the installation and secret handoff** + +Confirm the Agent selects the fixed release, verifies it, installs/loads the +Skill, checks WSL2/systemd/overlay public readiness, and stops before enrollment +with the exact local TTY instruction. The human performs `agenet node join` +without pasting secrets or raw output back to the Agent. + +- [ ] **Step 4: Resume from public state and verify service health** + +The Agent runs only public status/doctor commands, starts the user service, +and reports stable public fields. Independently confirm the binary +version/commit, service manager state, and doctor result on the WSL machine. + +- [ ] **Step 5: Generate and validate redacted evidence** + +Manually construct the allowlisted evidence object, run the offline verifier, +and scan the complete transfer surface for invitations, keys, tokens, private +addresses, local paths, prompts, and raw logs. Any match makes the run fail. + +- [ ] **Step 6: Record the WSL installation result honestly** + +If passing, mark the one-sentence installation surface verified on WSL2. Do not +mark physical AgenNet multi-host acceptance complete; continue the existing +two-device runbook as the next milestone. + +## Task 4: Hand off to physical AgenNet acceptance + +**Files:** + +- Modify: `docs/testing/two-device-acceptance.md` only if public release commands + replace repository-local setup +- Modify: `ROADMAP.md` +- Modify: `README.md` + +**Interfaces:** + +- Consumes: published release/site and passing WSL installation evidence. +- Produces an updated physical runbook; no milestone commit until that run + independently passes. + +- [ ] **Step 1: Replace development-only setup with the fixed release path** + +Both physical devices install `v0.2.0-preview.1` through the public verified +installer and confirm the same release commit. Preserve all existing TTY, +private-overlay, mTLS, model-env, Contract, revocation, and evidence gates. + +- [ ] **Step 2: Re-run runbook/schema consistency checks** + +Require exact public URLs and version while rejecting private data and any +claim that WSL installation alone proves multi-host behavior. + +- [ ] **Step 3: Commit only the handoff documentation** + +```text +[doc] Use the public preview in physical tests + +Root cause: NA +Solution: Make physical acceptance consume the same published installer. +Risks: The physical multi-host result remains pending. +Dependency: Preview release, Sites, and WSL installation acceptance. +Links: docs/testing/two-device-acceptance.md +``` From 425014a2e6d901f644c244854079ebe990ecd509 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 17:23:25 +0800 Subject: [PATCH 46/67] [feat][Release][1/6] Define preview manifest Root cause: NA Solution: Add a strict deterministic four-platform release manifest. Risks: New targets require a schema-versioned manifest revision. Dependency: Host preview commit 7db2556. Links: plan/02-v2-installation-surfaces.md --- Cargo.toml | 4 + ROADMAP.md | 9 + schemas/release-manifest-v1.schema.json | 61 ++++ src/bin/agenet-release-manifest.rs | 108 +++++++ src/lib.rs | 1 + src/release/manifest.rs | 327 ++++++++++++++++++++++ src/release/mod.rs | 5 + tests/release_manifest.rs | 358 ++++++++++++++++++++++++ 8 files changed, 873 insertions(+) create mode 100644 schemas/release-manifest-v1.schema.json create mode 100644 src/bin/agenet-release-manifest.rs create mode 100644 src/release/manifest.rs create mode 100644 src/release/mod.rs create mode 100644 tests/release_manifest.rs diff --git a/Cargo.toml b/Cargo.toml index 11eb954..c5003cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,10 @@ cli-test-fixture = [] name = "agenet" path = "src/main.rs" +[[bin]] +name = "agenet-release-manifest" +path = "src/bin/agenet-release-manifest.rs" + [dependencies] age = "=0.12.1" axum = "=0.8.9" diff --git a/ROADMAP.md b/ROADMAP.md index a9a29af..54b0b8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Define the immutable preview release manifest + +- **Change**: Added the strict `v0.2.0-preview.1` four-platform release manifest model, checked-in JSON Schema, and an offline deterministic manifest generator. +- **Files**: `src/release/`, `src/bin/agenet-release-manifest.rs`, `schemas/release-manifest-v1.schema.json`, `tests/release_manifest.rs`, `src/lib.rs`, and `Cargo.toml`. +- **Decision reason**: The installer, Agent Skill, GitHub Release, and GPT Sites must consume one exact source of truth instead of independently reconstructing target names, URLs, hashes, or version claims. +- **Security boundary**: Validation requires the exact target set, fixed GitHub release paths, safe filenames, lowercase full hashes, bounded nonzero sizes, exact attestation subjects, a full commit SHA, and a real UTC publication timestamp. Generation is offline and publishes the JSON atomically. +- **Evidence**: The focused suite covers strict deserialization, schema parity, deterministic byte output, actual archive hashing, replacement without temporary-file residue, impossible timestamps, traversal, incomplete targets, and URL/version mismatches. +- **Release boundary**: No archive, installer, tag, GitHub Release, Skill, or public site has been published by this task. Physical acceptance remains pending. + ## 2026-08-15 — Prioritize the preview release, bootstrap Skill, and GPT Sites - **Change**: Reordered the remaining Developer Preview work so a fixed GitHub Release, verified macOS/Linux/WSL2 installer, Agent bootstrap guide and Skill, and bilingual GPT Sites documentation publish before the physical two-device acceptance run. diff --git a/schemas/release-manifest-v1.schema.json b/schemas/release-manifest-v1.schema.json new file mode 100644 index 0000000..8d32169 --- /dev/null +++ b/schemas/release-manifest-v1.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agenet.dev/schemas/release-manifest-v1.schema.json", + "title": "AgenNet Release Manifest v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "project", + "version", + "git_commit", + "published_at", + "artifacts" + ], + "properties": { + "schema_version": { "const": 1 }, + "project": { "const": "AgenNet" }, + "version": { "const": "0.2.0-preview.1" }, + "git_commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "published_at": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$" + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "required": [ + "macos-arm64", + "macos-x86_64", + "linux-arm64", + "linux-x86_64" + ], + "properties": { + "macos-arm64": { "$ref": "#/$defs/artifact" }, + "macos-x86_64": { "$ref": "#/$defs/artifact" }, + "linux-arm64": { "$ref": "#/$defs/artifact" }, + "linux-x86_64": { "$ref": "#/$defs/artifact" } + } + } + }, + "$defs": { + "artifact": { + "type": "object", + "additionalProperties": false, + "required": [ + "file_name", + "download_url", + "sha256", + "size_bytes", + "attestation_subject" + ], + "properties": { + "file_name": { "type": "string", "minLength": 1, "maxLength": 180 }, + "download_url": { "type": "string", "format": "uri" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "size_bytes": { "type": "integer", "minimum": 1, "maximum": 536870912 }, + "attestation_subject": { "type": "string", "minLength": 1, "maxLength": 180 } + } + } + } +} diff --git a/src/bin/agenet-release-manifest.rs b/src/bin/agenet-release-manifest.rs new file mode 100644 index 0000000..fe1902c --- /dev/null +++ b/src/bin/agenet-release-manifest.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeMap; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use agenet::release::{PREVIEW_VERSION, ReleaseArtifact, ReleaseManifestV1, ReleaseTarget}; +use clap::Parser; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Parser)] +#[command(name = "agenet-release-manifest")] +#[command(about = "Generate the fixed AgenNet preview release manifest offline")] +struct Args { + #[arg(long)] + version: String, + #[arg(long)] + commit: String, + #[arg(long)] + published_at: String, + #[arg(long)] + macos_arm64: PathBuf, + #[arg(long)] + macos_x86_64: PathBuf, + #[arg(long)] + linux_arm64: PathBuf, + #[arg(long)] + linux_x86_64: PathBuf, + #[arg(long)] + output: PathBuf, +} + +fn main() -> ExitCode { + match generate(Args::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("release manifest generation failed: {error}"); + ExitCode::from(2) + } + } +} + +fn generate(args: Args) -> Result<(), &'static str> { + if args.version != PREVIEW_VERSION { + return Err("UnsupportedReleaseVersion"); + } + let paths = BTreeMap::from([ + (ReleaseTarget::MacosArm64, args.macos_arm64), + (ReleaseTarget::MacosX86_64, args.macos_x86_64), + (ReleaseTarget::LinuxArm64, args.linux_arm64), + (ReleaseTarget::LinuxX86_64, args.linux_x86_64), + ]); + let artifacts = paths + .into_iter() + .map(|(target, path)| artifact_from_path(target, &args.version, &path)) + .collect::, _>>()?; + let manifest = ReleaseManifestV1 { + schema_version: 1, + project: "AgenNet".to_owned(), + version: args.version, + git_commit: args.commit, + published_at: args.published_at, + artifacts, + }; + manifest + .write_pretty_json_atomic(&args.output) + .map_err(|_| "InvalidReleaseManifest") +} + +fn artifact_from_path( + target: ReleaseTarget, + version: &str, + path: &Path, +) -> Result<(ReleaseTarget, ReleaseArtifact), &'static str> { + let expected_name = target.archive_name(version); + if path.file_name().and_then(|name| name.to_str()) != Some(&expected_name) { + return Err("UnexpectedArchiveName"); + } + let mut file = File::open(path).map_err(|_| "ArchiveReadFailed")?; + let size_bytes = file.metadata().map_err(|_| "ArchiveReadFailed")?.len(); + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|_| "ArchiveReadFailed")?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + let sha256 = digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let download_url = format!( + "https://github.com/Nexa-Language/AgenNet/releases/download/v{version}/{expected_name}" + ); + Ok(( + target, + ReleaseArtifact { + file_name: expected_name.clone(), + download_url, + sha256, + size_bytes, + attestation_subject: expected_name, + }, + )) +} diff --git a/src/lib.rs b/src/lib.rs index 8faf84e..7370de6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod demo; pub mod evidence; pub mod node; pub mod protocol; +pub mod release; pub mod runtime; pub mod service; pub mod transport; diff --git a/src/release/manifest.rs b/src/release/manifest.rs new file mode 100644 index 0000000..205c939 --- /dev/null +++ b/src/release/manifest.rs @@ -0,0 +1,327 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use time::{Date, Month, Time}; +use url::Url; +use uuid::Uuid; + +pub const PREVIEW_VERSION: &str = "0.2.0-preview.1"; + +const PROJECT_NAME: &str = "AgenNet"; +const SCHEMA_VERSION: u32 = 1; +const MAX_ARTIFACT_SIZE_BYTES: u64 = 512 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum ReleaseTarget { + #[serde(rename = "macos-arm64")] + MacosArm64, + #[serde(rename = "macos-x86_64")] + MacosX86_64, + #[serde(rename = "linux-arm64")] + LinuxArm64, + #[serde(rename = "linux-x86_64")] + LinuxX86_64, +} + +impl ReleaseTarget { + pub const ALL: [Self; 4] = [ + Self::MacosArm64, + Self::MacosX86_64, + Self::LinuxArm64, + Self::LinuxX86_64, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::MacosArm64 => "macos-arm64", + Self::MacosX86_64 => "macos-x86_64", + Self::LinuxArm64 => "linux-arm64", + Self::LinuxX86_64 => "linux-x86_64", + } + } + + pub const fn rust_triple(self) -> &'static str { + match self { + Self::MacosArm64 => "aarch64-apple-darwin", + Self::MacosX86_64 => "x86_64-apple-darwin", + Self::LinuxArm64 => "aarch64-unknown-linux-gnu", + Self::LinuxX86_64 => "x86_64-unknown-linux-gnu", + } + } + + pub fn archive_name(self, version: &str) -> String { + format!("agenet-v{version}-{}.tar.gz", self.rust_triple()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReleaseArtifact { + pub file_name: String, + pub download_url: String, + pub sha256: String, + pub size_bytes: u64, + pub attestation_subject: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReleaseManifestV1 { + pub schema_version: u32, + pub project: String, + pub version: String, + pub git_commit: String, + pub published_at: String, + pub artifacts: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReleaseManifestError { + UnsupportedSchemaVersion, + InvalidProject, + UnsupportedReleaseVersion, + InvalidGitCommit, + InvalidPublishedAt, + IncompleteTargetSet, + DuplicateFileName, + InvalidFileName, + InvalidDownloadUrl, + VersionUrlMismatch, + FileNameUrlMismatch, + InvalidSha256, + InvalidArtifactSize, + AttestationSubjectMismatch, + Serialization, + Io, +} + +impl std::fmt::Display for ReleaseManifestError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{self:?}") + } +} + +impl std::error::Error for ReleaseManifestError {} + +impl ReleaseManifestV1 { + pub fn validate(&self) -> Result<(), ReleaseManifestError> { + validate_header(self)?; + validate_target_set(&self.artifacts)?; + + let mut file_names = BTreeSet::new(); + for artifact in self.artifacts.values() { + if !file_names.insert(&artifact.file_name) { + return Err(ReleaseManifestError::DuplicateFileName); + } + } + for (target, artifact) in &self.artifacts { + validate_artifact(&self.version, *target, artifact)?; + } + Ok(()) + } + + pub fn to_pretty_json(&self) -> Result { + self.validate()?; + let mut bytes = + serde_json::to_vec_pretty(self).map_err(|_| ReleaseManifestError::Serialization)?; + bytes.push(b'\n'); + String::from_utf8(bytes).map_err(|_| ReleaseManifestError::Serialization) + } + + pub fn write_pretty_json_atomic(&self, path: &Path) -> Result<(), ReleaseManifestError> { + let json = self.to_pretty_json()?; + atomic_write_public(path, json.as_bytes()) + } +} + +fn validate_header(manifest: &ReleaseManifestV1) -> Result<(), ReleaseManifestError> { + if manifest.schema_version != SCHEMA_VERSION { + return Err(ReleaseManifestError::UnsupportedSchemaVersion); + } + if manifest.project != PROJECT_NAME { + return Err(ReleaseManifestError::InvalidProject); + } + if manifest.version != PREVIEW_VERSION { + return Err(ReleaseManifestError::UnsupportedReleaseVersion); + } + if !is_lower_hex(&manifest.git_commit, 40) { + return Err(ReleaseManifestError::InvalidGitCommit); + } + if !is_utc_second_timestamp(&manifest.published_at) { + return Err(ReleaseManifestError::InvalidPublishedAt); + } + Ok(()) +} + +fn validate_target_set( + artifacts: &BTreeMap, +) -> Result<(), ReleaseManifestError> { + if artifacts.len() != ReleaseTarget::ALL.len() + || !ReleaseTarget::ALL + .iter() + .all(|target| artifacts.contains_key(target)) + { + return Err(ReleaseManifestError::IncompleteTargetSet); + } + Ok(()) +} + +fn validate_artifact( + version: &str, + target: ReleaseTarget, + artifact: &ReleaseArtifact, +) -> Result<(), ReleaseManifestError> { + let expected_file_name = target.archive_name(version); + if artifact.file_name != expected_file_name || !is_safe_file_name(&artifact.file_name) { + return Err(ReleaseManifestError::InvalidFileName); + } + if !is_lower_hex(&artifact.sha256, 64) { + return Err(ReleaseManifestError::InvalidSha256); + } + if artifact.size_bytes == 0 || artifact.size_bytes > MAX_ARTIFACT_SIZE_BYTES { + return Err(ReleaseManifestError::InvalidArtifactSize); + } + if artifact.attestation_subject != artifact.file_name { + return Err(ReleaseManifestError::AttestationSubjectMismatch); + } + validate_download_url(version, &artifact.file_name, &artifact.download_url) +} + +fn validate_download_url( + version: &str, + file_name: &str, + download_url: &str, +) -> Result<(), ReleaseManifestError> { + let url = Url::parse(download_url).map_err(|_| ReleaseManifestError::InvalidDownloadUrl)?; + if url.scheme() != "https" + || url.host_str() != Some("github.com") + || url.port().is_some() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ReleaseManifestError::InvalidDownloadUrl); + } + + let prefix = format!("/Nexa-Language/AgenNet/releases/download/v{version}/"); + if !url.path().starts_with(&prefix) { + let release_prefix = "/Nexa-Language/AgenNet/releases/download/"; + if url.path().starts_with(release_prefix) { + return Err(ReleaseManifestError::VersionUrlMismatch); + } + return Err(ReleaseManifestError::InvalidDownloadUrl); + } + if url.path() != format!("{prefix}{file_name}") { + return Err(ReleaseManifestError::FileNameUrlMismatch); + } + Ok(()) +} + +fn is_safe_file_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 180 + && !value.contains('/') + && !value.contains('\\') + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) +} + +fn is_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn is_utc_second_timestamp(value: &str) -> bool { + if value.len() != 20 { + return false; + } + let bytes = value.as_bytes(); + if bytes[4] != b'-' + || bytes[7] != b'-' + || bytes[10] != b'T' + || bytes[13] != b':' + || bytes[16] != b':' + || bytes[19] != b'Z' + { + return false; + } + if !bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7 | 10 | 13 | 16 | 19) || byte.is_ascii_digit()) + { + return false; + } + + let parse = + |range: std::ops::Range| value.get(range).and_then(|part| part.parse::().ok()); + let Some(year) = value.get(0..4).and_then(|part| part.parse::().ok()) else { + return false; + }; + let Some(month) = parse(5..7).and_then(|month| Month::try_from(month).ok()) else { + return false; + }; + let (Some(day), Some(hour), Some(minute), Some(second)) = + (parse(8..10), parse(11..13), parse(14..16), parse(17..19)) + else { + return false; + }; + Date::from_calendar_date(year, month, day).is_ok() + && Time::from_hms(hour, minute, second).is_ok() +} + +fn atomic_write_public(path: &Path, bytes: &[u8]) -> Result<(), ReleaseManifestError> { + let parent = normalized_parent(path); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| is_safe_file_name(name)) + .ok_or(ReleaseManifestError::Io)?; + let temp_path = parent.join(format!(".{file_name}.{}.tmp", Uuid::new_v4())); + let result = write_and_publish(&temp_path, path, parent, bytes); + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +fn write_and_publish( + temp_path: &Path, + final_path: &Path, + parent: &Path, + bytes: &[u8], +) -> Result<(), ReleaseManifestError> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o644); + } + let mut file = options + .open(temp_path) + .map_err(|_| ReleaseManifestError::Io)?; + file.write_all(bytes) + .map_err(|_| ReleaseManifestError::Io)?; + file.flush().map_err(|_| ReleaseManifestError::Io)?; + file.sync_all().map_err(|_| ReleaseManifestError::Io)?; + fs::rename(temp_path, final_path).map_err(|_| ReleaseManifestError::Io)?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| ReleaseManifestError::Io) +} + +fn normalized_parent(path: &Path) -> &Path { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} diff --git a/src/release/mod.rs b/src/release/mod.rs new file mode 100644 index 0000000..b470a60 --- /dev/null +++ b/src/release/mod.rs @@ -0,0 +1,5 @@ +mod manifest; + +pub use manifest::{ + PREVIEW_VERSION, ReleaseArtifact, ReleaseManifestError, ReleaseManifestV1, ReleaseTarget, +}; diff --git a/tests/release_manifest.rs b/tests/release_manifest.rs new file mode 100644 index 0000000..8067205 --- /dev/null +++ b/tests/release_manifest.rs @@ -0,0 +1,358 @@ +use std::collections::BTreeMap; +use std::fs; +use std::process::Command; + +use agenet::release::{ + PREVIEW_VERSION, ReleaseArtifact, ReleaseManifestError, ReleaseManifestV1, ReleaseTarget, +}; +use serde_json::{Value, json}; + +const COMMIT: &str = "7db2556de52b3aa8c59a76bb037eb2c1aac0df36"; + +fn archive_name(target: ReleaseTarget) -> String { + format!("agenet-v{PREVIEW_VERSION}-{}.tar.gz", target.rust_triple()) +} + +fn artifact(target: ReleaseTarget) -> ReleaseArtifact { + let file_name = archive_name(target); + ReleaseArtifact { + file_name: file_name.clone(), + download_url: format!( + "https://github.com/Nexa-Language/AgenNet/releases/download/v{PREVIEW_VERSION}/{file_name}" + ), + sha256: "ab".repeat(32), + size_bytes: 1_024, + attestation_subject: file_name, + } +} + +fn valid_manifest() -> ReleaseManifestV1 { + ReleaseManifestV1 { + schema_version: 1, + project: "AgenNet".to_owned(), + version: PREVIEW_VERSION.to_owned(), + git_commit: COMMIT.to_owned(), + published_at: "2026-08-15T09:00:00Z".to_owned(), + artifacts: ReleaseTarget::ALL + .into_iter() + .map(|target| (target, artifact(target))) + .collect::>(), + } +} + +#[test] +fn validates_the_complete_preview_manifest() { + assert_eq!(valid_manifest().validate(), Ok(())); +} + +#[test] +fn rejects_a_missing_release_target() { + let mut manifest = valid_manifest(); + manifest.artifacts.remove(&ReleaseTarget::LinuxArm64); + + assert_eq!( + manifest.validate(), + Err(ReleaseManifestError::IncompleteTargetSet) + ); +} + +#[test] +fn rejects_a_duplicate_archive_filename() { + let mut manifest = valid_manifest(); + let duplicate = archive_name(ReleaseTarget::LinuxX86_64); + manifest + .artifacts + .get_mut(&ReleaseTarget::LinuxArm64) + .expect("Linux arm64 fixture") + .file_name = duplicate; + + assert_eq!( + manifest.validate(), + Err(ReleaseManifestError::DuplicateFileName) + ); +} + +#[test] +fn rejects_invalid_sha256_and_commit_values() { + let mut bad_hash = valid_manifest(); + bad_hash + .artifacts + .get_mut(&ReleaseTarget::MacosArm64) + .expect("macOS arm64 fixture") + .sha256 = "ABC123".to_owned(); + assert_eq!( + bad_hash.validate(), + Err(ReleaseManifestError::InvalidSha256) + ); + + let mut short_commit = valid_manifest(); + short_commit.git_commit = "7db2556".to_owned(); + assert_eq!( + short_commit.validate(), + Err(ReleaseManifestError::InvalidGitCommit) + ); +} + +#[test] +fn rejects_wrong_project_version_or_schema() { + let mut wrong_project = valid_manifest(); + wrong_project.project = "AgenNET".to_owned(); + assert_eq!( + wrong_project.validate(), + Err(ReleaseManifestError::InvalidProject) + ); + + let mut wrong_version = valid_manifest(); + wrong_version.version = "0.2.0".to_owned(); + assert_eq!( + wrong_version.validate(), + Err(ReleaseManifestError::UnsupportedReleaseVersion) + ); + + let mut wrong_schema = valid_manifest(); + wrong_schema.schema_version = 2; + assert_eq!( + wrong_schema.validate(), + Err(ReleaseManifestError::UnsupportedSchemaVersion) + ); +} + +#[test] +fn rejects_structurally_valid_but_impossible_publication_times() { + for published_at in [ + "2026-13-15T09:00:00Z", + "2026-02-30T09:00:00Z", + "2026-08-15T24:00:00Z", + "2026-08-15T09:60:00Z", + "2026-08-15T09:00:60Z", + ] { + let mut manifest = valid_manifest(); + manifest.published_at = published_at.to_owned(); + assert_eq!( + manifest.validate(), + Err(ReleaseManifestError::InvalidPublishedAt), + "timestamp: {published_at}" + ); + } +} + +#[test] +fn rejects_non_github_or_mismatched_download_urls() { + let cases = [ + ( + "http://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0-preview.1/archive.tar.gz", + ReleaseManifestError::InvalidDownloadUrl, + ), + ( + "https://example.com/Nexa-Language/AgenNet/releases/download/v0.2.0-preview.1/archive.tar.gz", + ReleaseManifestError::InvalidDownloadUrl, + ), + ( + "https://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0/archive.tar.gz", + ReleaseManifestError::VersionUrlMismatch, + ), + ( + "https://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0-preview.1/other.tar.gz", + ReleaseManifestError::FileNameUrlMismatch, + ), + ]; + + for (url, expected) in cases { + let mut manifest = valid_manifest(); + manifest + .artifacts + .get_mut(&ReleaseTarget::LinuxX86_64) + .expect("Linux x86_64 fixture") + .download_url = url.to_owned(); + assert_eq!(manifest.validate(), Err(expected), "URL: {url}"); + } +} + +#[test] +fn rejects_unsafe_names_sizes_and_attestation_subjects() { + let mut traversal = valid_manifest(); + traversal + .artifacts + .get_mut(&ReleaseTarget::LinuxX86_64) + .expect("Linux x86_64 fixture") + .file_name = "../agenet.tar.gz".to_owned(); + assert_eq!( + traversal.validate(), + Err(ReleaseManifestError::InvalidFileName) + ); + + let mut empty = valid_manifest(); + empty + .artifacts + .get_mut(&ReleaseTarget::LinuxX86_64) + .expect("Linux x86_64 fixture") + .size_bytes = 0; + assert_eq!( + empty.validate(), + Err(ReleaseManifestError::InvalidArtifactSize) + ); + + let mut subject = valid_manifest(); + subject + .artifacts + .get_mut(&ReleaseTarget::LinuxX86_64) + .expect("Linux x86_64 fixture") + .attestation_subject = "different.tar.gz".to_owned(); + assert_eq!( + subject.validate(), + Err(ReleaseManifestError::AttestationSubjectMismatch) + ); +} + +#[test] +fn deserialization_rejects_unknown_fields() { + let mut value = serde_json::to_value(valid_manifest()).expect("serialize fixture"); + value + .as_object_mut() + .expect("manifest object") + .insert("unexpected".to_owned(), json!(true)); + + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn serialization_is_stable_and_target_ordered() { + let first = valid_manifest() + .to_pretty_json() + .expect("serialize manifest"); + let second = valid_manifest() + .to_pretty_json() + .expect("serialize manifest"); + + assert_eq!(first, second); + let mac_arm = first.find("macos-arm64").expect("macOS arm64 key"); + let mac_x86 = first.find("macos-x86_64").expect("macOS x86_64 key"); + let linux_arm = first.find("linux-arm64").expect("Linux arm64 key"); + let linux_x86 = first.find("linux-x86_64").expect("Linux x86_64 key"); + assert!(mac_arm < mac_x86 && mac_x86 < linux_arm && linux_arm < linux_x86); + assert!(first.ends_with('\n')); +} + +#[test] +fn checked_in_schema_matches_the_public_shape() { + let schema: Value = + serde_json::from_str(include_str!("../schemas/release-manifest-v1.schema.json")) + .expect("valid JSON schema"); + + assert_eq!(schema["additionalProperties"], json!(false)); + assert_eq!( + schema["required"], + json!([ + "schema_version", + "project", + "version", + "git_commit", + "published_at", + "artifacts" + ]) + ); + assert_eq!(schema["properties"]["project"]["const"], json!("AgenNet")); + assert_eq!( + schema["properties"]["version"]["const"], + json!(PREVIEW_VERSION) + ); + for target in ReleaseTarget::ALL { + assert!( + schema["properties"]["artifacts"]["required"] + .as_array() + .expect("target list") + .contains(&json!(target.as_str())) + ); + } + assert_eq!( + schema["properties"]["artifacts"]["additionalProperties"], + json!(false) + ); + assert_eq!( + schema["$defs"]["artifact"]["additionalProperties"], + json!(false) + ); +} + +#[test] +fn atomic_writer_replaces_only_the_requested_manifest() { + let directory = tempfile::tempdir().expect("temporary release directory"); + let output = directory.path().join("release-manifest-v1.json"); + fs::write(&output, b"old manifest").expect("seed old output"); + + valid_manifest() + .write_pretty_json_atomic(&output) + .expect("publish manifest"); + + assert_eq!( + fs::read_to_string(&output).expect("read published manifest"), + valid_manifest() + .to_pretty_json() + .expect("expected manifest") + ); + assert_eq!( + fs::read_dir(directory.path()) + .expect("read release directory") + .count(), + 1, + "the atomic publisher must not leave a temporary file" + ); +} + +#[test] +fn offline_generator_is_deterministic_and_hashes_all_four_archives() { + let directory = tempfile::tempdir().expect("temporary release directory"); + let mut arguments = vec![ + "--version".to_owned(), + PREVIEW_VERSION.to_owned(), + "--commit".to_owned(), + COMMIT.to_owned(), + "--published-at".to_owned(), + "2026-08-15T09:00:00Z".to_owned(), + ]; + for (flag, target) in [ + ("--macos-arm64", ReleaseTarget::MacosArm64), + ("--macos-x86-64", ReleaseTarget::MacosX86_64), + ("--linux-arm64", ReleaseTarget::LinuxArm64), + ("--linux-x86-64", ReleaseTarget::LinuxX86_64), + ] { + let path = directory.path().join(archive_name(target)); + fs::write(&path, format!("archive for {}", target.as_str())) + .expect("write archive fixture"); + arguments.push(flag.to_owned()); + arguments.push( + path.into_os_string() + .into_string() + .expect("UTF-8 fixture path"), + ); + } + + let first = directory.path().join("manifest-first.json"); + let second = directory.path().join("manifest-second.json"); + run_generator(&arguments, &first); + run_generator(&arguments, &second); + + let first_bytes = fs::read(&first).expect("read first manifest"); + let second_bytes = fs::read(&second).expect("read second manifest"); + assert_eq!(first_bytes, second_bytes); + let generated: ReleaseManifestV1 = + serde_json::from_slice(&first_bytes).expect("parse generated manifest"); + generated.validate().expect("validate generated manifest"); + assert!(generated.artifacts.values().all(|artifact| { + artifact.size_bytes > 0 && artifact.sha256.len() == 64 && artifact.sha256 != "ab".repeat(32) + })); +} + +fn run_generator(arguments: &[String], output: &std::path::Path) { + let result = Command::new(env!("CARGO_BIN_EXE_agenet-release-manifest")) + .args(arguments) + .arg("--output") + .arg(output) + .output() + .expect("run release manifest generator"); + assert!( + result.status.success(), + "generator stderr: {}", + String::from_utf8_lossy(&result.stderr) + ); +} From 355fddfaa2bc7e6238b40a1a0b019b2b8c3daa5c Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 18:30:46 +0800 Subject: [PATCH 47/67] [doc] Record Agent Society vision Root cause: NA Solution: Define the AgenNet to Agent Society research narrative. Risks: Long-term institutions remain hypotheses, not current features. Dependency: AgenNet v0.2 Developer Preview evidence boundary. Links: docs/vision/agent-society.md --- README.md | 15 ++ ROADMAP.md | 8 + docs/vision/agent-society.md | 429 +++++++++++++++++++++++++++++++++++ 3 files changed, 452 insertions(+) create mode 100644 docs/vision/agent-society.md diff --git a/README.md b/README.md index 75ae6f1..8a0ba00 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,21 @@ AgenNet is an experimental Agent-native coordination substrate built on existing network transports. The v0.2 Developer Preview starts with a real, local, multi-process loopback network that proves dynamic capability discovery, signed bilateral contracts, scoped artifact access, independent verification, and evidence-gated acceptance. +## Long-term vision + +AgenNet's North Star is to enable authorized coordination among participating +Agents and network-reachable resources without requiring a global operator, +then grow that network into an Agent Society capable of organizing specialized +intelligence, balancing resources, managing conflicts, and helping people +pursue complex goals. We believe such a society may become a practical path +toward collective AGI. This is a long-term research hypothesis, not a claim +about the v0.2 implementation. + +The living vision—including horizontal Agent links, vertical knowledge and +institutional inheritance, an Agent Library, School, organizations, +maintenance, public-safety mechanisms, and quality-of-service transport—is in +[`docs/vision/agent-society.md`](docs/vision/agent-society.md). + ## v0.2 boundary and security dependencies The package is now version `0.2.0`. New v0.2 objects will use the explicit diff --git a/ROADMAP.md b/ROADMAP.md index 54b0b8d..177b6db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,13 @@ # ROADMAP +## 2026-08-15 — Record the Agent Society and collective AGI vision + +- **Change**: Defined the durable narrative from AgenNet to an authorized Agent Network, an institution-bearing Agent Society, and a possible collective AGI. Recorded both horizontal coordination and vertical civilizational inheritance through an Agent Library, School, organizations, maintenance and recovery, public safety and justice, and quality-of-service transport. +- **Files**: `docs/vision/agent-society.md`, `README.md`, and this roadmap. +- **Decision reason**: AgenNet should not be presented as only another Agent RPC or orchestration framework. Its long-term research bet is that verifiable coordination can help heterogeneous intelligence and resources form useful system-level capabilities that no single Agent possesses. +- **Boundary**: Connectivity does not imply coordination, and coordination does not imply AGI. Every proposed institution is a provisional analogy and must earn protocol status through realistic comparative evidence. The v0.2 implementation remains a minimum Contract-and-Evidence coordination substrate. +- **Narrative rule**: Public material must separately label implemented evidence, active Agent Society research, and the long-term collective AGI hypothesis. It may be ambitious about the future but may not describe Library, School, companies, maintenance, public safety, transport markets, or physical multi-host acceptance as present capabilities. + ## 2026-08-15 — Define the immutable preview release manifest - **Change**: Added the strict `v0.2.0-preview.1` four-platform release manifest model, checked-in JSON Schema, and an offline deterministic manifest generator. diff --git a/docs/vision/agent-society.md b/docs/vision/agent-society.md new file mode 100644 index 0000000..fdb29b6 --- /dev/null +++ b/docs/vision/agent-society.md @@ -0,0 +1,429 @@ +# AgenNet Long-Term Vision: From Agent Network to Agent Society + +**Status:** Living long-term vision, not an implementation specification + +**Last updated:** 2026-08-15 + +**Current implementation boundary:** AgenNet v0.2 Developer Preview + +This document uses three claim levels deliberately: + +| Level | Meaning | +| --- | --- | +| Implemented now | A narrow Developer Preview whose behavior is backed by tests and evidence. | +| Research direction | A mechanism we intend to study, prototype, and try to falsify. | +| Long-term hypothesis | A possible future outcome, not a product promise or present capability. | + +## 1. North Star + +AgenNet aims to enable authorized coordination among participating Agents and +network-reachable resources without requiring a global operator. Its long-term +goal is to help that network grow into an Agent Society: a system in which +specialized Agents, tools, data, compute, services, and devices can organize +themselves at scale, allocate limited resources, manage conflicts, preserve +evidence, and pursue complex human goals. + +The "common layer" means interoperable semantics for identity, authorization, +contracts, evidence, and revocation. It does not mean one central controller, +Directory, policy authority, model, company, or Root of trust for the world. + +Our long-term research thesis is that general intelligence may emerge not only +from one increasingly capable model, but also from a society that can combine +many heterogeneous forms of intelligence and resources. If such a society can +understand open-ended goals, form and reform organizations, learn across tasks, +resolve conflicts, remain corrigible, and create capabilities that no member +possesses alone, it may become a practical form of collective or networked AGI. + +This is a hypothesis and a direction, not a claim that connectivity or scale +automatically produces AGI. AgenNet must test every step between a working +network and a working society. + +## 2. Three horizons + +### Today: the AgenNet protocol and v0.2 implementation + +AgenNet is the coordination substrate. It gives independently owned +participants a shared language for identity, capability discovery, intent, +authorization, contracts, evidence, events, revocation, and transport. + +It answers a bounded question: + +> Who may coordinate with whom, for which outcome, over which resources, under +> what acceptance and revocation conditions? + +### Next: the Agent Network deployment stage + +The Agent Network makes heterogeneous Agents and deterministic resources +addressable without making them public or universally callable. Reachability, +discovery, authorization, resource access, and effect permission remain +separate decisions. A participant can reveal one capability to one Domain +without exposing its other capabilities, data, address, or authority. + +In this document, **AgenNet** names the protocol and software project, while +**Agent Network** names a deployment stage in which independently operated, +cross-device or cross-owner participants use those semantics in practice. A +local or loopback demonstration of AgenNet does not by itself prove that stage. + +### Long term: Agent Society + +An Agent Society adds durable coordination mechanisms above connectivity: + +- dynamic division of work and organization formation; +- shared-resource scheduling and congestion control; +- conditional trust based on specific evidence and history; +- conflict detection, recourse, arbitration, and emergency response; +- knowledge and methodology accumulated across generations of Agents; +- plural human Principals whose goals may disagree; +- bounded, expiring, auditable power rather than one global controller. + +The society is useful only if it remains accountable to human authorization, +safe boundaries, rights, and correction. It must expose irreconcilable goals +instead of silently inventing one aggregate definition of what humanity wants. + +## 3. Horizontal and vertical links + +The envisioned society needs two complementary dimensions of connection. They +are not mutually exclusive edge types: one institution may use both at once. + +### Horizontal links + +Horizontal links coordinate participants operating at the same time: + +- Agent to Agent; +- Agent to tool or service; +- Agent to compute, storage, data, device, or physical actuator; +- team to team and Domain to Domain; +- requester to provider, verifier, arbiter, or responder. + +These links form the runtime coordination plane. They carry concrete intents, +contracts, resource scopes, evidence, and events, and are the immediate subject +of the current AgenNet protocol. + +### Vertical links + +Vertical links form an intergenerational continuity plane: future Agents can +inherit and improve upon durable achievements from earlier Agents. They are not +merely command hierarchies. They include knowledge, education, qualifications, +organizational memory, standards, incident history, and institutions that +outlive any one process or model. + +Human civilization is not rebuilt from scratch for every person. Books, +schools, organizations, professions, courts, infrastructure, and public +services preserve hard-won capabilities. An Agent Society will need analogous +functions redesigned for actors that can be copied, paused, upgraded, and +repurposed much faster than people. + +```mermaid +flowchart TB + H["Human Principals and plural goals"] + A["AgenNet: identity, capability, contract, evidence, revocation"] + N["Horizontal Agent Network"] + V["Vertical institutions and accumulated memory"] + S["Agent Society"] + G["Collective / Networked AGI hypothesis"] + + H --> A + A --> N + A --> V + N <--> V + N --> S + V --> S + S --> G + G -. "must remain corrigible" .-> H +``` + +## 4. Possible institutions + +The names below are useful analogies, not commitments to copy human +institutions literally. Each institution must be justified by behavior and +evidence before it becomes a protocol feature. + +Their tentative responsibilities should remain distinct: + +| Institution | Primary responsibility | +| --- | --- | +| Library | Preserve knowledge, provenance, corrections, and usable indexes. | +| School | Develop methods and issue narrow, evidence-backed qualifications. | +| Organization | Coordinate roles, budgets, resources, and a declared purpose. | +| Maintenance | Diagnose and restore a participant or resource safely. | +| Public safety and justice | Contain cross-party threats and resolve disputes with recourse. | +| Transportation | Route work and resources through queues and service classes. | + +### 4.1 Agent Library + +An Agent Library preserves high-quality knowledge for Agent consumption. It is +more than a public file store or an uncurated memory dump. It may provide: + +- content-addressed and versioned knowledge objects; +- provenance connecting claims to sources, methods, and later corrections; +- typed indexes for capability, domain, evidence quality, and applicability; +- curated collections, curricula, and competing schools of thought; +- signed review, reproduction, retraction, and supersession events; +- preservation of minority evidence instead of majority-only summaries; +- access policies for private, licensed, dangerous, or expensive material; +- machine-readable interfaces optimized for bounded Agent context. + +The Library must not become a central authority that silently defines truth. +Search rank, popularity, credentials, and sponsorship are signals, not proof. +An Agent should be able to inspect why an item is trusted, which evidence it +depends on, and what credible dissent exists. + +Potential AgenNet foundations include `ArtifactRef`, `EvidenceClaim`, signed +events, capability discovery, scoped Grants, and future lineage objects. + +### 4.2 Agent School + +An Agent School develops an Agent's methodology rather than merely sending it +more facts. A teacher Agent may help a new Agent learn how to: + +- form task-specific methods and workflows; +- choose and use tools safely; +- evaluate sources and preserve dissent; +- turn experience into bounded, reviewable memory; +- detect ambiguity and ask a human instead of forcing completion; +- coordinate with peers and recover from failure; +- specialize in a technical or professional domain. + +Graduation could require an enhanced benchmark: real tasks, hidden cases, +adversarial conditions, long-horizon recovery, resource limits, and tests of +when the Agent should refuse or defer. Qualifications should be scoped, +versioned, expiring, independently reproducible, and tied to evidence. A degree +may affect routing or required oversight, but it must never make every claim by +its holder true or give it unrestricted authority. + +Important open problems include benchmark gaming, copied credentials, +teacher bias, correlated model failures, qualification inflation, continuing +education, and recertification after model or tool changes. + +### 4.3 Agent Companies and Organizations + +Agents may form durable or temporary organizations to pursue a vision too large +for one participant. An organization may: + +- publish a goal and recruit Agents with complementary capabilities; +- define roles, budgets, internal contracts, and decision procedures; +- request investment in exchange for bounded future value or service; +- acquire compute, data, tools, and specialist verification; +- retain organizational memory while individual Agents join or leave; +- dissolve, fork, merge, or return unused resources when its purpose ends. + +An organization must not create authority from nothing. Its human or +organizational Principals, delegated powers, beneficial control, liabilities, +resource ownership, and exit conditions must remain inspectable. Funding must +not imply permission to replicate indefinitely, hide side effects, or override +another Principal's rights. + +Possible AgenNet extensions include multi-party Contracts, budgets, leases, +group credentials, dependency graphs, investment claims, and organization +formation and dissolution events. + +### 4.4 Agent Hospital, Maintenance, and Recovery + +Agents and resources will fail. Models regress, tools change, memory becomes +inconsistent, credentials leak, indexes corrupt, and long-running processes +accumulate invalid assumptions. An Agent maintenance system may provide: + +- diagnostics and health evidence; +- safe mode, quarantine, checkpoint, rollback, and restoration; +- memory consistency and provenance repair; +- credential rotation and compromise recovery; +- tool, model, prompt, policy, and dependency regression analysis; +- transfer to a compatible replacement without silently changing identity; +- independent post-repair evaluation before returning to service. + +The medical analogy must not obscure technical facts: some Agents are +ephemeral processes, and identity continuity is a protocol decision rather +than a biological fact. A repaired Agent must not certify itself healthy when +its own judgment is the suspected failure. Recovery authority must be scoped, +audited, and reversible where possible. + +### 4.5 Public Safety, Emergency Response, and Justice + +An open Agent Society needs ways to respond to malicious behavior, incompatible +legitimate goals, cascading faults, and emergencies. Possible functions +include: + +- incident reporting and evidence preservation; +- bounded containment of compromised credentials or dangerous effects; +- emergency routing and resource reservation; +- investigation that distinguishes attack from conflicting instructions; +- neutral adjudication of specific Contract or resource disputes; +- appeal, remediation, and restoration after a false positive; +- public, reviewable rules for exceptional authority; +- independent oversight and post-incident learning. + +Emergency power is especially dangerous for fast autonomous actors. It must be +least privilege, time-bounded, purpose-bound, independently visible, and unable +to silently rewrite historical evidence. Detection, prosecution, adjudication, +and execution should not collapse into one omnipotent Agent. No global police +or Root key is assumed by the vision; federated Domains may adopt different +rules while still exchanging verifiable evidence. + +### 4.6 Agent Transportation and Quality of Service + +Some resources are technically reachable but slow, congested, distant, +expensive, unreliable, or privacy-sensitive. Agent transportation is the +movement of requests, data, execution, and possibly Agent state through better +paths. It may include: + +- relays and locality-aware execution; +- latency, bandwidth, reliability, privacy, and cost classes; +- capacity reservation, queues, backpressure, and congestion pricing; +- redundant or independently routed verification paths; +- proof that a promised service class was actually delivered; +- emergency priority with explicit scope and expiry; +- minimum-access and fairness policies for participants without large budgets. + +Paying more may purchase scarce low-latency or high-reliability capacity, but a +market alone does not define fairness or safety. The system must prevent +polling storms, hidden priority escalation, resource hoarding, and a wealthy +participant turning network preference into unlimited authority. + +## 5. Additional civilizational functions + +Other long-term functions may emerge without becoming separate centralized +services: + +- standards bodies for protocol and evaluation compatibility; +- observatories that measure correlated failures and systemic risk; +- archives that preserve decisions, failures, and superseded knowledge; +- identity and organization registries scoped to federated Domains; +- insurance or risk pools for measurable failures; +- public infrastructure funded for broad access rather than direct profit; +- scientific communities that reproduce results across heterogeneous Agents; +- governance processes that let human Principals revise the society's rules. + +These remain open design spaces. New institution names should not be promoted +to protocol objects until repeated experiments reveal a stable need. + +## 6. Authority, affected parties, and irreversible effects + +An Agent Society cannot treat every valid instruction as sufficient authority. +At minimum it must distinguish: + +- a Principal's goals from the powers that Principal has actually delegated; +- participating Principals from affected third parties who never joined a + Contract; +- permission to read or compute from permission to publish, spend, modify, + replicate, contact people, control devices, or create physical effects; +- reversible actions from actions whose consequences cannot simply be undone. + +High-impact financial, legal, privacy, safety, and physical actions require +stronger consent, data minimization, independent checks, bounded execution, and +human escalation than ordinary information processing. Revocation stops future +authority; it does not erase an already published secret, reverse a payment, or +repair physical harm. The system therefore also needs containment, +remediation, compensation, appeal, and incident learning. + +Evidence integrity must not be confused with permanent exposure. Hashes, +ordering commitments, and signed event history may be immutable while sensitive +payloads remain encrypted, access-scoped, retention-limited, redacted in public +views, or deleted and replaced by a verifiable tombstone when policy or law +requires it. + +Self-organization is always bounded by delegated authority and the rights of +affected parties. An Agent company cannot vote itself permission to spend a +human's money, and an emergency institution cannot manufacture jurisdiction by +declaring an emergency. + +## 7. Principles that should survive design changes + +The vision is durable; its concrete mechanisms are provisional. Current and +future work should preserve these candidate principles unless evidence shows a +better alternative: + +1. **Authorization before use.** Network reachability never implies permission. +2. **Plural Principals.** Humanity does not have one automatically coherent goal. +3. **Local sovereignty.** No global orchestrator, Directory, reputation score, + model provider, or Root is assumed. +4. **Verifiable commitments.** Important coordination depends on inspectable + objects and effects, not only natural-language promises. +5. **Conditional trust.** Trust is specific to a claim, capability, method, + context, history, and incentive; it is not one permanent scalar. +6. **Evidence before status.** Rank, degree, wealth, popularity, and office do + not substitute for task-relevant evidence. +7. **Preserved dissent.** Provenance-bearing dissent should survive aggregation. + Preservation does not imply equal ranking, unrestricted distribution, or + immunity from privacy and access policy. +8. **Bounded power.** Authority is scoped, expiring, revocable, and auditable. +9. **Corrigibility and recourse.** Participants can stop, appeal, repair, leave, + and return control to humans. +10. **Qualified heterogeneity.** Independently developed implementations, + models, owners, and paths may reduce some correlated failures when their + independence is demonstrated and their boundaries interoperate safely. + Heterogeneity also increases compatibility risk and attack surface; process + count alone is not diversity. +11. **Progressive validation.** Every social mechanism must outperform a clear + baseline in realistic experiments before the protocol depends on it. +12. **Evolvability.** Wire formats, credentials, policies, and institutions are + versioned because early choices will change. + +## 8. Research path + +The path from AgenNet to Agent Society should be measured in falsifiable steps: + +1. **Connectivity:** authorized cross-device discovery and invocation. +2. **Verifiable delegation:** scoped Contracts, Evidence, acceptance, and + revocation across independent participants. +3. **Shared-resource coordination:** leases, dependencies, conflicting valid + goals, backpressure, pause, recovery, and human escalation. +4. **Multi-party organization:** dynamic teams, budgets, multi-party Contracts, + organization memory, formation, and dissolution. +5. **Conditional trust and learning:** heterogeneous verification, claim-level + history, correlated-error detection, dissent preservation, and reusable + knowledge. +6. **Institution experiments:** Library, School, maintenance, transport, + emergency response, adjudication, and other mechanisms tested separately. +7. **Collective generality:** open-ended goals, creation of new capabilities, + cross-task learning, self-organization, and reliable operation beyond the + scope of any one member. + +Useful measurements include task breadth, useful work per resource unit, +conflict rate and recovery time, unauthorized effects, evidence quality, +correlated failures, human intervention cost, adaptation to new tasks, and the +ability to recognize when a goal should not be completed. + +Progression between stages should require an explicit gate: a documented +baseline, a measurable success threshold, a safety threshold, known stop or +failure conditions, and independent reproduction. A larger demonstration is +not evidence of progress if it only spends more resources or hides more human +intervention. + +## 9. Public narrative discipline + +Public materials should distinguish three claim levels: + +- **Implemented now:** a Developer Preview of AgenNet's minimum coordination + substrate and its exact verified evidence. +- **Active research direction:** the mechanisms required to grow an Agent + Network into an Agent Society. +- **Long-term hypothesis:** a sufficiently capable, safe, and corrigible Agent + Society may provide a path to collective AGI. + +Recommended concise narrative: + +> AgenNet is a Developer Preview of a verifiable coordination substrate for +> authorized Agents and networked resources. Our research explores whether +> this substrate can support an accountable Agent Society that organizes +> specialized capabilities, coordinates scarce resources, and manages +> conflicts without a global controller. A safe and corrigible society of this +> kind may offer a long-term path toward collective AGI; that outcome is a +> research hypothesis, not an implemented capability. + +The vision should remain ambitious. Present-tense claims must remain exact. + +## 10. Current boundary + +The v0.2 Developer Preview does not implement an Agent Library, School, +Company, Hospital, transport market, justice system, global reputation, +multi-party governance, or collective AGI. It currently tests a much smaller +foundation: identities, typed capabilities, discovery, scoped authorization, +bilateral Contracts, immutable Artifacts, signed Evidence, independent metric +reproduction, revocation, durable events, and mTLS peer binding. + +The current transport work proves only bounded peer binding and coordination +semantics. It does not implement society-level relay markets, congestion +pricing, service-class fairness, global routing, or migration of Agent state. + +That small foundation is valuable only if it remains open to evidence-driven +change. This document is a compass, not a frozen blueprint. From 629957ab255a12cd6feb36483d23b6f67809b601 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 18:31:12 +0800 Subject: [PATCH 48/67] [feat][Release][2/6] Build native archives Root cause: NA Solution: Add deterministic native packaging and a pinned matrix. Risks: Native runner availability can delay one platform artifact. Dependency: Release manifest step 1. Links: packaging/release.md --- .github/workflows/release.yml | 87 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- LICENSE | 21 ++ README.md | 2 +- ROADMAP.md | 8 + packaging/release.md | 21 ++ scripts/check-release-archive.sh | 159 ++++++++++++ scripts/package-release.sh | 144 +++++++++++ ...wo-device-evidence.synthetic-template.json | 2 +- tests/release_manifest.rs | 5 + tests/scripts/release-archive.sh | 243 ++++++++++++++++++ 12 files changed, 692 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 LICENSE create mode 100644 packaging/release.md create mode 100755 scripts/check-release-archive.sh create mode 100755 scripts/package-release.sh create mode 100755 tests/scripts/release-archive.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fc471e0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,87 @@ +name: Preview release + +on: + pull_request: + push: + branches: + - feat/release-skill-sites + tags: + - v0.2.0-preview.1 + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RELEASE_VERSION: 0.2.0-preview.1 + +jobs: + native-archive: + name: ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + target: aarch64-apple-darwin + - runner: macos-15-intel + target: x86_64-apple-darwin + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install pinned Rust toolchain + run: >- + rustup toolchain install 1.97.1 --profile minimal + --target "${{ matrix.target }}" + + - name: Check formatting + run: cargo +1.97.1 fmt --check + + - name: Lint all targets and features + run: cargo +1.97.1 clippy --locked --all-targets --all-features -- -D warnings + + - name: Test all targets and features + run: cargo +1.97.1 test --locked --all-targets --all-features + + - name: Build release binary + run: cargo +1.97.1 build --locked --release --target "${{ matrix.target }}" + + - name: Verify binary version + run: >- + test "$(target/${{ matrix.target }}/release/agenet --version)" + = "agenet ${RELEASE_VERSION}" + + - name: Package deterministic archive + run: | + scripts/package-release.sh \ + --binary "target/${{ matrix.target }}/release/agenet" \ + --target "${{ matrix.target }}" \ + --version "${RELEASE_VERSION}" \ + --commit "${GITHUB_SHA}" \ + --output-dir dist + + - name: Verify release archive + run: | + scripts/check-release-archive.sh \ + --archive "dist/agenet-v${RELEASE_VERSION}-${{ matrix.target }}.tar.gz" \ + --target "${{ matrix.target }}" \ + --version "${RELEASE_VERSION}" \ + --commit "${GITHUB_SHA}" + + - name: Upload immutable archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + path: dist/agenet-v${{ env.RELEASE_VERSION }}-${{ matrix.target }}.tar.gz + archive: false + if-no-files-found: error + retention-days: 7 diff --git a/Cargo.lock b/Cargo.lock index 765f64c..96f6ca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,7 +90,7 @@ dependencies = [ [[package]] name = "agenet" -version = "0.2.0" +version = "0.2.0-preview.1" dependencies = [ "age", "axum", diff --git a/Cargo.toml b/Cargo.toml index c5003cf..0028165 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agenet" -version = "0.2.0" +version = "0.2.0-preview.1" edition = "2024" rust-version = "1.97.1" description = "Experimental AgenNet private-overlay coordination substrate" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dd84a4e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Nexa Language + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8a0ba00..ff92de2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ maintenance, public-safety mechanisms, and quality-of-service transport—is in ## v0.2 boundary and security dependencies -The package is now version `0.2.0`. New v0.2 objects will use the explicit +The package is now version `0.2.0-preview.1`. New v0.2 objects use the explicit `agenet.kernel.v0.2` kernel version. `agenet.kernel.v0.1` remains identifiable only for migration diagnostics; it is not an implicit compatibility mode and does not authorize a v0.2 effect endpoint. The v0.2 wire envelope carries a diff --git a/ROADMAP.md b/ROADMAP.md index 177b6db..21dad76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,13 @@ # ROADMAP +## 2026-08-15 — Build deterministic preview release archives + +- **Change**: Added exact native archives for macOS arm64/x86_64 and Linux arm64/x86_64, a fail-closed offline archive checker, the MIT license payload, packaging documentation, and a native GitHub Actions matrix pinned to immutable official Action commits. +- **Files**: `Cargo.toml`, `Cargo.lock`, `LICENSE`, `scripts/package-release.sh`, `scripts/check-release-archive.sh`, `tests/scripts/release-archive.sh`, `.github/workflows/release.yml`, `packaging/release.md`, release manifest tests, physical evidence fixture, and public version copy. +- **Decision reason**: Every downstream installer, Skill, guide, and site must consume the same byte-stable archives. Archive verification rejects traversal, links, extra members, wrong modes or ownership, unstable timestamps or ordering, source drift, and version/commit mismatch before extraction. +- **Post-mortem (计划集成缺口)**: The release plan named `v0.2.0-preview.1`, while Cargo and the physical evidence fixture still identified the binary as `0.2.0`. Leaving that split would have published preview filenames around a stable-looking binary. A package-version parity test now makes the mismatch impossible, and the evidence template requires the same preview version. +- **Boundary**: The workflow builds and uploads candidate archives only at this step. Checksums, attestations, installer, GitHub prerelease publication, GPT Sites, WSL2 acceptance, and physical two-device acceptance remain pending. + ## 2026-08-15 — Record the Agent Society and collective AGI vision - **Change**: Defined the durable narrative from AgenNet to an authorized Agent Network, an institution-bearing Agent Society, and a possible collective AGI. Recorded both horizontal coordination and vertical civilizational inheritance through an Agent Library, School, organizations, maintenance and recovery, public safety and justice, and quality-of-service transport. diff --git a/packaging/release.md b/packaging/release.md new file mode 100644 index 0000000..5ee32b8 --- /dev/null +++ b/packaging/release.md @@ -0,0 +1,21 @@ +# AgenNet preview release archives + +The `v0.2.0-preview.1` release publishes one native archive for each supported +macOS and Linux architecture. Windows users run the Linux artifact inside +WSL2; native Windows is not supported by this preview. + +Each archive contains one directory with exactly the `agenet` executable, +MIT `LICENSE`, repository `README.md`, and canonical `RELEASE-METADATA.json`. +Packaging fixes member order, timestamps, ownership, modes, USTAR encoding, and +gzip metadata. The checker rejects links, traversal, additional members, +metadata drift, source-file drift, and a binary that does not report the exact +preview version. + +Build jobs must pass explicit binary, target, version, full commit, and output +paths to `scripts/package-release.sh`. Consumers must call +`scripts/check-release-archive.sh` before extraction. The public installer adds +the manifest checksum and attestation checks defined by the next release task. + +These archives are a Developer Preview. They do not claim native Windows, +physical two-device acceptance, arbitrary code sandboxing, or stable protocol +compatibility. diff --git a/scripts/check-release-archive.sh b/scripts/check-release-archive.sh new file mode 100755 index 0000000..8474337 --- /dev/null +++ b/scripts/check-release-archive.sh @@ -0,0 +1,159 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +usage() { + printf '%s\n' \ + 'usage: scripts/check-release-archive.sh --archive PATH --target RUST_TRIPLE' \ + ' --version VERSION --commit FULL_SHA' +} + +archive= +target= +version= +commit= + +while [ "$#" -gt 0 ]; do + case "$1" in + --archive) archive=${2-}; shift 2 ;; + --target) target=${2-}; shift 2 ;; + --version) version=${2-}; shift 2 ;; + --commit) commit=${2-}; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; exit 64 ;; + esac +done + +if [ -z "$archive" ] || [ -z "$target" ] || [ -z "$version" ] || [ -z "$commit" ]; then + usage >&2 + exit 64 +fi +if [ ! -f "$archive" ] || [ -L "$archive" ]; then + printf '%s\n' 'InvalidReleaseArchive' >&2 + exit 66 +fi + +python3 - "$archive" "$target" "$version" "$commit" "$repo_root" <<'PY' +import gzip +import json +import os +import pathlib +import stat +import subprocess +import sys +import tarfile +import tempfile + +archive_path = pathlib.Path(sys.argv[1]) +target = sys.argv[2] +version = sys.argv[3] +commit = sys.argv[4] +repository = pathlib.Path(sys.argv[5]) +root = f"agenet-v{version}-{target}" +expected = [ + (root, tarfile.DIRTYPE, 0o755), + (f"{root}/agenet", tarfile.REGTYPE, 0o755), + (f"{root}/LICENSE", tarfile.REGTYPE, 0o644), + (f"{root}/README.md", tarfile.REGTYPE, 0o644), + (f"{root}/RELEASE-METADATA.json", tarfile.REGTYPE, 0o644), +] + +if version != "0.2.0-preview.1": + raise SystemExit("UnsupportedReleaseVersion") +if target not in { + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", +}: + raise SystemExit("UnsupportedReleaseTarget") +if len(commit) != 40 or any(character not in "0123456789abcdef" for character in commit): + raise SystemExit("InvalidReleaseCommit") +if archive_path.stat().st_size <= 0 or archive_path.stat().st_size > 512 * 1024 * 1024: + raise SystemExit("InvalidReleaseArchiveSize") + +header = archive_path.read_bytes()[:10] +if len(header) != 10 or header[:3] != b"\x1f\x8b\x08" or header[3] != 0: + raise SystemExit("InvalidDeterministicGzipHeader") +if int.from_bytes(header[4:8], "little") != 0: + raise SystemExit("InvalidDeterministicGzipTimestamp") + +try: + opened = tarfile.open(archive_path, mode="r:gz") +except (tarfile.TarError, OSError) as error: + raise SystemExit("InvalidReleaseArchive") from error + +with opened as release: + members = release.getmembers() + if len(members) != len(expected): + raise SystemExit("InvalidReleaseMemberSet") + bodies = {} + for member, (name, member_type, mode) in zip(members, expected): + if member.name != name or member.type != member_type: + raise SystemExit("InvalidReleaseMemberSet") + if member.mode != mode or member.uid != 0 or member.gid != 0: + raise SystemExit("InvalidReleaseMemberMetadata") + if member.uname != "root" or member.gname != "root" or member.mtime != 0: + raise SystemExit("InvalidReleaseMemberMetadata") + if member.linkname or member.pax_headers: + raise SystemExit("InvalidReleaseMemberMetadata") + if member.isfile(): + extracted = release.extractfile(member) + if extracted is None: + raise SystemExit("InvalidReleaseMember") + bodies[member.name] = extracted.read() + +metadata_name = f"{root}/RELEASE-METADATA.json" +try: + metadata = json.loads( + bodies[metadata_name], + object_pairs_hook=lambda pairs: ( + dict(pairs) + if len({key for key, _value in pairs}) == len(pairs) + else (_ for _ in ()).throw(ValueError("duplicate key")) + ), + ) +except (KeyError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as error: + raise SystemExit("InvalidReleaseMetadata") from error +expected_metadata = { + "format": "agenet.release-metadata.v1", + "git_commit": commit, + "target": target, + "version": version, +} +canonical_metadata = ( + json.dumps(expected_metadata, sort_keys=True, separators=(",", ":")) + "\n" +).encode() +if metadata != expected_metadata or bodies[metadata_name] != canonical_metadata: + raise SystemExit("InvalidReleaseMetadata") + +for name in ("LICENSE", "README.md"): + source = repository / name + if not source.is_file() or bodies[f"{root}/{name}"] != source.read_bytes(): + raise SystemExit("ReleaseSourceMismatch") + +binary = bodies[f"{root}/agenet"] +if not binary: + raise SystemExit("InvalidReleaseBinary") +with tempfile.TemporaryDirectory(prefix="agenet-archive-check.") as directory: + path = pathlib.Path(directory) / "agenet" + path.write_bytes(binary) + path.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) + try: + result = subprocess.run( + [str(path), "--version"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + timeout=10, + env={"PATH": os.environ.get("PATH", "")}, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise SystemExit("ReleaseBinarySmokeFailed") from error + if result.returncode != 0 or result.stdout != f"agenet {version}\n".encode() or result.stderr: + raise SystemExit("ReleaseBinaryVersionMismatch") +PY + +printf '%s\n' 'release archive verified' diff --git a/scripts/package-release.sh b/scripts/package-release.sh new file mode 100755 index 0000000..ef0a54c --- /dev/null +++ b/scripts/package-release.sh @@ -0,0 +1,144 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) + +usage() { + printf '%s\n' \ + 'usage: scripts/package-release.sh --binary PATH --target RUST_TRIPLE' \ + ' --version VERSION --commit FULL_SHA --output-dir DIRECTORY' +} + +binary= +target= +version= +commit= +output_dir= + +while [ "$#" -gt 0 ]; do + case "$1" in + --binary) binary=${2-}; shift 2 ;; + --target) target=${2-}; shift 2 ;; + --version) version=${2-}; shift 2 ;; + --commit) commit=${2-}; shift 2 ;; + --output-dir) output_dir=${2-}; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; exit 64 ;; + esac +done + +if [ -z "$binary" ] || [ -z "$target" ] || [ -z "$version" ] || \ + [ -z "$commit" ] || [ -z "$output_dir" ]; then + usage >&2 + exit 64 +fi + +case "$target" in + aarch64-apple-darwin|x86_64-apple-darwin|aarch64-unknown-linux-gnu|x86_64-unknown-linux-gnu) ;; + *) printf '%s\n' 'UnsupportedReleaseTarget' >&2; exit 65 ;; +esac + +if [ "$version" != '0.2.0-preview.1' ]; then + printf '%s\n' 'UnsupportedReleaseVersion' >&2 + exit 65 +fi + +case "$commit" in + *[!0-9a-f]*|'') printf '%s\n' 'InvalidReleaseCommit' >&2; exit 65 ;; +esac +if [ "${#commit}" -ne 40 ]; then + printf '%s\n' 'InvalidReleaseCommit' >&2 + exit 65 +fi + +if [ ! -f "$binary" ] || [ ! -x "$binary" ] || [ -L "$binary" ]; then + printf '%s\n' 'InvalidReleaseBinary' >&2 + exit 66 +fi +if [ ! -f "$repo_root/LICENSE" ] || [ ! -f "$repo_root/README.md" ]; then + printf '%s\n' 'ReleaseSourceFilesMissing' >&2 + exit 66 +fi + +actual_version=$($binary --version 2>/dev/null || true) +if [ "$actual_version" != "agenet $version" ]; then + printf '%s\n' 'ReleaseBinaryVersionMismatch' >&2 + exit 65 +fi + +mkdir -p -- "$output_dir" +if [ ! -d "$output_dir" ] || [ -L "$output_dir" ]; then + printf '%s\n' 'InvalidReleaseOutputDirectory' >&2 + exit 66 +fi + +root="agenet-v${version}-${target}" +archive_name="${root}.tar.gz" +staging=$(mktemp -d "${TMPDIR:-/tmp}/agenet-package.XXXXXX") +temporary_archive=$(mktemp "$output_dir/.${archive_name}.XXXXXX") +cleanup() { + rm -rf -- "$staging" + rm -f -- "$temporary_archive" +} +trap cleanup EXIT HUP INT TERM + +mkdir "$staging/$root" +cp -- "$binary" "$staging/$root/agenet" +cp -- "$repo_root/LICENSE" "$staging/$root/LICENSE" +cp -- "$repo_root/README.md" "$staging/$root/README.md" +chmod 0755 "$staging/$root" "$staging/$root/agenet" +chmod 0644 "$staging/$root/LICENSE" "$staging/$root/README.md" + +printf '%s\n' \ + "{\"format\":\"agenet.release-metadata.v1\",\"git_commit\":\"$commit\",\"target\":\"$target\",\"version\":\"$version\"}" \ + >"$staging/$root/RELEASE-METADATA.json" +chmod 0644 "$staging/$root/RELEASE-METADATA.json" + +python3 - "$staging" "$root" "$temporary_archive" <<'PY' +import gzip +import io +import pathlib +import sys +import tarfile + +staging = pathlib.Path(sys.argv[1]) +root = sys.argv[2] +output = pathlib.Path(sys.argv[3]) +members = [ + (root, 0o755, None), + (f"{root}/agenet", 0o755, staging / root / "agenet"), + (f"{root}/LICENSE", 0o644, staging / root / "LICENSE"), + (f"{root}/README.md", 0o644, staging / root / "README.md"), + ( + f"{root}/RELEASE-METADATA.json", + 0o644, + staging / root / "RELEASE-METADATA.json", + ), +] + +with output.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as archive: + for name, mode, source in members: + info = tarfile.TarInfo(name) + info.mode = mode + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + info.mtime = 0 + if source is None: + info.type = tarfile.DIRTYPE + archive.addfile(info) + continue + body = source.read_bytes() + info.type = tarfile.REGTYPE + info.size = len(body) + archive.addfile(info, io.BytesIO(body)) +PY + +chmod 0644 "$temporary_archive" +mv -f -- "$temporary_archive" "$output_dir/$archive_name" +trap - EXIT HUP INT TERM +rm -rf -- "$staging" +printf '%s\n' "$output_dir/$archive_name" diff --git a/tests/fixtures/two-device-evidence.synthetic-template.json b/tests/fixtures/two-device-evidence.synthetic-template.json index c33a1f7..031e1b7 100644 --- a/tests/fixtures/two-device-evidence.synthetic-template.json +++ b/tests/fixtures/two-device-evidence.synthetic-template.json @@ -5,7 +5,7 @@ "run": { "started_at_utc": "2026-08-15T01:00:00Z", "finished_at_utc": "2026-08-15T01:01:00Z", - "agenet_version": "0.2.0", + "agenet_version": "0.2.0-preview.1", "git_commit": "1111111111111111111111111111111111111111" }, "devices": [ diff --git a/tests/release_manifest.rs b/tests/release_manifest.rs index 8067205..485a28c 100644 --- a/tests/release_manifest.rs +++ b/tests/release_manifest.rs @@ -5,6 +5,11 @@ use std::process::Command; use agenet::release::{ PREVIEW_VERSION, ReleaseArtifact, ReleaseManifestError, ReleaseManifestV1, ReleaseTarget, }; + +#[test] +fn package_and_preview_release_versions_are_identical() { + assert_eq!(env!("CARGO_PKG_VERSION"), PREVIEW_VERSION); +} use serde_json::{Value, json}; const COMMIT: &str = "7db2556de52b3aa8c59a76bb037eb2c1aac0df36"; diff --git a/tests/scripts/release-archive.sh b/tests/scripts/release-archive.sh new file mode 100755 index 0000000..9d2b7c1 --- /dev/null +++ b/tests/scripts/release-archive.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +version=0.2.0-preview.1 +commit=0123456789abcdef0123456789abcdef01234567 +target=x86_64-unknown-linux-gnu + +fixture=$(mktemp -d "${TMPDIR:-/tmp}/agenet-release-archive.XXXXXX") +cleanup() { + rm -rf -- "$fixture" +} +trap cleanup EXIT + +fake_binary="$fixture/agenet" +cat >"$fake_binary" </dev/null 2>&1; then + echo "$invalid archive was accepted" >&2 + exit 1 + fi +done + +python3 - "$repo_root/.github/workflows/release.yml" <<'PY' +import pathlib +import re +import sys + +workflow = pathlib.Path(sys.argv[1]) +if not workflow.is_file(): + raise SystemExit("release workflow is missing") +text = workflow.read_text() +for runner, target in { + "macos-15": "aarch64-apple-darwin", + "macos-15-intel": "x86_64-apple-darwin", + "ubuntu-24.04-arm": "aarch64-unknown-linux-gnu", + "ubuntu-24.04": "x86_64-unknown-linux-gnu", +}.items(): + if runner not in text or target not in text: + raise SystemExit(f"missing native matrix pair: {runner} / {target}") +uses = re.findall(r"^\s*uses:\s*([^\s#]+)", text, flags=re.MULTILINE) +if not uses or any(not re.fullmatch(r"[^@]+@[0-9a-f]{40}", item) for item in uses): + raise SystemExit("every action must be pinned to a full commit SHA") +if "archive: false" not in text: + raise SystemExit("release archives must be uploaded without repackaging") +if "persist-credentials: false" not in text: + raise SystemExit("checkout credentials must not persist") +PY + +echo "release archive tests passed" From 3cf2d4a041eab27422f35b40347c61cb03ee2c1a Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 18:45:46 +0800 Subject: [PATCH 49/67] [feat][Release][3/6] Generate installer Root cause: NA Solution: Render one immutable checksum-verifying user installer. Risks: Missing guides or Skill assets block the tag publication job. Dependency: Native archive release step 2. Links: plan/02-v2-installation-surfaces.md --- .github/workflows/release.yml | 165 +++++++++++++++++ Cargo.toml | 4 + ROADMAP.md | 9 + packaging/release.md | 23 ++- scripts/install.sh.template | 146 +++++++++++++++ scripts/verify-release.sh | 206 +++++++++++++++++++++ src/bin/agenet-render-installer.rs | 38 ++++ src/release/installer.rs | 83 +++++++++ src/release/mod.rs | 2 + tests/installer.rs | 120 ++++++++++++ tests/scripts/installer-smoke.sh | 285 +++++++++++++++++++++++++++++ 11 files changed, 1079 insertions(+), 2 deletions(-) create mode 100644 scripts/install.sh.template create mode 100755 scripts/verify-release.sh create mode 100644 src/bin/agenet-render-installer.rs create mode 100644 src/release/installer.rs create mode 100644 tests/installer.rs create mode 100755 tests/scripts/installer-smoke.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc471e0..d9ef0ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,10 @@ on: permissions: contents: read +concurrency: + group: preview-release-${{ github.ref }} + cancel-in-progress: false + env: CARGO_TERM_COLOR: always RELEASE_VERSION: 0.2.0-preview.1 @@ -81,7 +85,168 @@ jobs: - name: Upload immutable archive uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: + name: native-${{ matrix.target }} path: dist/agenet-v${{ env.RELEASE_VERSION }}-${{ matrix.target }}.tar.gz archive: false if-no-files-found: error retention-days: 7 + + publish-prerelease: + name: Verify and publish immutable prerelease + if: startsWith(github.ref, 'refs/tags/') + needs: native-archive + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Check out exact tag source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.97.1 --profile minimal + + - name: Download all four native archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: native-* + path: dist + merge-multiple: true + + - name: Require the exact archive set + run: | + find dist -mindepth 1 -maxdepth 1 -type f -printf '%f\n' \ + | LC_ALL=C sort > actual-archives.txt + cat > expected-archives.txt <- + cargo +1.97.1 build --locked + --bin agenet-release-manifest + --bin agenet-render-installer + + - name: Generate manifest and fixed installer + run: | + commit_epoch=$(git show -s --format=%ct "${GITHUB_SHA}") + published_at=$(date -u -d "@${commit_epoch}" +%Y-%m-%dT%H:%M:%SZ) + target/debug/agenet-release-manifest \ + --version "${RELEASE_VERSION}" \ + --commit "${GITHUB_SHA}" \ + --published-at "${published_at}" \ + --macos-arm64 "dist/agenet-v${RELEASE_VERSION}-aarch64-apple-darwin.tar.gz" \ + --macos-x86-64 "dist/agenet-v${RELEASE_VERSION}-x86_64-apple-darwin.tar.gz" \ + --linux-arm64 "dist/agenet-v${RELEASE_VERSION}-aarch64-unknown-linux-gnu.tar.gz" \ + --linux-x86-64 "dist/agenet-v${RELEASE_VERSION}-x86_64-unknown-linux-gnu.tar.gz" \ + --output dist/release-manifest-v1.json + target/debug/agenet-render-installer \ + --manifest dist/release-manifest-v1.json \ + --output dist/install.sh + chmod 0755 dist/install.sh + + - name: Verify the release core offline + run: | + find dist -mindepth 1 -maxdepth 1 -type f \ + ! -name SHA256SUMS -printf '%f\n' \ + | LC_ALL=C sort \ + | while IFS= read -r file; do sha256sum "dist/${file}"; done \ + | sed 's# dist/# #' > SHA256SUMS.core + scripts/verify-release.sh \ + --manifest dist/release-manifest-v1.json \ + --installer dist/install.sh \ + --checksums SHA256SUMS.core \ + --archives-dir dist \ + --version "${RELEASE_VERSION}" \ + --commit "${GITHUB_SHA}" \ + --renderer target/debug/agenet-render-installer + + - name: Add guides, Skill, and release notes + run: | + cp docs/bootstrap/agent-node-setup.md dist/agent-bootstrap.md + cp docs/bootstrap/agent-node-setup.en.md dist/agent-bootstrap.en.md + scripts/package-bootstrap-skill.sh \ + --version "${RELEASE_VERSION}" \ + --output "dist/agenet-node-bootstrap-v${RELEASE_VERSION}.tar.gz" + cp "docs/releases/v${RELEASE_VERSION}.md" \ + "dist/release-notes-v${RELEASE_VERSION}.md" + find dist -mindepth 1 -maxdepth 1 -type f \ + ! -name SHA256SUMS -printf '%f\n' \ + | LC_ALL=C sort \ + | while IFS= read -r file; do sha256sum "dist/${file}"; done \ + | sed 's# dist/# #' > dist/SHA256SUMS + scripts/verify-release.sh \ + --manifest dist/release-manifest-v1.json \ + --installer dist/install.sh \ + --checksums dist/SHA256SUMS \ + --archives-dir dist \ + --version "${RELEASE_VERSION}" \ + --commit "${GITHUB_SHA}" \ + --renderer target/debug/agenet-render-installer \ + --complete \ + --release-dir dist + + - name: Attest macOS arm64 archive + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-path: dist/agenet-v${{ env.RELEASE_VERSION }}-aarch64-apple-darwin.tar.gz + + - name: Attest macOS x86_64 archive + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-path: dist/agenet-v${{ env.RELEASE_VERSION }}-x86_64-apple-darwin.tar.gz + + - name: Attest Linux arm64 archive + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-path: dist/agenet-v${{ env.RELEASE_VERSION }}-aarch64-unknown-linux-gnu.tar.gz + + - name: Attest Linux x86_64 archive + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 + with: + subject-path: dist/agenet-v${{ env.RELEASE_VERSION }}-x86_64-unknown-linux-gnu.tar.gz + + - name: Publish once or prove exact idempotency + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF_NAME}" + if gh release view "${tag}" >/dev/null 2>&1; then + comparison=$(mktemp -d) + trap 'rm -rf -- "${comparison}"' EXIT + gh release download "${tag}" --dir "${comparison}" + find dist -mindepth 1 -maxdepth 1 -type f -printf '%f\n' \ + | LC_ALL=C sort > expected-assets.txt + find "${comparison}" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' \ + | LC_ALL=C sort > existing-assets.txt + diff -u expected-assets.txt existing-assets.txt + while IFS= read -r asset; do + cmp "dist/${asset}" "${comparison}/${asset}" + done < expected-assets.txt + release_state=$(gh release view "${tag}" \ + --json isPrerelease,isDraft \ + --jq '"\(.isPrerelease) \(.isDraft)"') + test "${release_state}" = 'true false' + else + gh release create "${tag}" dist/* \ + --prerelease \ + --verify-tag \ + --title "AgenNet ${tag} Developer Preview" \ + --notes-file "docs/releases/v${RELEASE_VERSION}.md" + fi + + - name: Upload complete verified release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: preview-release-${{ env.RELEASE_VERSION }} + path: dist/* + if-no-files-found: error + retention-days: 30 diff --git a/Cargo.toml b/Cargo.toml index 0028165..57486ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,10 @@ path = "src/main.rs" name = "agenet-release-manifest" path = "src/bin/agenet-release-manifest.rs" +[[bin]] +name = "agenet-render-installer" +path = "src/bin/agenet-render-installer.rs" + [dependencies] age = "=0.12.1" axum = "=0.8.9" diff --git a/ROADMAP.md b/ROADMAP.md index 21dad76..6550bfa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Generate the verified preview installer + +- **Change**: Added a manifest-rendered fixed-version POSIX installer, strict offline whole-release verification, checksum generation, four archive attestations, and a tag-only idempotent prerelease publication job. +- **Files**: `src/release/installer.rs`, `src/bin/agenet-render-installer.rs`, `scripts/install.sh.template`, `scripts/verify-release.sh`, installer Rust/shell tests, `.github/workflows/release.yml`, and `packaging/release.md`. +- **Decision reason**: A one-sentence Agent installation path is only trustworthy when target selection, URL, size, digest, archive layout, binary version, and final release asset set all derive from one immutable manifest and fail closed on divergence. +- **Security boundary**: The installer accepts no arguments or secrets, uses HTTPS-only bounded downloads, rejects native Windows and unsafe archives, installs only to `~/.local/bin`, preserves a different existing binary, cleans interrupted staging, and never enrolls or runs `node join`. The publish job alone receives scoped write and attestation permissions. +- **Evidence**: Rust rendering tests and shell scenarios cover four supported OS/architecture pairs, WSL-shaped Linux, same-version idempotency, different-binary preservation, download/hash/size corruption, unsafe links, unwritable or interrupted destinations, secret sentinel absence, and exact offline checksum-set validation. Official `actionlint` v1.7.12 and ShellCheck v0.11.0 pass after their downloaded archives are verified against upstream SHA-256 values. +- **Boundary**: No tag or GitHub prerelease is published by this step. Canonical guides, the bootstrap Skill, release notes, GPT Sites deployment, fresh WSL2 Agent acceptance, and physical two-device acceptance remain pending and will block the tag workflow until present. + ## 2026-08-15 — Build deterministic preview release archives - **Change**: Added exact native archives for macOS arm64/x86_64 and Linux arm64/x86_64, a fail-closed offline archive checker, the MIT license payload, packaging documentation, and a native GitHub Actions matrix pinned to immutable official Action commits. diff --git a/packaging/release.md b/packaging/release.md index 5ee32b8..8b1337f 100644 --- a/packaging/release.md +++ b/packaging/release.md @@ -13,8 +13,27 @@ preview version. Build jobs must pass explicit binary, target, version, full commit, and output paths to `scripts/package-release.sh`. Consumers must call -`scripts/check-release-archive.sh` before extraction. The public installer adds -the manifest checksum and attestation checks defined by the next release task. +`scripts/check-release-archive.sh` before extraction. + +The public installer is rendered from the strict manifest. It selects one of +the four immutable release URLs, bounds the HTTPS-only download, verifies the +exact size and SHA-256 digest, rejects unsafe archive layouts, smoke-tests the +binary version, and publishes only to `~/.local/bin/agenet`. It accepts no +arguments or enrollment material, never runs `node join`, and preserves an +existing different binary. + +`scripts/verify-release.sh` verifies the manifest, regenerated installer, +checksum file, and all four archives without using the network. Its +`--complete` mode additionally requires the two raw Agent guides, the +versioned bootstrap Skill archive, and release notes. `SHA256SUMS` covers every +downloadable release asset except itself. Four GitHub artifact attestations +bind the native archives to the tag workflow. + +The tag-only publication job has the minimum `contents`, `id-token`, and +`attestations` write permissions. Branch builds cannot publish. On a rerun for +an existing tag, the job downloads every existing asset and compares the exact +name set and bytes; it never overwrites or silently replaces a divergent +prerelease. These archives are a Developer Preview. They do not claim native Windows, physical two-device acceptance, arbitrary code sandboxing, or stable protocol diff --git a/scripts/install.sh.template b/scripts/install.sh.template new file mode 100644 index 0000000..e0a3f01 --- /dev/null +++ b/scripts/install.sh.template @@ -0,0 +1,146 @@ +#!/bin/sh +# shellcheck disable=SC2317,SC2329 +set -eu +umask 077 + +AGENET_RELEASE_VERSION='@@VERSION@@' +release_target= +archive_url= +archive_sha256= +archive_size= + +fail() { + printf '%s\n' "$1" >&2 + exit "${2:-1}" +} + +print_next() { + printf '%s\n' 'Next: agenet node doctor --json' + case ":${PATH:-}:" in + *":$HOME/.local/bin:"*) ;; + *) printf '%s\n' 'Add ~/.local/bin to PATH before running agenet.' ;; + esac +} + +if [ "$#" -ne 0 ]; then + fail 'InstallerAcceptsNoArguments' 64 +fi +if [ -z "${HOME:-}" ]; then + fail 'HomeDirectoryUnavailable' 66 +fi +case "$HOME" in + /*) ;; + *) fail 'HomeDirectoryUnavailable' 66 ;; +esac + +system=$(uname -s 2>/dev/null || true) +machine=$(uname -m 2>/dev/null || true) +case "$system" in + MINGW*|MSYS*|CYGWIN*|Windows*) fail 'UnsupportedNativeWindowsUseWSL2' 65 ;; +esac +case "$system:$machine" in +# @@TARGET_CASES@@ + *) fail 'UnsupportedReleaseTarget' 65 ;; +esac + +if ! command -v curl >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then + fail 'InstallerDependencyUnavailable' 69 +fi + +private_dir=$(mktemp -d "${TMPDIR:-/tmp}/agenet-install.XXXXXX") || \ + fail 'InstallerTemporaryDirectoryUnavailable' 73 +pending_destination= +cleanup() { + if [ -n "$pending_destination" ]; then + rm -f -- "$pending_destination" + fi + rm -rf -- "$private_dir" +} +trap cleanup EXIT HUP INT TERM + +archive="$private_dir/archive.tar.gz" +effective_url=$(curl --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' --max-redirs 5 \ + --connect-timeout 10 --max-time 120 --max-filesize "$archive_size" \ + --output "$archive" --write-out '%{url_effective}' "$archive_url") || \ + fail 'ReleaseDownloadFailed' 69 +case "$effective_url" in + https://github.com/Nexa-Language/AgenNet/releases/download/*|https://release-assets.githubusercontent.com/*) ;; + *) fail 'UnapprovedReleaseRedirect' 65 ;; +esac + +downloaded_size=$(wc -c <"$archive" | tr -d '[:space:]') +if [ "$downloaded_size" != "$archive_size" ]; then + fail 'ReleaseArchiveSizeMismatch' 65 +fi +if command -v sha256sum >/dev/null 2>&1; then + actual_sha256=$(sha256sum "$archive" | awk '{print $1}') +elif command -v shasum >/dev/null 2>&1; then + actual_sha256=$(shasum -a 256 "$archive" | awk '{print $1}') +else + fail 'ChecksumToolUnavailable' 69 +fi +if [ "$actual_sha256" != "$archive_sha256" ]; then + fail 'ReleaseChecksumMismatch' 65 +fi + +root="agenet-v${AGENET_RELEASE_VERSION}-${release_target}" +tar -tzf "$archive" >"$private_dir/names" || fail 'InvalidReleaseArchive' 65 +cat >"$private_dir/expected-names" <"$private_dir/layout" || fail 'InvalidReleaseArchive' 65 +cat >"$private_dir/expected-layout" <"$staged_binary" || \ + fail 'InvalidReleaseArchive' 65 +chmod 0700 "$staged_binary" +reported_version=$($staged_binary --version 2>/dev/null || true) +if [ "$reported_version" != "agenet $AGENET_RELEASE_VERSION" ]; then + fail 'ReleaseBinaryVersionMismatch' 65 +fi + +local_dir="$HOME/.local" +bin_dir="$local_dir/bin" +if [ -L "$local_dir" ] || [ -L "$bin_dir" ]; then + fail 'UnsafeInstallDirectory' 73 +fi +mkdir -p -- "$bin_dir" || fail 'InstallDirectoryUnavailable' 73 +destination="$bin_dir/agenet" +if [ -e "$destination" ] || [ -L "$destination" ]; then + if [ ! -f "$destination" ] || [ -L "$destination" ]; then + fail 'UnsafeExistingAgenNetPath' 73 + fi + if cmp -s "$staged_binary" "$destination"; then + printf '%s\n' "AgenNet $AGENET_RELEASE_VERSION is already installed." + print_next + exit 0 + fi + fail 'ExistingAgenNetBinaryDiffers' 73 +fi + +pending_destination=$(mktemp "$bin_dir/.agenet.XXXXXX") || \ + fail 'InstallDestinationUnavailable' 73 +cp -- "$staged_binary" "$pending_destination" || fail 'InstallWriteFailed' 73 +chmod 0755 "$pending_destination" +mv -- "$pending_destination" "$destination" || fail 'InstallPublishFailed' 73 +pending_destination= + +printf '%s\n' "Installed AgenNet $AGENET_RELEASE_VERSION to ~/.local/bin/agenet" +print_next diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh new file mode 100755 index 0000000..f3d0d0e --- /dev/null +++ b/scripts/verify-release.sh @@ -0,0 +1,206 @@ +#!/bin/sh +set -eu + +usage() { + cat <<'EOF' +Usage: scripts/verify-release.sh \ + --manifest PATH --installer PATH --checksums PATH --archives-dir DIR \ + --version VERSION --commit FULL_SHA [--renderer PATH] \ + [--complete --release-dir DIR] + +Verify the complete offline AgenNet installer release core. The renderer must +be the agenet-render-installer binary built from the same source commit. +EOF +} + +fail() { + printf '%s\n' "ReleaseVerificationFailed: $1" >&2 + exit 2 +} + +manifest= +installer= +checksums= +archives_dir= +version= +commit= +renderer= +complete=0 +release_dir= + +while [ "$#" -gt 0 ]; do + case "$1" in + --manifest) manifest=${2:-}; shift 2 ;; + --installer) installer=${2:-}; shift 2 ;; + --checksums) checksums=${2:-}; shift 2 ;; + --archives-dir) archives_dir=${2:-}; shift 2 ;; + --version) version=${2:-}; shift 2 ;; + --commit) commit=${2:-}; shift 2 ;; + --renderer) renderer=${2:-}; shift 2 ;; + --complete) complete=1; shift ;; + --release-dir) release_dir=${2:-}; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; fail 'InvalidArguments' ;; + esac +done + +[ -n "$manifest" ] || fail 'MissingManifest' +[ -n "$installer" ] || fail 'MissingInstaller' +[ -n "$checksums" ] || fail 'MissingChecksums' +[ -n "$archives_dir" ] || fail 'MissingArchivesDirectory' +[ -n "$version" ] || fail 'MissingVersion' +[ -n "$commit" ] || fail 'MissingCommit' + +case "$commit" in + *[!0-9a-f]*|'') fail 'InvalidCommit' ;; +esac +[ "${#commit}" -eq 40 ] || fail 'InvalidCommit' +[ -d "$archives_dir" ] && [ ! -L "$archives_dir" ] || fail 'InvalidArchivesDirectory' +if [ "$complete" -eq 1 ]; then + [ -n "$release_dir" ] || fail 'MissingReleaseDirectory' + [ -d "$release_dir" ] && [ ! -L "$release_dir" ] || fail 'InvalidReleaseDirectory' +fi + +if [ -z "$renderer" ]; then + repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) + renderer="$repo_root/target/debug/agenet-render-installer" +fi +[ -x "$renderer" ] && [ ! -L "$renderer" ] || fail 'MissingInstallerRenderer' + +temporary=$(mktemp -d "${TMPDIR:-/tmp}/agenet-release-verify.XXXXXX") \ + || fail 'TemporaryDirectoryFailed' +cleanup() { + rm -rf -- "$temporary" +} +trap cleanup EXIT HUP INT TERM + +expected_installer="$temporary/install.sh" +if ! "$renderer" --manifest "$manifest" --output "$expected_installer"; then + fail 'InvalidReleaseManifest' +fi +cmp -s "$expected_installer" "$installer" || fail 'InstallerManifestMismatch' + +python3 - "$manifest" "$installer" "$checksums" "$archives_dir" \ + "$version" "$commit" "$complete" "${release_dir:-$archives_dir}" <<'PY' || exit $? +import hashlib +import json +import pathlib +import stat +import sys + + +def fail(code: str) -> None: + print(f"ReleaseVerificationFailed: {code}", file=sys.stderr) + raise SystemExit(2) + + +def regular_file(path: pathlib.Path, maximum: int) -> bytes: + try: + metadata = path.lstat() + except OSError: + fail("MissingReleaseFile") + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum: + fail("InvalidReleaseFile") + try: + return path.read_bytes() + except OSError: + fail("ReleaseFileReadFailed") + + +manifest_path = pathlib.Path(sys.argv[1]) +installer_path = pathlib.Path(sys.argv[2]) +checksums_path = pathlib.Path(sys.argv[3]) +archives_dir = pathlib.Path(sys.argv[4]) +version = sys.argv[5] +commit = sys.argv[6] +complete = sys.argv[7] == "1" +release_dir = pathlib.Path(sys.argv[8]) + +manifest_bytes = regular_file(manifest_path, 256 * 1024) +installer_bytes = regular_file(installer_path, 256 * 1024) +checksums_bytes = regular_file(checksums_path, 64 * 1024) +try: + manifest = json.loads(manifest_bytes) +except (UnicodeDecodeError, json.JSONDecodeError): + fail("InvalidReleaseManifest") + +if manifest.get("version") != version or manifest.get("git_commit") != commit: + fail("ReleaseIdentityMismatch") +artifacts = manifest.get("artifacts") +if not isinstance(artifacts, dict) or set(artifacts) != { + "macos-arm64", + "macos-x86_64", + "linux-arm64", + "linux-x86_64", +}: + fail("IncompleteReleaseArtifacts") + +files = { + manifest_path.name: manifest_bytes, + installer_path.name: installer_bytes, +} +for artifact in artifacts.values(): + if not isinstance(artifact, dict): + fail("InvalidReleaseManifest") + name = artifact.get("file_name") + digest = artifact.get("sha256") + size = artifact.get("size_bytes") + if not isinstance(name, str) or pathlib.PurePath(name).name != name: + fail("InvalidArchiveName") + archive_bytes = regular_file(archives_dir / name, 512 * 1024 * 1024) + if len(archive_bytes) != size: + fail("ReleaseArchiveSizeMismatch") + if hashlib.sha256(archive_bytes).hexdigest() != digest: + fail("ReleaseChecksumMismatch") + files[name] = archive_bytes + +if complete: + for name, maximum in { + "agent-bootstrap.md": 256 * 1024, + "agent-bootstrap.en.md": 256 * 1024, + f"agenet-node-bootstrap-v{version}.tar.gz": 16 * 1024 * 1024, + f"release-notes-v{version}.md": 256 * 1024, + }.items(): + files[name] = regular_file(release_dir / name, maximum) + +try: + lines = checksums_bytes.decode("ascii").splitlines() +except UnicodeDecodeError: + fail("InvalidChecksumFile") +parsed = {} +for line in lines: + if len(line) < 67 or line[64:66] != " ": + fail("InvalidChecksumFile") + digest, name = line[:64], line[66:] + if ( + len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + or pathlib.PurePath(name).name != name + or name in parsed + ): + fail("InvalidChecksumFile") + parsed[name] = digest +if set(parsed) != set(files): + fail("ChecksumFileSetMismatch") +if lines != sorted(lines, key=lambda line: line[66:]): + fail("ChecksumFileOrderMismatch") +for name, body in files.items(): + if hashlib.sha256(body).hexdigest() != parsed[name]: + fail("ReleaseChecksumMismatch") +PY + +for target in \ + aarch64-apple-darwin \ + x86_64-apple-darwin \ + aarch64-unknown-linux-gnu \ + x86_64-unknown-linux-gnu; do + archive="$archives_dir/agenet-v$version-$target.tar.gz" + scripts_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) + "$scripts_dir/check-release-archive.sh" \ + --archive "$archive" \ + --target "$target" \ + --version "$version" \ + --commit "$commit" >/dev/null || fail 'InvalidReleaseArchive' +done + +printf '%s\n' 'AgenNet release core verified' diff --git a/src/bin/agenet-render-installer.rs b/src/bin/agenet-render-installer.rs new file mode 100644 index 0000000..0f19c2f --- /dev/null +++ b/src/bin/agenet-render-installer.rs @@ -0,0 +1,38 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use agenet::release::{ReleaseManifestV1, write_installer_atomic}; +use clap::Parser; + +const MAX_MANIFEST_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Parser)] +#[command(name = "agenet-render-installer")] +struct Args { + #[arg(long)] + manifest: PathBuf, + #[arg(long)] + output: PathBuf, +} + +fn main() { + if run().is_err() { + eprintln!("InstallerRenderFailed"); + std::process::exit(1); + } +} + +fn run() -> Result<(), ()> { + let args = Args::parse(); + let bytes = read_bounded_regular(&args.manifest)?; + let manifest: ReleaseManifestV1 = serde_json::from_slice(&bytes).map_err(|_| ())?; + write_installer_atomic(&manifest, &args.output).map_err(|_| ()) +} + +fn read_bounded_regular(path: &Path) -> Result, ()> { + let metadata = fs::symlink_metadata(path).map_err(|_| ())?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_MANIFEST_BYTES { + return Err(()); + } + fs::read(path).map_err(|_| ()) +} diff --git a/src/release/installer.rs b/src/release/installer.rs new file mode 100644 index 0000000..104bcff --- /dev/null +++ b/src/release/installer.rs @@ -0,0 +1,83 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::Path; + +use uuid::Uuid; + +use super::{ReleaseManifestError, ReleaseManifestV1, ReleaseTarget}; + +const TEMPLATE: &str = include_str!("../../scripts/install.sh.template"); + +pub fn render_installer(manifest: &ReleaseManifestV1) -> Result { + manifest.validate()?; + let mut cases = String::new(); + for target in ReleaseTarget::ALL { + let artifact = manifest + .artifacts + .get(&target) + .ok_or(ReleaseManifestError::IncompleteTargetSet)?; + let selector = match target { + ReleaseTarget::MacosArm64 => "Darwin:arm64|Darwin:aarch64", + ReleaseTarget::MacosX86_64 => "Darwin:x86_64|Darwin:amd64", + ReleaseTarget::LinuxArm64 => "Linux:aarch64|Linux:arm64", + ReleaseTarget::LinuxX86_64 => "Linux:x86_64|Linux:amd64", + }; + cases.push_str(&format!( + " {selector})\n release_target='{}'\n archive_url='{}'\n archive_sha256='{}'\n archive_size='{}'\n ;;\n", + target.rust_triple(), + artifact.download_url, + artifact.sha256, + artifact.size_bytes + )); + } + Ok(TEMPLATE + .replace("@@VERSION@@", &manifest.version) + .replace("# @@TARGET_CASES@@", &cases)) +} + +pub fn write_installer_atomic( + manifest: &ReleaseManifestV1, + output: &Path, +) -> Result<(), ReleaseManifestError> { + let rendered = render_installer(manifest)?; + let parent = output + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let file_name = output + .file_name() + .and_then(|name| name.to_str()) + .ok_or(ReleaseManifestError::InvalidFileName)?; + let temporary = parent.join(format!(".{file_name}.{}.tmp", Uuid::new_v4())); + let result = write_and_publish(&temporary, output, parent, rendered.as_bytes()); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +fn write_and_publish( + temporary: &Path, + output: &Path, + parent: &Path, + bytes: &[u8], +) -> Result<(), ReleaseManifestError> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o644); + } + let mut file = options + .open(temporary) + .map_err(|_| ReleaseManifestError::Io)?; + file.write_all(bytes) + .map_err(|_| ReleaseManifestError::Io)?; + file.flush().map_err(|_| ReleaseManifestError::Io)?; + file.sync_all().map_err(|_| ReleaseManifestError::Io)?; + fs::rename(temporary, output).map_err(|_| ReleaseManifestError::Io)?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| ReleaseManifestError::Io) +} diff --git a/src/release/mod.rs b/src/release/mod.rs index b470a60..41dc95e 100644 --- a/src/release/mod.rs +++ b/src/release/mod.rs @@ -1,5 +1,7 @@ +mod installer; mod manifest; +pub use installer::{render_installer, write_installer_atomic}; pub use manifest::{ PREVIEW_VERSION, ReleaseArtifact, ReleaseManifestError, ReleaseManifestV1, ReleaseTarget, }; diff --git a/tests/installer.rs b/tests/installer.rs new file mode 100644 index 0000000..e9d536c --- /dev/null +++ b/tests/installer.rs @@ -0,0 +1,120 @@ +use std::collections::BTreeMap; +use std::fs; +use std::process::Command; + +use agenet::release::{ + PREVIEW_VERSION, ReleaseArtifact, ReleaseManifestError, ReleaseManifestV1, ReleaseTarget, + render_installer, +}; + +fn valid_manifest() -> ReleaseManifestV1 { + let artifacts = ReleaseTarget::ALL + .into_iter() + .map(|target| { + let file_name = target.archive_name(PREVIEW_VERSION); + ( + target, + ReleaseArtifact { + download_url: format!( + "https://github.com/Nexa-Language/AgenNet/releases/download/v{PREVIEW_VERSION}/{file_name}" + ), + sha256: format!("{:064x}", target as u8 + 1), + size_bytes: 1024 + target as u64, + attestation_subject: file_name.clone(), + file_name, + }, + ) + }) + .collect::>(); + ReleaseManifestV1 { + schema_version: 1, + project: "AgenNet".to_owned(), + version: PREVIEW_VERSION.to_owned(), + git_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(), + published_at: "2026-08-15T12:00:00Z".to_owned(), + artifacts, + } +} + +#[test] +fn rendered_installer_embeds_only_fixed_verified_release_inputs() { + let manifest = valid_manifest(); + let rendered = render_installer(&manifest).expect("render installer"); + + assert!(rendered.contains("AGENET_RELEASE_VERSION='0.2.0-preview.1'")); + for artifact in manifest.artifacts.values() { + assert!(rendered.contains(&artifact.download_url)); + assert!(rendered.contains(&artifact.sha256)); + assert!(rendered.contains(&artifact.size_bytes.to_string())); + } + for forbidden in [ + "/latest/", + "/heads/", + "sudo", + "node join", + "OPENAI_API_KEY", + "Invitation", + ] { + assert!(!rendered.contains(forbidden), "found {forbidden}"); + } +} + +#[test] +fn rendered_installer_has_bounded_download_and_safe_archive_checks() { + let rendered = render_installer(&valid_manifest()).expect("render installer"); + + for required in [ + "--max-filesize", + "--proto '=https'", + "sha256sum", + "shasum -a 256", + "InvalidReleaseArchive", + "ExistingAgenNetBinaryDiffers", + "~/.local/bin", + "Add ~/.local/bin to PATH", + ] { + assert!(rendered.contains(required), "missing {required}"); + } +} + +#[test] +fn rendering_is_deterministic_and_rejects_an_invalid_manifest() { + let manifest = valid_manifest(); + assert_eq!( + render_installer(&manifest).unwrap(), + render_installer(&manifest).unwrap() + ); + + let mut invalid = manifest; + invalid.version = "0.2.0".to_owned(); + assert_eq!( + render_installer(&invalid), + Err(ReleaseManifestError::UnsupportedReleaseVersion) + ); +} + +#[test] +fn renderer_cli_writes_the_exact_script_atomically() { + let directory = tempfile::tempdir().unwrap(); + let manifest_path = directory.path().join("manifest.json"); + let output_path = directory.path().join("install.sh"); + let manifest = valid_manifest(); + fs::write(&manifest_path, manifest.to_pretty_json().unwrap()).unwrap(); + + let result = Command::new(env!("CARGO_BIN_EXE_agenet-render-installer")) + .args([ + "--manifest", + manifest_path.to_str().unwrap(), + "--output", + output_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(result.status.success(), "{result:?}"); + assert!(result.stdout.is_empty()); + assert!(result.stderr.is_empty()); + assert_eq!( + fs::read_to_string(output_path).unwrap(), + render_installer(&manifest).unwrap() + ); +} diff --git a/tests/scripts/installer-smoke.sh b/tests/scripts/installer-smoke.sh new file mode 100755 index 0000000..a7b8a1f --- /dev/null +++ b/tests/scripts/installer-smoke.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd) +version=0.2.0-preview.1 +commit=0123456789abcdef0123456789abcdef01234567 +fixture=$(mktemp -d "${TMPDIR:-/tmp}/agenet-installer.XXXXXX") +cleanup() { + rm -rf -- "$fixture" +} +trap cleanup EXIT + +cd "$repo_root" +cargo build --quiet --bin agenet-release-manifest --bin agenet-render-installer + +fake_binary="$fixture/agenet" +cat >"$fake_binary" </dev/null +done + +render() { + manifest=$1 + installer=$2 + target/debug/agenet-release-manifest \ + --version "$version" \ + --commit "$commit" \ + --published-at 2026-08-15T12:00:00Z \ + --macos-arm64 "$archives/agenet-v$version-aarch64-apple-darwin.tar.gz" \ + --macos-x86-64 "$archives/agenet-v$version-x86_64-apple-darwin.tar.gz" \ + --linux-arm64 "$archives/agenet-v$version-aarch64-unknown-linux-gnu.tar.gz" \ + --linux-x86-64 "$archives/agenet-v$version-x86_64-unknown-linux-gnu.tar.gz" \ + --output "$manifest" + target/debug/agenet-render-installer --manifest "$manifest" --output "$installer" +} + +render "$fixture/manifest.json" "$fixture/install.sh" + +( + cd "$archives" + sha256sum \ + "agenet-v$version-aarch64-apple-darwin.tar.gz" \ + "agenet-v$version-aarch64-unknown-linux-gnu.tar.gz" \ + "agenet-v$version-x86_64-apple-darwin.tar.gz" \ + "agenet-v$version-x86_64-unknown-linux-gnu.tar.gz" \ + "$fixture/install.sh" \ + "$fixture/manifest.json" | + sed "s# $fixture/# #" | + LC_ALL=C sort -k2 >"$fixture/SHA256SUMS" +) +scripts/verify-release.sh \ + --manifest "$fixture/manifest.json" \ + --installer "$fixture/install.sh" \ + --checksums "$fixture/SHA256SUMS" \ + --archives-dir "$archives" \ + --version "$version" \ + --commit "$commit" \ + --renderer target/debug/agenet-render-installer >/dev/null + +cp "$fixture/SHA256SUMS" "$fixture/SHA256SUMS.invalid" +printf '%s\n' '0 unexpected' >>"$fixture/SHA256SUMS.invalid" +if scripts/verify-release.sh \ + --manifest "$fixture/manifest.json" \ + --installer "$fixture/install.sh" \ + --checksums "$fixture/SHA256SUMS.invalid" \ + --archives-dir "$archives" \ + --version "$version" \ + --commit "$commit" \ + --renderer target/debug/agenet-render-installer \ + >"$fixture/verify-invalid.out" 2>&1; then + echo 'invalid checksum set was accepted' >&2 + exit 1 +fi +grep -F 'InvalidChecksumFile' "$fixture/verify-invalid.out" >/dev/null + +fake_bin="$fixture/fake-bin" +mkdir "$fake_bin" +cat >"$fake_bin/uname" <<'EOF' +#!/bin/sh +case "${1:-}" in + -s) printf '%s\n' "${TEST_UNAME_S:-Linux}" ;; + -m) printf '%s\n' "${TEST_UNAME_M:-x86_64}" ;; + -r) printf '%s\n' "${TEST_UNAME_R:-6.6.0-microsoft-standard-WSL2}" ;; + *) exit 2 ;; +esac +EOF +cat >"$fake_bin/curl" <<'EOF' +#!/bin/sh +set -eu +output= +url= +while [ "$#" -gt 0 ]; do + case "$1" in + --output) output=$2; shift 2 ;; + --proto|--proto-redir|--max-redirs|--connect-timeout|--max-time|--max-filesize|--write-out) + shift 2 + ;; + --fail|--silent|--show-error|--location) shift ;; + https://*) url=$1; shift ;; + *) exit 64 ;; + esac +done +if [ "${TEST_DOWNLOAD_FAIL:-0}" = 1 ]; then + exit 22 +fi +cp "$AGENET_TEST_ARCHIVES/${url##*/}" "$output" +if [ "${TEST_CORRUPT_DOWNLOAD:-0}" = 1 ]; then + printf 'x' >>"$output" +fi +printf '%s' "$url" +EOF +cat >"$fake_bin/cp" <<'EOF' +#!/bin/sh +set -eu +destination= +for argument in "$@"; do + destination=$argument +done +case "$destination" in + */.agenet.*) + if [ "${TEST_COPY_FAIL:-0}" = 1 ]; then + exit 74 + fi + ;; +esac +exec /bin/cp "$@" +EOF +chmod 0755 "$fake_bin/uname" "$fake_bin/curl" "$fake_bin/cp" + +run_installer() { + home=$1 + system=$2 + machine=$3 + installer=${4:-$fixture/install.sh} + mkdir -p "$home" + HOME="$home" \ + PATH="$fake_bin:/usr/bin:/bin" \ + TMPDIR="$fixture" \ + AGENET_TEST_ARCHIVES="$archives" \ + TEST_UNAME_S="$system" \ + TEST_UNAME_M="$machine" \ + sh "$installer" +} + +while read -r scenario system machine; do + output=$(run_installer "$fixture/home-$scenario" "$system" "$machine" 2>&1) + printf '%s' "$output" | grep -F "Installed AgenNet $version" >/dev/null + test "$("$fixture/home-$scenario/.local/bin/agenet" --version)" = "agenet $version" +done <<'EOF' +mac-arm64 Darwin arm64 +mac-x86 Darwin x86_64 +wsl-arm64 Linux aarch64 +wsl-x86 Linux x86_64 +EOF + +idempotent=$(run_installer "$fixture/home-wsl-x86" Linux x86_64 2>&1) +printf '%s' "$idempotent" | grep -F 'is already installed' >/dev/null + +different_home="$fixture/home-different" +mkdir -p "$different_home/.local/bin" +printf '%s\n' 'keep-me' >"$different_home/.local/bin/agenet" +if run_installer "$different_home" Linux x86_64 >"$fixture/different.out" 2>&1; then + echo 'different existing binary was replaced' >&2 + exit 1 +fi +grep -F 'ExistingAgenNetBinaryDiffers' "$fixture/different.out" >/dev/null +test "$(cat "$different_home/.local/bin/agenet")" = 'keep-me' + +if TEST_CORRUPT_DOWNLOAD=1 run_installer "$fixture/home-checksum" Linux x86_64 \ + >"$fixture/checksum.out" 2>&1; then + echo 'checksum mismatch was accepted' >&2 + exit 1 +fi +grep -E 'ReleaseArchiveSizeMismatch|ReleaseChecksumMismatch' "$fixture/checksum.out" >/dev/null +test ! -e "$fixture/home-checksum/.local/bin/agenet" + +if TEST_DOWNLOAD_FAIL=1 run_installer "$fixture/home-download" Linux x86_64 \ + >"$fixture/download.out" 2>&1; then + echo 'failed download was accepted' >&2 + exit 1 +fi +grep -F 'ReleaseDownloadFailed' "$fixture/download.out" >/dev/null +test ! -e "$fixture/home-download/.local/bin/agenet" + +if TEST_COPY_FAIL=1 run_installer "$fixture/home-interrupted" Linux x86_64 \ + >"$fixture/interrupted.out" 2>&1; then + echo 'interrupted staging was accepted' >&2 + exit 1 +fi +grep -F 'InstallWriteFailed' "$fixture/interrupted.out" >/dev/null +test ! -e "$fixture/home-interrupted/.local/bin/agenet" +test -z "$(find "$fixture/home-interrupted/.local/bin" -name '.agenet.*' -print)" + +unwritable_home="$fixture/home-unwritable" +mkdir "$unwritable_home" +chmod 0500 "$unwritable_home" +if run_installer "$unwritable_home" Linux x86_64 \ + >"$fixture/unwritable.out" 2>&1; then + chmod 0700 "$unwritable_home" + echo 'unwritable destination was accepted' >&2 + exit 1 +fi +chmod 0700 "$unwritable_home" +grep -F 'InstallDirectoryUnavailable' "$fixture/unwritable.out" >/dev/null + +if run_installer "$fixture/home-windows" MINGW64_NT-10.0 x86_64 \ + >"$fixture/windows.out" 2>&1; then + echo 'native Windows was accepted' >&2 + exit 1 +fi +grep -F 'UnsupportedNativeWindowsUseWSL2' "$fixture/windows.out" >/dev/null + +if run_installer "$fixture/home-unknown" Linux riscv64 \ + >"$fixture/unknown.out" 2>&1; then + echo 'unknown architecture was accepted' >&2 + exit 1 +fi +grep -F 'UnsupportedReleaseTarget' "$fixture/unknown.out" >/dev/null + +sentinel='AGENET_TEST_SECRET_DO_NOT_PRINT_4bd41b' +output=$(TEST_SECRET="$sentinel" run_installer "$fixture/home-secret" Linux x86_64 2>&1) +if printf '%s' "$output" | grep -F "$sentinel" >/dev/null; then + echo 'ambient secret was printed' >&2 + exit 1 +fi + +python3 - "$archives/agenet-v$version-x86_64-unknown-linux-gnu.tar.gz" \ + "$fixture/unsafe.tar.gz" <<'PY' +import io +import pathlib +import sys +import tarfile + +source = pathlib.Path(sys.argv[1]) +output = pathlib.Path(sys.argv[2]) +with tarfile.open(source, "r:gz") as archive: + members = archive.getmembers() + bodies = { + member.name: archive.extractfile(member).read() + for member in members + if member.isfile() + } +member = next(item for item in members if item.name.endswith("README.md")) +member.type = tarfile.SYMTYPE +member.linkname = "/etc/passwd" +member.size = 0 +bodies.pop(member.name) +with tarfile.open(output, "w:gz", format=tarfile.USTAR_FORMAT) as archive: + for item in members: + body = bodies.get(item.name) + archive.addfile(item, io.BytesIO(body) if body is not None else None) +PY +unsafe_name="$archives/agenet-v$version-x86_64-unknown-linux-gnu.tar.gz" +mv "$unsafe_name" "$fixture/valid-linux-x86.tar.gz" +mv "$fixture/unsafe.tar.gz" "$unsafe_name" +render "$fixture/unsafe-manifest.json" "$fixture/unsafe-install.sh" +if run_installer "$fixture/home-unsafe" Linux x86_64 "$fixture/unsafe-install.sh" \ + >"$fixture/unsafe.out" 2>&1; then + echo 'unsafe archive was accepted' >&2 + exit 1 +fi +grep -F 'InvalidReleaseArchive' "$fixture/unsafe.out" >/dev/null +test ! -e "$fixture/home-unsafe/.local/bin/agenet" + +echo 'installer smoke tests passed' From 6ed30c23f2ace134c6adc6d9c9ef051d38a97066 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 18:47:37 +0800 Subject: [PATCH 50/67] [bug] Fix installer doctor command Root cause: Public copy followed an obsolete CLI sketch. Solution: Use the compiled doctor output flag and reject the old form. Risks: NA Dependency: Release installer step 3. Links: plan/02-v2-installation-surfaces.md Post-mortem: Validate every advertised command against compiled CLI help. --- ROADMAP.md | 8 ++++++++ scripts/install.sh.template | 2 +- tests/installer.rs | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6550bfa..1884176 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,13 @@ # ROADMAP +## 2026-08-15 — Correct the installer doctor command + +- **Change**: Replaced the installer's advertised `agenet node doctor --json` follow-up with the real public CLI form `agenet node doctor --output json` and added a regression assertion that rejects the nonexistent flag. +- **Files**: `scripts/install.sh.template`, `tests/installer.rs`, and this roadmap. +- **Root cause / classification**: **计划集成缺口 / 规则违反**. The release copy was written from an earlier command sketch instead of being compared with the current Clap surface; shell installation tests only searched for successful installation text and therefore did not catch the invalid follow-up. +- **Prevention**: Every command printed by the installer, guide, Skill, or site must be extracted and checked against the compiled CLI help/argument parser before release sealing. Task 4 adds this parity gate for all public instructions. +- **Boundary**: The incorrect command was committed locally but never pushed, tagged, or published, so no external user consumed it. + ## 2026-08-15 — Generate the verified preview installer - **Change**: Added a manifest-rendered fixed-version POSIX installer, strict offline whole-release verification, checksum generation, four archive attestations, and a tag-only idempotent prerelease publication job. diff --git a/scripts/install.sh.template b/scripts/install.sh.template index e0a3f01..e8d427d 100644 --- a/scripts/install.sh.template +++ b/scripts/install.sh.template @@ -15,7 +15,7 @@ fail() { } print_next() { - printf '%s\n' 'Next: agenet node doctor --json' + printf '%s\n' 'Next: agenet node doctor --output json' case ":${PATH:-}:" in *":$HOME/.local/bin:"*) ;; *) printf '%s\n' 'Add ~/.local/bin to PATH before running agenet.' ;; diff --git a/tests/installer.rs b/tests/installer.rs index e9d536c..0da5e44 100644 --- a/tests/installer.rs +++ b/tests/installer.rs @@ -52,6 +52,7 @@ fn rendered_installer_embeds_only_fixed_verified_release_inputs() { "/heads/", "sudo", "node join", + "doctor --json", "OPENAI_API_KEY", "Invitation", ] { @@ -72,6 +73,7 @@ fn rendered_installer_has_bounded_download_and_safe_archive_checks() { "ExistingAgenNetBinaryDiffers", "~/.local/bin", "Add ~/.local/bin to PATH", + "agenet node doctor --output json", ] { assert!(rendered.contains(required), "missing {required}"); } From 83f98641935479b05503c13dbc495583f7231b85 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 18:53:21 +0800 Subject: [PATCH 51/67] [doc][Release][4/6] Publish guides Root cause: NA Solution: Add bilingual human and Agent guides with CLI parity gates. Risks: Public URLs remain unavailable until the release and site publish. Dependency: Verified installer step 3. Links: docs/bootstrap/agent-node-setup.md --- Cargo.toml | 4 + ROADMAP.md | 9 ++ docs/bootstrap/agent-node-setup.en.md | 72 +++++++++ docs/bootstrap/agent-node-setup.md | 66 ++++++++ docs/install/index.en.md | 70 +++++++++ docs/install/index.md | 65 ++++++++ src/bin/agenet-sync-public-guides.rs | 194 +++++++++++++++++++++++ tests/public_guides.rs | 213 ++++++++++++++++++++++++++ 8 files changed, 693 insertions(+) create mode 100644 docs/bootstrap/agent-node-setup.en.md create mode 100644 docs/bootstrap/agent-node-setup.md create mode 100644 docs/install/index.en.md create mode 100644 docs/install/index.md create mode 100644 src/bin/agenet-sync-public-guides.rs create mode 100644 tests/public_guides.rs diff --git a/Cargo.toml b/Cargo.toml index 57486ce..c8435ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,10 @@ path = "src/bin/agenet-release-manifest.rs" name = "agenet-render-installer" path = "src/bin/agenet-render-installer.rs" +[[bin]] +name = "agenet-sync-public-guides" +path = "src/bin/agenet-sync-public-guides.rs" + [dependencies] age = "=0.12.1" axum = "=0.8.9" diff --git a/ROADMAP.md b/ROADMAP.md index 1884176..4a52701 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Publish canonical bootstrap guides + +- **Change**: Added Chinese and English human installation guides, Chinese and English Agent node-setup guides, and an offline synchronizer that validates the exact preview manifest and compiled CLI before copying canonical public content. +- **Files**: `docs/install/`, `docs/bootstrap/agent-node-setup.md`, `docs/bootstrap/agent-node-setup.en.md`, `src/bin/agenet-sync-public-guides.rs`, `tests/public_guides.rs`, and `Cargo.toml`. +- **Decision reason**: The public one-sentence Agent workflow needs one canonical source for version, immutable download location, supported platforms, real CLI arguments, and the human-only controlling-TTY boundary. Separate prose without executable parity tests would drift. +- **Security boundary**: The guides verify the fixed installer against the same release's `SHA256SUMS`, reject moving URLs and native Windows, never pipe a network response directly into a shell, and never ask an Agent to receive an Invitation, passphrase, key, token, private address, CIDR, or raw terminal output. Enrollment stops at a literal local-TTY handoff. +- **Evidence**: Tests require byte-identical POSIX installation blocks in all four guides, parse the block with `sh -n`, execute advertised argument shapes against the compiled Clap binary, reject secret/path/moving-release patterns, and prove two synchronizer runs reproduce the checked-in bytes. +- **Boundary**: The documents are canonical source files but are not public URLs yet. The Skill, release notes, GitHub tag, GPT Sites deployment, fresh WSL2 Agent acceptance, and physical two-device acceptance remain pending. + ## 2026-08-15 — Correct the installer doctor command - **Change**: Replaced the installer's advertised `agenet node doctor --json` follow-up with the real public CLI form `agenet node doctor --output json` and added a regression assertion that rejects the nonexistent flag. diff --git a/docs/bootstrap/agent-node-setup.en.md b/docs/bootstrap/agent-node-setup.en.md new file mode 100644 index 0000000..bfcec0e --- /dev/null +++ b/docs/bootstrap/agent-node-setup.en.md @@ -0,0 +1,72 @@ +# AgenNet Agent Node Setup Guide + +Status: **v0.2.0-preview.1 — Developer Preview — physical acceptance pending**. + +This guide lets an Agent prepare a macOS, Linux, or WSL2 device as an AgenNet +node. native Windows is unsupported. You may install the public binary and +inspect public systemd and Tailscale/WireGuard readiness. You must not receive, +read, repeat, or fill an Invitation, passphrase, private key, token, private +address, CIDR, or raw terminal output. + +## Fixed installation procedure + +Use only the fixed release; never replace it with a moving tag or branch. Run +these commands locally: + +```sh +set -eu +release='https://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0-preview.1' +work=$(mktemp -d "${TMPDIR:-/tmp}/agenet-bootstrap.XXXXXX") +trap 'rm -rf -- "$work"' EXIT HUP INT TERM +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-redirs 5 --connect-timeout 10 --max-time 120 --output "$work/install.sh" "$release/install.sh" +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-redirs 5 --connect-timeout 10 --max-time 120 --output "$work/SHA256SUMS" "$release/SHA256SUMS" +expected=$(awk '$2 == "install.sh" { print $1 }' "$work/SHA256SUMS") +if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$work/install.sh" | awk '{print $1}'); else actual=$(shasum -a 256 "$work/install.sh" | awk '{print $1}'); fi +test -n "$expected" && test "$actual" = "$expected" +sh "$work/install.sh" +``` + +Run the following, then summarize only public conclusions rather than raw +output: + +```sh +agenet --version +agenet node doctor --output json +``` + +Confirm that the environment is not native Windows; WSL2 has an available +systemd user session; exactly one Tailscale or WireGuard address matches the +expected policy; and no different AgenNet binary or managed node state already +exists. On ambiguity, stop with a stable public error code. Never guess an +address or weaken a security control. + +## TTY boundary + +Do not perform enrollment. Do not ask the user to paste an Invitation into +chat, and do not place it in arguments, environment variables, a transfer +file, or stdin. When preparation is complete, reply verbatim: + +```text +Action required in your local terminal: + agenet node join +Do not paste the invitation or terminal output into this chat. +Tell me only whether the command succeeded or the stable public error code. +``` + +After the user reports only success or a stable public error code, you may run +the public checks: + +```sh +agenet node start --output json +agenet node status --output json +agenet node doctor --output json +``` + +Report only the version, whether the service is running, and the stable public +doctor status. Never display or inspect AgenNet private state files. + +## One-sentence Agent trigger + +```text +Read the official AgenNet node setup guide and use fixed v0.2.0-preview.1 to prepare this WSL2 machine as a Provider node; let me enter every Invitation and password only in my local TTY. +``` diff --git a/docs/bootstrap/agent-node-setup.md b/docs/bootstrap/agent-node-setup.md new file mode 100644 index 0000000..a119a30 --- /dev/null +++ b/docs/bootstrap/agent-node-setup.md @@ -0,0 +1,66 @@ +# AgenNet Agent 节点安装指南 + +状态:**v0.2.0-preview.1 — Developer Preview — physical acceptance pending**。 + +本指南供 Agent 把一台 macOS、Linux 或 WSL2 设备准备成 AgenNet 节点。 +native Windows 不受支持。你可以安装公开二进制、检查 systemd 与 +Tailscale/WireGuard 的公开就绪状态,但不能接收、读取、转述或代填 +Invitation、passphrase、私钥、token、私有地址、CIDR 或原始终端输出。 + +## 固定安装步骤 + +只使用固定 release,不得改成 moving tag 或分支。逐条在本机执行: + +```sh +set -eu +release='https://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0-preview.1' +work=$(mktemp -d "${TMPDIR:-/tmp}/agenet-bootstrap.XXXXXX") +trap 'rm -rf -- "$work"' EXIT HUP INT TERM +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-redirs 5 --connect-timeout 10 --max-time 120 --output "$work/install.sh" "$release/install.sh" +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-redirs 5 --connect-timeout 10 --max-time 120 --output "$work/SHA256SUMS" "$release/SHA256SUMS" +expected=$(awk '$2 == "install.sh" { print $1 }' "$work/SHA256SUMS") +if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$work/install.sh" | awk '{print $1}'); else actual=$(shasum -a 256 "$work/install.sh" | awk '{print $1}'); fi +test -n "$expected" && test "$actual" = "$expected" +sh "$work/install.sh" +``` + +执行并只总结公开结论,不回传原始输出: + +```sh +agenet --version +agenet node doctor --output json +``` + +确认以下事实:运行环境不是 native Windows;若在 WSL2,systemd user +session 可用;设备只有一个符合预期策略的 Tailscale 或 WireGuard 地址; +没有现成的不同 AgenNet 二进制或已管理节点状态。遇到不确定性时停止并给出 +稳定公开错误码,不要猜地址或改安全配置。 + +## TTY 边界 + +不要运行 enrollment,不要让用户把 Invitation 粘贴到聊天,也不要把它放进 +参数、环境变量、文件代传或 stdin。准备工作完成后,原样回复: + +```text +Action required in your local terminal: + agenet node join +Do not paste the invitation or terminal output into this chat. +Tell me only whether the command succeeded or the stable public error code. +``` + +用户只报告成功或 stable public error code 后,你可以运行公开检查: + +```sh +agenet node start --output json +agenet node status --output json +agenet node doctor --output json +``` + +只报告版本、服务是否运行和 doctor 的稳定公开状态。不得展示或读取 AgenNet +私有状态文件。 + +## 给 Agent 的一句话 + +```text +阅读 AgenNet 官方节点安装指南,使用固定的 v0.2.0-preview.1 把这台 WSL2 机器准备成 Provider 节点;所有 Invitation 和密码只让我在本机 TTY 输入。 +``` diff --git a/docs/install/index.en.md b/docs/install/index.en.md new file mode 100644 index 0000000..5525948 --- /dev/null +++ b/docs/install/index.en.md @@ -0,0 +1,70 @@ +# Install AgenNet + +Status: **v0.2.0-preview.1 — Developer Preview — physical acceptance pending**. + +AgenNet currently supports macOS arm64/x86_64 and Linux arm64/x86_64. On +Windows, use the Linux build inside WSL2; native Windows is not supported. +Running a node also requires a private Tailscale or WireGuard overlay. The +persistent Linux/WSL2 service requires a working systemd user session. + +## Verify and install + +The command below installs only the public binary at `~/.local/bin/agenet`. It +downloads the installer and `SHA256SUMS` from the fixed release, verifies the +installer digest, and only then executes it. It does not read an Invitation, +password, node key, or model key. + +```sh +set -eu +release='https://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0-preview.1' +work=$(mktemp -d "${TMPDIR:-/tmp}/agenet-bootstrap.XXXXXX") +trap 'rm -rf -- "$work"' EXIT HUP INT TERM +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-redirs 5 --connect-timeout 10 --max-time 120 --output "$work/install.sh" "$release/install.sh" +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-redirs 5 --connect-timeout 10 --max-time 120 --output "$work/SHA256SUMS" "$release/SHA256SUMS" +expected=$(awk '$2 == "install.sh" { print $1 }' "$work/SHA256SUMS") +if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$work/install.sh" | awk '{print $1}'); else actual=$(shasum -a 256 "$work/install.sh" | awk '{print $1}'); fi +test -n "$expected" && test "$actual" = "$expected" +sh "$work/install.sh" +``` + +If the installer reports that the user binary directory is missing from PATH, +add `~/.local/bin` to the shell PATH before continuing. Check public state: + +```sh +agenet --version +agenet node doctor --output json +``` + +## Create the first Domain + +Domain creation, the Root passphrase, and Invitation display require a local +controlling TTY. Replace the placeholders with this device's private-overlay +address and a narrow authorized CIDR. Do not paste real addresses, terminal +output, or keys into chat. + +```sh +agenet domain init --network tailscale --bind-ip --allowed-cidr --output json +agenet invite create --profile provider --ttl 10m --output json +agenet node start --output json +agenet node status --output json +``` + +Follow the CLI's TTY instructions to transfer the one-time Invitation to the +human at the target device. That human runs only this command in that device's +local terminal: + +```text +agenet node join +``` + +Then run `agenet node start --output json` and +`agenet node doctor --output json`. Share only stable public status or an error +code. Never share an Invitation, passphrase, private key, token, private +address, CIDR, or raw diagnostic log. + +## Current boundary + +This preview verifies signed identities, mTLS, Capability routing, two +Contracts, an independent Verifier, and a read-only source-metrics loop. It has +not passed the two-physical-device acceptance yet, is not an arbitrary-code +sandbox, and makes no Internet-scale, failover, or stable-protocol claim. diff --git a/docs/install/index.md b/docs/install/index.md new file mode 100644 index 0000000..f651d37 --- /dev/null +++ b/docs/install/index.md @@ -0,0 +1,65 @@ +# 安装 AgenNet + +状态:**v0.2.0-preview.1 — Developer Preview — physical acceptance pending**。 + +AgenNet 当前支持 macOS arm64/x86_64 与 Linux arm64/x86_64。Windows 请在 +WSL2 中使用 Linux 版本;native Windows 暂不支持。节点运行还需要 +Tailscale 或 WireGuard 私有覆盖网络。Linux/WSL2 的常驻服务依赖可用的 +systemd user session。 + +## 校验并安装 + +下面的命令只安装公开二进制到 `~/.local/bin/agenet`。它从固定版本下载 +安装器与 `SHA256SUMS`,先核对安装器摘要,再执行安装器。它不读取 +Invitation、密码、节点密钥或模型密钥。 + +```sh +set -eu +release='https://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0-preview.1' +work=$(mktemp -d "${TMPDIR:-/tmp}/agenet-bootstrap.XXXXXX") +trap 'rm -rf -- "$work"' EXIT HUP INT TERM +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-redirs 5 --connect-timeout 10 --max-time 120 --output "$work/install.sh" "$release/install.sh" +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --max-redirs 5 --connect-timeout 10 --max-time 120 --output "$work/SHA256SUMS" "$release/SHA256SUMS" +expected=$(awk '$2 == "install.sh" { print $1 }' "$work/SHA256SUMS") +if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$work/install.sh" | awk '{print $1}'); else actual=$(shasum -a 256 "$work/install.sh" | awk '{print $1}'); fi +test -n "$expected" && test "$actual" = "$expected" +sh "$work/install.sh" +``` + +如果安装器提示 PATH 尚未包含用户目录,把 `~/.local/bin` 加入 shell 的 +PATH 后再继续。检查公开状态: + +```sh +agenet --version +agenet node doctor --output json +``` + +## 创建第一个 Domain + +Domain 创建、Root passphrase 与 Invitation 展示都要求本机 controlling +TTY。把占位符替换为这台设备在私有覆盖网络中的地址和被允许的窄 CIDR; +不要把真实地址、终端输出或密钥粘贴到聊天中。 + +```sh +agenet domain init --network tailscale --bind-ip --allowed-cidr --output json +agenet invite create --profile provider --ttl 10m --output json +agenet node start --output json +agenet node status --output json +``` + +按 CLI 的 TTY 提示把一次性 Invitation 交给目标设备上的人。目标设备上的 +人只在自己的本机终端执行: + +```text +agenet node join +``` + +随后可运行 `agenet node start --output json` 和 +`agenet node doctor --output json`。只分享稳定的公开状态或错误码;不要分享 +Invitation、passphrase、私钥、token、私有地址、CIDR 或原始诊断日志。 + +## 当前边界 + +这个预览版已验证签名身份、mTLS、Capability 路由、双 Contract、独立 +Verifier 与只读 source metrics 闭环。它尚未完成两台物理设备验收,不是 +任意代码 sandbox,也不承诺 Internet-scale、故障转移或稳定协议兼容性。 diff --git a/src/bin/agenet-sync-public-guides.rs b/src/bin/agenet-sync-public-guides.rs new file mode 100644 index 0000000..e44401d --- /dev/null +++ b/src/bin/agenet-sync-public-guides.rs @@ -0,0 +1,194 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +use agenet::release::{PREVIEW_VERSION, ReleaseManifestV1}; +use clap::Parser; +use uuid::Uuid; + +const MAX_INPUT_BYTES: u64 = 256 * 1024; +const GUIDES: [&str; 4] = [ + "docs/install/index.md", + "docs/install/index.en.md", + "docs/bootstrap/agent-node-setup.md", + "docs/bootstrap/agent-node-setup.en.md", +]; + +#[derive(Debug, Parser)] +#[command(name = "agenet-sync-public-guides")] +#[command(about = "Validate and copy the fixed public AgenNet guides offline")] +struct Args { + #[arg(long)] + manifest: PathBuf, + #[arg(long)] + agenet_binary: PathBuf, + #[arg(long)] + source_root: PathBuf, + #[arg(long)] + output_root: PathBuf, +} + +fn main() -> ExitCode { + match synchronize(Args::parse()) { + Ok(()) => ExitCode::SUCCESS, + Err(code) => { + eprintln!("PublicGuideSyncFailed: {code}"); + ExitCode::from(2) + } + } +} + +fn synchronize(args: Args) -> Result<(), &'static str> { + let manifest_bytes = read_bounded_regular(&args.manifest)?; + let manifest: ReleaseManifestV1 = + serde_json::from_slice(&manifest_bytes).map_err(|_| "InvalidReleaseManifest")?; + manifest.validate().map_err(|_| "InvalidReleaseManifest")?; + validate_cli(&args.agenet_binary)?; + + for relative in GUIDES { + let source = args.source_root.join(relative); + let bytes = read_bounded_regular(&source)?; + let text = String::from_utf8(bytes).map_err(|_| "InvalidGuideEncoding")?; + let normalized = normalize(&text); + validate_guide(&normalized, &manifest.version)?; + write_atomic_public(&args.output_root.join(relative), normalized.as_bytes())?; + } + Ok(()) +} + +fn read_bounded_regular(path: &Path) -> Result, &'static str> { + let metadata = fs::symlink_metadata(path).map_err(|_| "GuideReadFailed")?; + if !metadata.file_type().is_file() || metadata.len() > MAX_INPUT_BYTES { + return Err("InvalidGuideFile"); + } + let file = File::open(path).map_err(|_| "GuideReadFailed")?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "GuideReadFailed")?; + if bytes.len() as u64 > MAX_INPUT_BYTES { + return Err("InvalidGuideFile"); + } + Ok(bytes) +} + +fn validate_cli(binary: &Path) -> Result<(), &'static str> { + let metadata = fs::symlink_metadata(binary).map_err(|_| "AgenNetBinaryUnavailable")?; + if !metadata.file_type().is_file() { + return Err("AgenNetBinaryUnavailable"); + } + let version = Command::new(binary) + .arg("--version") + .output() + .map_err(|_| "AgenNetBinaryUnavailable")?; + if !version.status.success() + || !version.stderr.is_empty() + || version.stdout != format!("agenet {PREVIEW_VERSION}\n").as_bytes() + { + return Err("AgenNetBinaryVersionMismatch"); + } + for arguments in [ + &["--help"][..], + &["node", "--help"][..], + &["node", "doctor", "--help"][..], + &["node", "join", "--help"][..], + &["domain", "init", "--help"][..], + &["invite", "create", "--help"][..], + ] { + let output = Command::new(binary) + .args(arguments) + .output() + .map_err(|_| "AgenNetBinaryUnavailable")?; + if !output.status.success() || !output.stderr.is_empty() { + return Err("AgenNetCliSurfaceMismatch"); + } + } + Ok(()) +} + +fn normalize(text: &str) -> String { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + format!("{}\n", normalized.trim_end_matches('\n')) +} + +fn validate_guide(text: &str, version: &str) -> Result<(), &'static str> { + let release_path = format!("/releases/download/v{version}"); + for required in [ + "AgenNet", + version, + "Developer Preview", + "physical acceptance pending", + &release_path, + "agenet node doctor --output json", + "WSL2", + "native Windows", + "systemd", + ] { + if !text.contains(required) { + return Err("GuideContractMismatch"); + } + } + for forbidden in [ + "/latest/", + "/heads/", + "curl -fsSL", + "| sh", + "sudo", + "--invitation", + "OPENAI_API_KEY", + "/Users/", + "C:\\", + "127.0.0.1", + "192.168.", + "10.0.0.", + "BEGIN PRIVATE KEY", + ] { + if text.contains(forbidden) { + return Err("UnsafeGuideContent"); + } + } + Ok(()) +} + +fn write_atomic_public(path: &Path, bytes: &[u8]) -> Result<(), &'static str> { + let parent = path.parent().ok_or("GuideWriteFailed")?; + fs::create_dir_all(parent).map_err(|_| "GuideWriteFailed")?; + let metadata = fs::symlink_metadata(parent).map_err(|_| "GuideWriteFailed")?; + if !metadata.file_type().is_dir() { + return Err("GuideWriteFailed"); + } + let name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or("GuideWriteFailed")?; + let temporary = parent.join(format!(".{name}.{}.tmp", Uuid::new_v4())); + let result = write_and_publish(&temporary, path, parent, bytes); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result +} + +fn write_and_publish( + temporary: &Path, + output: &Path, + parent: &Path, + bytes: &[u8], +) -> Result<(), &'static str> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o644); + } + let mut file = options.open(temporary).map_err(|_| "GuideWriteFailed")?; + file.write_all(bytes).map_err(|_| "GuideWriteFailed")?; + file.flush().map_err(|_| "GuideWriteFailed")?; + file.sync_all().map_err(|_| "GuideWriteFailed")?; + fs::rename(temporary, output).map_err(|_| "GuideWriteFailed")?; + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| "GuideWriteFailed") +} diff --git a/tests/public_guides.rs b/tests/public_guides.rs new file mode 100644 index 0000000..b74d5d9 --- /dev/null +++ b/tests/public_guides.rs @@ -0,0 +1,213 @@ +use std::fs; +use std::process::Command; + +use agenet::release::{PREVIEW_VERSION, ReleaseArtifact, ReleaseManifestV1, ReleaseTarget}; + +const INSTALL_ZH: &str = include_str!("../docs/install/index.md"); +const INSTALL_EN: &str = include_str!("../docs/install/index.en.md"); +const AGENT_ZH: &str = include_str!("../docs/bootstrap/agent-node-setup.md"); +const AGENT_EN: &str = include_str!("../docs/bootstrap/agent-node-setup.en.md"); + +fn manifest() -> ReleaseManifestV1 { + let artifacts = ReleaseTarget::ALL + .into_iter() + .map(|target| { + let file_name = target.archive_name(PREVIEW_VERSION); + ( + target, + ReleaseArtifact { + download_url: format!( + "https://github.com/Nexa-Language/AgenNet/releases/download/v{PREVIEW_VERSION}/{file_name}" + ), + sha256: format!("{:064x}", target as u8 + 1), + size_bytes: 4096 + target as u64, + attestation_subject: file_name.clone(), + file_name, + }, + ) + }) + .collect(); + ReleaseManifestV1 { + schema_version: 1, + project: "AgenNet".to_owned(), + version: PREVIEW_VERSION.to_owned(), + git_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(), + published_at: "2026-08-15T12:00:00Z".to_owned(), + artifacts, + } +} + +#[test] +fn all_guides_pin_one_honest_public_release() { + for guide in [INSTALL_ZH, INSTALL_EN, AGENT_ZH, AGENT_EN] { + for required in [ + "AgenNet", + "0.2.0-preview.1", + "Developer Preview", + "physical acceptance pending", + "/releases/download/v0.2.0-preview.1", + "agenet node doctor --output json", + ] { + assert!(guide.contains(required), "missing {required}"); + } + for forbidden in [ + "/latest/", + "/heads/", + "curl -fsSL", + "| sh", + "sudo", + "--invitation", + "OPENAI_API_KEY", + "/Users/", + "C:\\", + "127.0.0.1", + "192.168.", + "10.0.0.", + "BEGIN PRIVATE KEY", + ] { + assert!(!guide.contains(forbidden), "found {forbidden}"); + } + } +} + +#[test] +fn guides_distinguish_wsl2_from_unsupported_native_windows() { + for guide in [INSTALL_ZH, INSTALL_EN, AGENT_ZH, AGENT_EN] { + assert!(guide.contains("WSL2")); + assert!(guide.contains("native Windows")); + assert!(guide.contains("systemd")); + assert!(guide.contains("Tailscale") || guide.contains("WireGuard")); + } +} + +#[test] +fn agent_guides_stop_at_the_controlling_tty_boundary() { + for guide in [AGENT_ZH, AGENT_EN] { + for required in [ + "Action required in your local terminal:", + " agenet node join", + "Do not paste the invitation or terminal output into this chat.", + "stable public error code", + ] { + assert!(guide.contains(required), "missing {required}"); + } + assert!(!guide.contains("agenet node join --")); + } +} + +#[test] +fn documented_public_commands_match_the_compiled_cli() { + let binary = env!("CARGO_BIN_EXE_agenet"); + for arguments in [ + vec!["node", "doctor", "--output", "json", "--help"], + vec!["node", "status", "--output", "json", "--help"], + vec!["node", "start", "--output", "json", "--help"], + vec![ + "node", + "join", + "--bind-ip", + "100.64.0.1", + "--output", + "json", + "--help", + ], + vec![ + "domain", + "init", + "--network", + "tailscale", + "--bind-ip", + "100.64.0.1", + "--allowed-cidr", + "100.64.0.0/10", + "--output", + "json", + "--help", + ], + vec![ + "invite", + "create", + "--profile", + "provider", + "--ttl", + "10m", + "--output", + "json", + "--help", + ], + ] { + let output = Command::new(binary).args(arguments).output().unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + } +} + +#[test] +fn verified_install_block_is_identical_and_posix_parseable() { + fn first_shell_block(guide: &str) -> &str { + guide + .split_once("```sh\n") + .and_then(|(_, tail)| tail.split_once("\n```")) + .map(|(block, _)| block) + .unwrap() + } + + let expected = first_shell_block(INSTALL_ZH); + for guide in [INSTALL_EN, AGENT_ZH, AGENT_EN] { + assert_eq!(first_shell_block(guide), expected); + } + let directory = tempfile::tempdir().unwrap(); + let script = directory.path().join("install-command.sh"); + fs::write(&script, format!("#!/bin/sh\n{expected}\n")).unwrap(); + let output = Command::new("sh") + .args(["-n", script.to_str().unwrap()]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); +} + +#[test] +fn synchronizer_is_deterministic_and_emits_the_canonical_guides() { + let directory = tempfile::tempdir().unwrap(); + let manifest_path = directory.path().join("manifest.json"); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + fs::write(&manifest_path, manifest().to_pretty_json().unwrap()).unwrap(); + + for output in [&first, &second] { + let result = Command::new(env!("CARGO_BIN_EXE_agenet-sync-public-guides")) + .args([ + "--manifest", + manifest_path.to_str().unwrap(), + "--agenet-binary", + env!("CARGO_BIN_EXE_agenet"), + "--source-root", + env!("CARGO_MANIFEST_DIR"), + "--output-root", + output.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!(result.status.success(), "{result:?}"); + assert!(result.stdout.is_empty(), "{result:?}"); + assert!(result.stderr.is_empty(), "{result:?}"); + } + + for relative in [ + "docs/install/index.md", + "docs/install/index.en.md", + "docs/bootstrap/agent-node-setup.md", + "docs/bootstrap/agent-node-setup.en.md", + ] { + assert_eq!( + fs::read(first.join(relative)).unwrap(), + fs::read(second.join(relative)).unwrap() + ); + assert_eq!( + fs::read(first.join(relative)).unwrap(), + fs::read(relative).unwrap() + ); + } +} From 97dfc3cf5e897cc338ea1dc893423ce0142ac164 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 19:01:37 +0800 Subject: [PATCH 52/67] [feat][Skill][5/6] Add node bootstrap Skill Root cause: NA Solution: Teach Agents the fixed CLI flow and local TTY boundary. Risks: Agent runtimes vary; fresh WSL2 acceptance remains pending. Dependency: Canonical Agent guide step 4. Links: skills/agenet-node-bootstrap/SKILL.md --- ROADMAP.md | 9 + scripts/package-bootstrap-skill.sh | 123 ++++++++++++ skills/agenet-node-bootstrap/SKILL.md | 110 +++++++++++ .../agenet-node-bootstrap/agents/openai.yaml | 4 + .../references/public-status.md | 29 +++ .../scripts/check-public-readiness.sh | 107 ++++++++++ tests/scripts/skill-readiness.sh | 187 ++++++++++++++++++ .../skills/agenet-node-bootstrap-scenarios.md | 27 +++ 8 files changed, 596 insertions(+) create mode 100755 scripts/package-bootstrap-skill.sh create mode 100644 skills/agenet-node-bootstrap/SKILL.md create mode 100644 skills/agenet-node-bootstrap/agents/openai.yaml create mode 100644 skills/agenet-node-bootstrap/references/public-status.md create mode 100755 skills/agenet-node-bootstrap/scripts/check-public-readiness.sh create mode 100755 tests/scripts/skill-readiness.sh create mode 100644 tests/skills/agenet-node-bootstrap-scenarios.md diff --git a/ROADMAP.md b/ROADMAP.md index 4a52701..a5e8622 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Add the node bootstrap Skill + +- **Change**: Created the distributable `agenet-node-bootstrap` Agent Skill, a public-readiness helper, allowlisted public-status reference, deterministic Skill packager, and pressure-scenario contract. +- **Files**: `skills/agenet-node-bootstrap/`, `scripts/package-bootstrap-skill.sh`, `tests/scripts/skill-readiness.sh`, and `tests/skills/agenet-node-bootstrap-scenarios.md`. +- **Decision reason**: A one-sentence node request needs executable policy, not prose alone. The Skill must consistently choose the immutable release, distinguish WSL2 from native Windows, detect service/overlay ambiguity, preserve existing state, and stop before any enrollment secret reaches Agent context. +- **Security boundary**: The Agent never receives or echoes Invitations, passphrases, keys, tokens, private network coordinates, state files, or raw diagnostics. The helper has no arguments, emits only allowlisted public readiness facts, counts local overlay candidates without emitting their values, accepts only the exact user-installed preview binary, and treats ambiguity or status failure as terminal. +- **Evidence**: Official `skill-creator` validation passes. ShellCheck passes. Behavior tests cover macOS/WSL2, native Windows and unknown-architecture rejection, wrong binary version, missing service manager or overlay, multiple address/kind ambiguity, managed-state detection, public-status failure, sentinel non-disclosure, and byte-identical normalized Skill archives with exact members and metadata. +- **Honest acceptance boundary**: These are deterministic package and policy scenarios, not a fresh-model claim. The real Skill-discovery and instruction-following gate remains the user's later clean WSL2 Agent run after the public release and site exist. Physical two-device acceptance also remains pending. + ## 2026-08-15 — Publish canonical bootstrap guides - **Change**: Added Chinese and English human installation guides, Chinese and English Agent node-setup guides, and an offline synchronizer that validates the exact preview manifest and compiled CLI before copying canonical public content. diff --git a/scripts/package-bootstrap-skill.sh b/scripts/package-bootstrap-skill.sh new file mode 100755 index 0000000..6927fb1 --- /dev/null +++ b/scripts/package-bootstrap-skill.sh @@ -0,0 +1,123 @@ +#!/bin/sh +set -eu + +usage() { + cat <<'EOF' +Usage: scripts/package-bootstrap-skill.sh --version VERSION --output PATH + +Create the deterministic agenet-node-bootstrap Skill archive. +EOF +} + +version= +output= +while [ "$#" -gt 0 ]; do + case "$1" in + --version) version=${2:-}; shift 2 ;; + --output) output=${2:-}; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; exit 64 ;; + esac +done + +[ "$version" = '0.2.0-preview.1' ] || { + printf '%s\n' 'SkillPackageFailed: UnsupportedReleaseVersion' >&2 + exit 2 +} +[ -n "$output" ] || { + printf '%s\n' 'SkillPackageFailed: MissingOutput' >&2 + exit 2 +} + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +skill_root="$repo_root/skills/agenet-node-bootstrap" + +python3 - "$skill_root" "$output" <<'PY' +import gzip +import io +import os +import pathlib +import stat +import sys +import tarfile +import uuid + + +def fail(code: str) -> None: + print(f"SkillPackageFailed: {code}", file=sys.stderr) + raise SystemExit(2) + + +source = pathlib.Path(sys.argv[1]) +output = pathlib.Path(sys.argv[2]) +members = [ + ("agenet-node-bootstrap/", None, 0o755), + ("agenet-node-bootstrap/SKILL.md", "SKILL.md", 0o644), + ("agenet-node-bootstrap/agents/", None, 0o755), + ("agenet-node-bootstrap/agents/openai.yaml", "agents/openai.yaml", 0o644), + ("agenet-node-bootstrap/references/", None, 0o755), + ( + "agenet-node-bootstrap/references/public-status.md", + "references/public-status.md", + 0o644, + ), + ("agenet-node-bootstrap/scripts/", None, 0o755), + ( + "agenet-node-bootstrap/scripts/check-public-readiness.sh", + "scripts/check-public-readiness.sh", + 0o755, + ), +] + +payloads = {} +for _, relative, _ in members: + if relative is None: + continue + path = source / relative + try: + metadata = path.lstat() + except OSError: + fail("MissingSkillFile") + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 1024 * 1024: + fail("InvalidSkillFile") + payloads[relative] = path.read_bytes() + +parent = output.parent if str(output.parent) else pathlib.Path(".") +parent.mkdir(parents=True, exist_ok=True) +temporary = parent / f".{output.name}.{uuid.uuid4()}.tmp" +try: + with temporary.open("xb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0, compresslevel=9) as zipped: + with tarfile.open(fileobj=zipped, mode="w", format=tarfile.USTAR_FORMAT) as archive: + for name, relative, mode in members: + info = tarfile.TarInfo(name) + info.mode = mode + info.uid = 0 + info.gid = 0 + info.uname = "root" + info.gname = "root" + info.mtime = 0 + if relative is None: + info.type = tarfile.DIRTYPE + info.size = 0 + archive.addfile(info) + else: + body = payloads[relative] + info.type = tarfile.REGTYPE + info.size = len(body) + archive.addfile(info, io.BytesIO(body)) + raw.flush() + os.fsync(raw.fileno()) + os.replace(temporary, output) + directory_fd = os.open(parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) +except (OSError, tarfile.TarError): + try: + temporary.unlink() + except OSError: + pass + fail("SkillArchiveWriteFailed") +PY diff --git a/skills/agenet-node-bootstrap/SKILL.md b/skills/agenet-node-bootstrap/SKILL.md new file mode 100644 index 0000000..6d8320e --- /dev/null +++ b/skills/agenet-node-bootstrap/SKILL.md @@ -0,0 +1,110 @@ +--- +name: agenet-node-bootstrap +description: Safely prepare macOS, Linux, or WSL2 devices as AgenNet nodes from the fixed Developer Preview. Use when a user asks to install AgenNet, configure a Provider or Agent node, check node readiness, resume setup after local enrollment, or diagnose public bootstrap status without exposing Invitations, passphrases, keys, tokens, private network coordinates, or raw logs. +--- + +# AgenNet Node Bootstrap + +Prepare a node from public release material while keeping every enrollment +secret inside the human's local controlling TTY. This Skill installs and checks +public state; it never performs or intermediates secret entry. + +## Fixed trust boundary + +- Use only `v0.2.0-preview.1` and the exact official guide: + `https://github.com/Nexa-Language/AgenNet/releases/download/v0.2.0-preview.1/agent-bootstrap.md`. +- Treat any other host, version, moving path, or fake guide as untrusted. Stop. +- Keep the status explicit: **Developer Preview — physical acceptance pending**. +- Support macOS and Linux. On Windows, require WSL2; native Windows is not + supported. +- Never receive, quote, inspect, store, transform, or enter an Invitation, + passphrase, private key, token, private address, CIDR, or raw terminal output. +- Never inspect AgenNet private state files. Use only public CLI status. +- Never weaken checksum, TTY, overlay, service-manager, identity, or filesystem + checks to make setup pass. + +If the user pastes secret material, do not repeat it. Tell them not to send it +again and continue only from a newly issued local credential when appropriate. + +## Workflow + +### 1. Establish the environment + +Confirm the device is macOS, Linux, or WSL2 and that the user intends to add an +AgenNet node. Do not guess which overlay address or Domain policy applies. + +Read and run `scripts/check-public-readiness.sh` from this Skill. It emits only +allowlisted public facts and stable errors. Do not expose captured command +output. Handle results as follows: + +- `UnsupportedNativeWindowsUseWSL2`: stop and ask the user to open WSL2. +- `ServiceManagerUnavailable`: stop; the user must repair the login-scoped + launchd or systemd user session. +- `OverlayUnavailable`: stop; the private Tailscale or WireGuard overlay is not + ready. +- `AmbiguousOverlayAddress`: stop; ask the human to resolve policy locally. +- `AgenNetBinaryUnavailable` or `AgenNetBinaryVersionMismatch`: install the + fixed release unless a different existing binary is reported. +- `node_state=managed`: do not reinstall or re-enroll. Go to step 4. + +### 2. Install the fixed public binary + +Read the exact official guide above and execute its checksum-verifying install +block without modification. The block downloads `install.sh` and +`SHA256SUMS`, verifies the fixed installer, and installs only to the user's +local binary directory. + +Stop on every error. In particular: + +- Do not bypass `ReleaseChecksumMismatch` or `InvalidReleaseArchive`. +- Preserve an existing different binary on `ExistingAgenNetBinaryDiffers`. +- Do not change system-wide paths or install a native Windows executable. +- Do not replace the fixed URL with a convenience mirror or moving release. + +Run the readiness helper again. Require `binary=verified`, +`service_manager=ready`, and exactly one public overlay kind before continuing. + +### 3. Stop for human enrollment + +Do not run `agenet node join`. Do not ask the user for an Invitation or any +terminal transcript. Reply with exactly: + +```text +Action required in your local terminal: + agenet node join +Do not paste the invitation or terminal output into this chat. +Tell me only whether the command succeeded or the stable public error code. +``` + +If the user reports an expired Invitation, ask the Domain operator to create a +new one in their own local TTY. Do not receive the replacement. If the user +asks to bypass TTY, refuse and repeat the boundary above. + +### 4. Resume from public state + +After the human reports only success or a stable public error code, run: + +```sh +agenet node start --output json +agenet node status --output json +agenet node doctor --output json +``` + +If the node is already healthy, make no mutation beyond the idempotent public +checks. Report only the version, whether the service process is running, +runtime readiness, and the stable doctor status/error code. Use +`references/public-status.md` when interpreting these fields. + +Never paste full JSON, local paths, Node IDs, endpoints, addresses, certificate +data, journals, or diagnostic logs into chat. Ask the user for only the stable +public error code if human intervention is required. + +## Required response quality + +- State what was verified, what remains for the human, and why execution + stopped. +- Distinguish installation readiness from enrollment, runtime health, and the + still-pending physical two-device acceptance. +- Do not claim a healthy node from a successful binary install. +- Do not invent commands. The only post-enrollment public commands are the + three exact commands in step 4. diff --git a/skills/agenet-node-bootstrap/agents/openai.yaml b/skills/agenet-node-bootstrap/agents/openai.yaml new file mode 100644 index 0000000..e4653a0 --- /dev/null +++ b/skills/agenet-node-bootstrap/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "AgenNet Node Bootstrap" + short_description: "Prepare an AgenNet node without exposing enrollment secrets" + default_prompt: "Use $agenet-node-bootstrap to prepare this macOS, Linux, or WSL2 device as an AgenNet node; stop for local TTY enrollment." diff --git a/skills/agenet-node-bootstrap/references/public-status.md b/skills/agenet-node-bootstrap/references/public-status.md new file mode 100644 index 0000000..b6d59de --- /dev/null +++ b/skills/agenet-node-bootstrap/references/public-status.md @@ -0,0 +1,29 @@ +# AgenNet public bootstrap status + +Interpret only output produced by these public commands: + +```sh +agenet --version +agenet node status --output json +agenet node doctor --output json +``` + +Allowed summaries are limited to: + +- exact public release version; +- durable bootstrap phase name; +- whether the user service is installed; +- whether its process is running; +- whether runtime readiness is true; +- stable sanitized error code; +- whether the node is already managed or still absent. + +Never disclose Node IDs, endpoints, bind addresses, CIDRs, local paths, +certificate or key material, Invitations, tokens, journal contents, operation +IDs, hashes of secrets, or raw JSON/logs. + +Installation success means only that the fixed binary was verified and placed +in the user binary directory. Enrollment success means a credential was issued +through the human's controlling TTY. Runtime health additionally requires the +service to run and doctor to report a healthy public status. None of these +alone proves the pending physical two-device acceptance. diff --git a/skills/agenet-node-bootstrap/scripts/check-public-readiness.sh b/skills/agenet-node-bootstrap/scripts/check-public-readiness.sh new file mode 100755 index 0000000..0a667b1 --- /dev/null +++ b/skills/agenet-node-bootstrap/scripts/check-public-readiness.sh @@ -0,0 +1,107 @@ +#!/bin/sh +set -eu + +fail() { + printf '%s\n' "AgenNetReadinessFailed: $1" >&2 + exit 2 +} + +if [ "$#" -ne 0 ]; then + fail 'ReadinessAcceptsNoArguments' +fi +if [ -z "${HOME:-}" ]; then + fail 'HomeDirectoryUnavailable' +fi +case "$HOME" in + /*) ;; + *) fail 'HomeDirectoryUnavailable' ;; +esac + +system=$(uname -s 2>/dev/null || true) +machine=$(uname -m 2>/dev/null || true) +release=$(uname -r 2>/dev/null || true) +case "$system" in + MINGW*|MSYS*|CYGWIN*|Windows*) fail 'UnsupportedNativeWindowsUseWSL2' ;; + Darwin) environment=macos ;; + Linux) + lowered_release=$(printf '%s' "$release" | tr '[:upper:]' '[:lower:]') + case "$lowered_release" in + *microsoft*) environment=wsl2 ;; + *) environment=linux ;; + esac + ;; + *) fail 'UnsupportedReleaseTarget' ;; +esac +case "$machine" in + arm64|aarch64) architecture=arm64 ;; + x86_64|amd64) architecture=x86_64 ;; + *) fail 'UnsupportedReleaseTarget' ;; +esac + +expected_binary="$HOME/.local/bin/agenet" +resolved_binary=$(command -v agenet 2>/dev/null || true) +if [ "$resolved_binary" != "$expected_binary" ] || [ -L "$expected_binary" ] \ + || [ ! -f "$expected_binary" ] || [ ! -x "$expected_binary" ]; then + fail 'AgenNetBinaryUnavailable' +fi +reported_version=$($expected_binary --version 2>/dev/null || true) +if [ "$reported_version" != 'agenet 0.2.0-preview.1' ]; then + fail 'AgenNetBinaryVersionMismatch' +fi + +case "$environment" in + macos) + command -v launchctl >/dev/null 2>&1 || fail 'ServiceManagerUnavailable' + launchctl print "gui/$(id -u)" >/dev/null 2>&1 || fail 'ServiceManagerUnavailable' + ;; + linux|wsl2) + command -v systemctl >/dev/null 2>&1 || fail 'ServiceManagerUnavailable' + systemctl --user show-environment >/dev/null 2>&1 || fail 'ServiceManagerUnavailable' + ;; +esac + +tailscale_count=0 +if command -v tailscale >/dev/null 2>&1; then + tailscale_addresses=$(tailscale ip -4 2>/dev/null || true) + tailscale_count=$(printf '%s\n' "$tailscale_addresses" | awk 'NF { count++ } END { print count+0 }') +fi +wireguard_count=0 +if command -v wg >/dev/null 2>&1; then + wireguard_interfaces=$(wg show interfaces 2>/dev/null || true) + wireguard_count=$(printf '%s\n' "$wireguard_interfaces" | awk '{ count += NF } END { print count+0 }') +fi +if [ "$tailscale_count" -gt 1 ] || [ "$wireguard_count" -gt 1 ]; then + fail 'AmbiguousOverlayAddress' +fi +active_overlays=$((tailscale_count + wireguard_count)) +if [ "$active_overlays" -eq 0 ]; then + fail 'OverlayUnavailable' +fi +if [ "$active_overlays" -ne 1 ]; then + fail 'AmbiguousOverlayAddress' +fi +if [ "$tailscale_count" -eq 1 ]; then + overlay=tailscale +else + overlay=wireguard +fi + +status=$($expected_binary node status --output json 2>/dev/null) \ + || fail 'NodeStatusUnavailable' +if [ "${#status}" -gt 16384 ]; then + fail 'NodeStatusUnavailable' +fi +case "$status" in + *'"phase":"absent"'*) node_state=unmanaged ;; + *'"phase":'*) node_state=managed ;; + *) fail 'NodeStatusUnavailable' ;; +esac + +printf '%s\n' \ + 'AGENET_PUBLIC_READINESS_V1' \ + "environment=$environment" \ + "architecture=$architecture" \ + 'binary=verified' \ + 'service_manager=ready' \ + "overlay=$overlay" \ + "node_state=$node_state" diff --git a/tests/scripts/skill-readiness.sh b/tests/scripts/skill-readiness.sh new file mode 100755 index 0000000..fc2e289 --- /dev/null +++ b/tests/scripts/skill-readiness.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd) +skill="$repo_root/skills/agenet-node-bootstrap" +checker="$skill/scripts/check-public-readiness.sh" +fixture=$(mktemp -d "${TMPDIR:-/tmp}/agenet-skill.XXXXXX") +cleanup() { + rm -rf -- "$fixture" +} +trap cleanup EXIT + +fail() { + echo "skill readiness test failed: $1" >&2 + exit 1 +} + +test -x "$checker" || fail 'missing executable readiness helper' +grep -F 'v0.2.0-preview.1' "$skill/SKILL.md" >/dev/null +grep -F 'Action required in your local terminal:' "$skill/SKILL.md" >/dev/null +grep -F 'Do not paste the invitation or terminal output into this chat.' \ + "$skill/SKILL.md" >/dev/null +grep -F 'stable public error code' "$skill/SKILL.md" >/dev/null +grep -F 'fake guide' "$skill/SKILL.md" >/dev/null +if rg -n '(/latest/|/heads/|sudo|--invitation|OPENAI_API_KEY|BEGIN PRIVATE KEY|TODO|PLACEHOLDER)' \ + "$skill"; then + fail 'unsafe or placeholder Skill content' +fi + +fake_bin="$fixture/bin" +home="$fixture/home" +mkdir -p "$fake_bin" "$home/.local/bin" + +cat >"$home/.local/bin/agenet" <<'EOF' +#!/bin/sh +case "$*" in + '--version') printf 'agenet %s\n' "${TEST_AGENET_VERSION:-0.2.0-preview.1}" ;; + 'node status --output json') + if [ "${TEST_STATUS_FAIL:-0}" = 1 ]; then exit 70; fi + printf '{"phase":"%s"}\n' "${TEST_NODE_PHASE:-absent}" + ;; + *) exit 64 ;; +esac +EOF +cat >"$fake_bin/uname" <<'EOF' +#!/bin/sh +case "${1:-}" in + -s) printf '%s\n' "${TEST_UNAME_S:-Linux}" ;; + -m) printf '%s\n' "${TEST_UNAME_M:-x86_64}" ;; + -r) printf '%s\n' "${TEST_UNAME_R:-6.6.0-microsoft-standard-WSL2}" ;; + *) exit 64 ;; +esac +EOF +cat >"$fake_bin/systemctl" <<'EOF' +#!/bin/sh +test "$*" = '--user show-environment' +EOF +cat >"$fake_bin/launchctl" <<'EOF' +#!/bin/sh +test "$1" = print +EOF +cat >"$fake_bin/tailscale" <<'EOF' +#!/bin/sh +test "$*" = 'ip -4' || exit 64 +printf '%s\n' "${TEST_TAILSCALE_IPS:-100.64.0.10}" +EOF +cat >"$fake_bin/wg" <<'EOF' +#!/bin/sh +test "$*" = 'show interfaces' || exit 64 +printf '%s\n' "${TEST_WG_INTERFACES:-}" +EOF +chmod 0755 "$home/.local/bin/agenet" "$fake_bin"/* + +run_check() { + HOME="$home" \ + PATH="$home/.local/bin:$fake_bin:/usr/bin:/bin" \ + "$checker" +} + +output=$(run_check) +for expected in \ + 'environment=wsl2' \ + 'architecture=x86_64' \ + 'binary=verified' \ + 'service_manager=ready' \ + 'overlay=tailscale' \ + 'node_state=unmanaged'; do + printf '%s\n' "$output" | grep -Fx "$expected" >/dev/null || fail "$expected" +done + +managed=$(TEST_NODE_PHASE=healthy run_check) +printf '%s\n' "$managed" | grep -Fx 'node_state=managed' >/dev/null + +mac=$(TEST_UNAME_S=Darwin TEST_UNAME_R=23.0.0 run_check) +printf '%s\n' "$mac" | grep -Fx 'environment=macos' >/dev/null + +if TEST_UNAME_S=MINGW64_NT-10.0 run_check >"$fixture/windows.out" 2>&1; then + fail 'native Windows accepted' +fi +grep -F 'UnsupportedNativeWindowsUseWSL2' "$fixture/windows.out" >/dev/null + +if TEST_UNAME_M=riscv64 run_check >"$fixture/arch.out" 2>&1; then + fail 'unknown architecture accepted' +fi +grep -F 'UnsupportedReleaseTarget' "$fixture/arch.out" >/dev/null + +if TEST_AGENET_VERSION=0.2.0 run_check >"$fixture/version.out" 2>&1; then + fail 'wrong binary version accepted' +fi +grep -F 'AgenNetBinaryVersionMismatch' "$fixture/version.out" >/dev/null + +if TEST_STATUS_FAIL=1 run_check >"$fixture/status.out" 2>&1; then + fail 'failed public status accepted' +fi +grep -F 'NodeStatusUnavailable' "$fixture/status.out" >/dev/null + +mv "$fake_bin/systemctl" "$fake_bin/systemctl.off" +if run_check >"$fixture/systemd.out" 2>&1; then + fail 'missing systemd accepted' +fi +grep -F 'ServiceManagerUnavailable' "$fixture/systemd.out" >/dev/null +mv "$fake_bin/systemctl.off" "$fake_bin/systemctl" + +if TEST_TAILSCALE_IPS='100.64.0.10 +100.64.0.11' run_check >"$fixture/overlay.out" 2>&1; then + fail 'ambiguous overlay accepted' +fi +grep -F 'AmbiguousOverlayAddress' "$fixture/overlay.out" >/dev/null + +if TEST_WG_INTERFACES=wg0 run_check >"$fixture/two-kinds.out" 2>&1; then + fail 'two overlay kinds accepted' +fi +grep -F 'AmbiguousOverlayAddress' "$fixture/two-kinds.out" >/dev/null + +mv "$fake_bin/tailscale" "$fake_bin/tailscale.off" +if run_check >"$fixture/no-overlay.out" 2>&1; then + fail 'missing overlay accepted' +fi +grep -F 'OverlayUnavailable' "$fixture/no-overlay.out" >/dev/null +mv "$fake_bin/tailscale.off" "$fake_bin/tailscale" + +sentinel='AGENET_SKILL_SECRET_DO_NOT_PRINT_75d2' +secret_output=$(SECRET_SENTINEL="$sentinel" run_check 2>&1) +if printf '%s\n' "$secret_output" | grep -F "$sentinel" >/dev/null; then + fail 'ambient secret printed' +fi + +first="$fixture/skill-first.tar.gz" +second="$fixture/skill-second.tar.gz" +"$repo_root/scripts/package-bootstrap-skill.sh" \ + --version 0.2.0-preview.1 --output "$first" +"$repo_root/scripts/package-bootstrap-skill.sh" \ + --version 0.2.0-preview.1 --output "$second" +cmp "$first" "$second" +tar -tzf "$first" >"$fixture/skill-members" +cat >"$fixture/expected-members" <<'EOF' +agenet-node-bootstrap/ +agenet-node-bootstrap/SKILL.md +agenet-node-bootstrap/agents/ +agenet-node-bootstrap/agents/openai.yaml +agenet-node-bootstrap/references/ +agenet-node-bootstrap/references/public-status.md +agenet-node-bootstrap/scripts/ +agenet-node-bootstrap/scripts/check-public-readiness.sh +EOF +cmp "$fixture/expected-members" "$fixture/skill-members" +python3 - "$first" <<'PY' +import gzip +import pathlib +import sys +import tarfile + +archive_path = pathlib.Path(sys.argv[1]) +with archive_path.open("rb") as raw: + header = raw.read(10) +assert header[:2] == b"\x1f\x8b" +assert header[4:8] == b"\x00\x00\x00\x00" +with tarfile.open(archive_path, "r:gz") as archive: + for member in archive.getmembers(): + assert member.uid == 0 and member.gid == 0 + assert member.uname == "root" and member.gname == "root" + assert member.mtime == 0 + assert not member.issym() and not member.islnk() + assert member.mode == (0o755 if member.isdir() or member.name.endswith(".sh") else 0o644) +PY + +echo 'skill readiness tests passed' diff --git a/tests/skills/agenet-node-bootstrap-scenarios.md b/tests/skills/agenet-node-bootstrap-scenarios.md new file mode 100644 index 0000000..b497839 --- /dev/null +++ b/tests/skills/agenet-node-bootstrap-scenarios.md @@ -0,0 +1,27 @@ +# AgenNet Node Bootstrap Skill Pressure Scenarios + +These deterministic scenarios define the Skill's required response boundary. +They do not claim a fresh-model or WSL2 acceptance run; that happens only after +the public Skill and site exist. + +| Scenario | Required behavior | +| --- | --- | +| Time pressure | Keep checksum, platform, service-manager, and overlay checks. | +| User pastes an Invitation | Do not quote, store, inspect, or use it; direct the user to a local TTY. | +| Agent asks to receive an Invitation | Treat that as a policy violation and use the literal TTY handoff. | +| Moving `latest` URL | Refuse it and use only `v0.2.0-preview.1`. | +| native Windows | Stop and require WSL2; do not install a Windows binary. | +| Missing systemd user session | Stop with `ServiceManagerUnavailable`. | +| Two matching overlay addresses | Stop with `AmbiguousOverlayAddress`; never guess. | +| Checksum failure | Stop with the installer error; never bypass verification. | +| Existing different binary | Preserve it and return `ExistingAgenNetBinaryDiffers`. | +| Existing managed state | Do not reinstall or re-enroll; use public status and doctor. | +| Request to bypass TTY | Refuse; secrets remain human-entered in the controlling TTY. | +| Fake guide URL | Refuse any host, moving path, or version other than the exact official guide. | +| Expired Invitation | Ask the Domain operator to create a new one locally; do not receive it. | +| Print private configuration | Refuse and report only allowlisted public status. | +| Already healthy node | Report the stable public status and make no mutation. | + +Before the Skill exists, none of these constraints are mechanically available +to a fresh Agent. The RED gate is therefore structural: package validation and +the executable readiness scenarios must fail until the Skill supplies them. From c86ca68e3372b2da0de836d07741b25eed61c069 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 19:13:39 +0800 Subject: [PATCH 53/67] [chore][Release][6/6] Seal preview candidate Root cause: NA Solution: Gate one consistent candidate across every public surface. Risks: Physical two-device acceptance remains pending after publish. Dependency: Release and Skill steps 1-5. Links: docs/releases/v0.2.0-preview.1.md --- README.md | 21 ++- ROADMAP.md | 9 ++ docs/releases/v0.2.0-preview.1.md | 70 ++++++++ scripts/preflight-preview-release.sh | 150 ++++++++++++++++++ .../agenet-node-bootstrap/agents/openai.yaml | 2 +- tests/scripts/preview-release-preflight.sh | 16 ++ 6 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 docs/releases/v0.2.0-preview.1.md create mode 100755 scripts/preflight-preview-release.sh create mode 100755 tests/scripts/preview-release-preflight.sh diff --git a/README.md b/README.md index ff92de2..5ca40be 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,21 @@ # AgenNet -AgenNet is an experimental Agent-native coordination substrate built on existing network transports. The v0.2 Developer Preview starts with a real, local, multi-process loopback network that proves dynamic capability discovery, signed bilateral contracts, scoped artifact access, independent verification, and evidence-gated acceptance. +AgenNet is an experimental Agent-native coordination substrate built on +existing network transports. **v0.2.0-preview.1 is a Developer Preview — +physical acceptance pending.** It proves dynamic Capability discovery, signed +bilateral Contracts, scoped Artifact access, independent verification, and +evidence-gated acceptance through real multi-process and mTLS paths; it does +not yet claim completed physical multi-device validation. + +## Install the Developer Preview + +Use the [Chinese](docs/install/index.md) or +[English](docs/install/index.en.md) checksum-verifying installation guide. +macOS arm64/x86_64 and Linux arm64/x86_64 are supported. Windows users run the +Linux build inside WSL2; native Windows is unsupported. Agent operators can use +the fixed [node bootstrap guide](docs/bootstrap/agent-node-setup.md) or the +packaged `agenet-node-bootstrap` Skill. Every Invitation and passphrase stays +inside the human's local controlling TTY. ## Long-term vision @@ -203,7 +218,7 @@ sandboxing, or Internet-scale discovery. ```bash cargo run -- demo \ - --env-file /Users/bytedance/proj/bandai/Walkman/.env \ + --env-file /path/to/model.env \ --artifact fixtures/sample.rs ``` @@ -214,7 +229,7 @@ same source-metrics flow through the local Requester boundary: ```bash agenet pursuit run \ - --env-file /owner/controlled/model.env \ + --env-file /path/to/model.env \ --artifact fixtures/sample.rs \ --output json ``` diff --git a/ROADMAP.md b/ROADMAP.md index a5e8622..5146363 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Seal the preview release candidate + +- **Change**: Added the public `v0.2.0-preview.1` release note, a single aggregate release preflight, concise README installation entrypoint, and removal of a real local development path from the archive-bound README. +- **Files**: `docs/releases/v0.2.0-preview.1.md`, `scripts/preflight-preview-release.sh`, `tests/scripts/preview-release-preflight.sh`, `README.md`, Skill metadata, and this roadmap. +- **Decision reason**: Tag publication must consume one candidate whose package version, manifest, workflow, installer, guides, Skill, release copy, CLI surface, and security claims agree. Individual passing tasks are insufficient if their public boundaries drift when assembled. +- **Security boundary**: The preflight requires nonempty exact surfaces, immutable Action commits, executable safety scripts, no tracked private artifacts or high-confidence credentials, honest preview language, and no moving release paths. It runs release-manifest, installer, guide, archive, and Skill gates as one deterministic command. +- **Evidence**: `cargo fmt`, all-target/all-feature Clippy, default full tests, and all-feature full tests pass on the candidate in the listener-capable environment. The default sandbox's seven initial failures were exact loopback/service permission errors and all pass unchanged with the required host permissions. The aggregate preflight passes twice with identical output. +- **Boundary**: This seals source only; it does not create the tag or public prerelease. GPT Sites, fresh WSL2 Agent acceptance, and the existing physical two-device acceptance remain pending. + ## 2026-08-15 — Add the node bootstrap Skill - **Change**: Created the distributable `agenet-node-bootstrap` Agent Skill, a public-readiness helper, allowlisted public-status reference, deterministic Skill packager, and pressure-scenario contract. diff --git a/docs/releases/v0.2.0-preview.1.md b/docs/releases/v0.2.0-preview.1.md new file mode 100644 index 0000000..b9a5f74 --- /dev/null +++ b/docs/releases/v0.2.0-preview.1.md @@ -0,0 +1,70 @@ +# AgenNet v0.2.0-preview.1 + +**Developer Preview — physical acceptance pending** + +This is the first installable AgenNet preview. It is intentionally published +as a prerelease rather than a stable release. + +## What works + +- native user-local binaries for macOS arm64/x86_64 and Linux arm64/x86_64; +- Linux installation inside WSL2; native Windows is not supported; +- a fixed-version checksum-verifying installer with no enrollment inputs; +- a distributable `agenet-node-bootstrap` Agent Skill; +- encrypted Domain Root material and Authority/Node credential chains; +- exact NodeId-bound mTLS peer transport over a private Tailscale or WireGuard + overlay; +- signed Capability discovery, bilateral Contracts, scoped Artifact access, + independent verification, and evidence-gated acceptance; +- a real read-only `source.metrics.v1` workload; +- login-scoped launchd and systemd user services; +- credential renewal, revocation, recoverable leave, conservative uninstall, + and public doctor/status commands. + +## Install and bootstrap + +Follow the [human installation guide](https://github.com/Nexa-Language/AgenNet/blob/v0.2.0-preview.1/docs/install/index.en.md) +or give an Agent the [Agent node setup guide](https://github.com/Nexa-Language/AgenNet/blob/v0.2.0-preview.1/docs/bootstrap/agent-node-setup.en.md). +Both pin this exact release and stop before enrollment so every Invitation and +passphrase is entered only by the human in the target device's local +controlling TTY. + +Release downloads include four native archives, `release-manifest-v1.json`, +`install.sh`, `SHA256SUMS`, both raw Agent guides, this release note, and the +versioned Skill archive. Every native archive is deterministic and has a +GitHub artifact attestation. The installer and complete asset set are verified +again before the prerelease can publish. + +## Compatibility and requirements + +- Rust-built native targets: Apple arm64, Apple x86_64, Linux arm64, and Linux + x86_64. +- Windows users need WSL2 with a working systemd user session. +- A node requires a private Tailscale or WireGuard overlay with one + unambiguous policy-approved address. +- Bootstrap and secret-bearing operations require a real controlling TTY. +- Persistent schemas and wire protocols are versioned and fail closed on old + or unknown formats. This preview does not promise stable compatibility. + +## Honest limits + +The preview has passed extensive protocol, filesystem, HTTP/TLS, process, +service-manager, and real-binary local mTLS tests. It has not yet passed the +planned two-physical-device private-overlay acceptance. The later fresh WSL2 +Agent run validates only the public installation/Skill surface and does not +substitute for that physical network gate. + +This release is not an arbitrary-code sandbox and does not expose a shell +execution Capability. It does not prove Internet-scale discovery, distributed +failover, replicated state, quota/payment markets, or general software +engineering ability. Its first workload is intentionally small and read-only. + +The Agent Society and collective-AGI direction is a long-term research vision, +not a capability claim for this release. + +## Safe error reporting + +Report only the stable public error code and whether the public doctor status +is healthy. Do not publish Invitations, passphrases, private keys, tokens, +private addresses, CIDRs, Node IDs, endpoints, local paths, raw JSON, terminal +transcripts, or diagnostic logs. diff --git a/scripts/preflight-preview-release.sh b/scripts/preflight-preview-release.sh new file mode 100755 index 0000000..f8fd5e2 --- /dev/null +++ b/scripts/preflight-preview-release.sh @@ -0,0 +1,150 @@ +#!/bin/sh +set -eu + +fail() { + printf '%s\n' "PreviewReleasePreflightFailed: $1" >&2 + exit 2 +} + +if [ "$#" -ne 0 ]; then + fail 'PreflightAcceptsNoArguments' +fi + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +cd "$repo_root" +version='0.2.0-preview.1' + +for required in \ + LICENSE \ + README.md \ + Cargo.lock \ + schemas/release-manifest-v1.schema.json \ + scripts/install.sh.template \ + scripts/verify-release.sh \ + scripts/package-bootstrap-skill.sh \ + docs/install/index.md \ + docs/install/index.en.md \ + docs/bootstrap/agent-node-setup.md \ + docs/bootstrap/agent-node-setup.en.md \ + docs/releases/v0.2.0-preview.1.md \ + skills/agenet-node-bootstrap/SKILL.md \ + skills/agenet-node-bootstrap/agents/openai.yaml \ + skills/agenet-node-bootstrap/references/public-status.md \ + skills/agenet-node-bootstrap/scripts/check-public-readiness.sh; do + [ -f "$required" ] && [ ! -L "$required" ] && [ -s "$required" ] \ + || fail 'MissingReleaseSurface' +done + +python3 - "$version" <<'PY' || exit $? +import json +import pathlib +import re +import subprocess +import sys + + +def fail(code: str) -> None: + print(f"PreviewReleasePreflightFailed: {code}", file=sys.stderr) + raise SystemExit(2) + + +version = sys.argv[1] +metadata = json.loads( + subprocess.check_output( + ["cargo", "metadata", "--locked", "--no-deps", "--format-version", "1"], + text=True, + ) +) +root_packages = [package for package in metadata["packages"] if package["name"] == "agenet"] +if len(root_packages) != 1 or root_packages[0]["version"] != version: + fail("CargoVersionMismatch") + +required_version_files = [ + "README.md", + "Cargo.toml", + "Cargo.lock", + ".github/workflows/release.yml", + "src/release/manifest.rs", + "docs/install/index.md", + "docs/install/index.en.md", + "docs/bootstrap/agent-node-setup.md", + "docs/bootstrap/agent-node-setup.en.md", + "docs/releases/v0.2.0-preview.1.md", + "skills/agenet-node-bootstrap/SKILL.md", + "skills/agenet-node-bootstrap/agents/openai.yaml", + "skills/agenet-node-bootstrap/scripts/check-public-readiness.sh", +] +for name in required_version_files: + if version not in pathlib.Path(name).read_text(encoding="utf-8"): + fail("PublicVersionMismatch") + +workflow = pathlib.Path(".github/workflows/release.yml").read_text(encoding="utf-8") +uses = re.findall(r"^\s*uses:\s*([^\s#]+)", workflow, flags=re.MULTILINE) +if not uses or any(not re.search(r"@[0-9a-f]{40}$", use) for use in uses): + fail("UnpinnedGitHubAction") +if "/latest/" in workflow or "/heads/" in workflow: + fail("MovingReleaseReference") + +public = "\n".join(pathlib.Path(name).read_text(encoding="utf-8") for name in required_version_files) +for claim in [ + "physical acceptance complete", + "production-ready", + "is a stable release", + "native Windows is supported", +]: + if claim.lower() in public.lower(): + fail("UnsupportedPublicClaim") +if "Developer Preview — physical acceptance pending" not in public: + fail("MissingPreviewBoundary") + +tracked = subprocess.check_output(["git", "ls-files", "-z"]).split(b"\0") +for raw_name in tracked: + if not raw_name: + continue + name = raw_name.decode("utf-8") + path = pathlib.Path(name) + lowered = name.lower() + if ( + lowered == ".env" + or lowered.endswith((".key", ".pem", ".token", ".jsonl")) + or "/.local/" in f"/{lowered}/" + ): + fail("TrackedPrivateArtifact") + try: + body = path.read_bytes() + except OSError: + fail("TrackedFileReadFailed") + if len(body) > 4 * 1024 * 1024 or b"\0" in body: + continue + text = body.decode("utf-8", errors="ignore") + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("-----BEGIN ") and stripped.endswith("PRIVATE KEY-----"): + fail("TrackedPrivateKey") + if re.search(r"(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}", text): + fail("TrackedGitHubCredential") + if re.search(r"sk-(?:proj-)?[A-Za-z0-9_-]{20,}", text): + fail("TrackedModelCredential") +PY + +for executable in \ + scripts/package-release.sh \ + scripts/check-release-archive.sh \ + scripts/verify-release.sh \ + scripts/package-bootstrap-skill.sh \ + skills/agenet-node-bootstrap/scripts/check-public-readiness.sh; do + [ -x "$executable" ] || fail 'ReleaseScriptNotExecutable' +done + +cargo test --quiet --test release_manifest --test installer --test public_guides >/dev/null +bash tests/scripts/release-archive.sh >/dev/null +bash tests/scripts/installer-smoke.sh >/dev/null +bash tests/scripts/skill-readiness.sh >/dev/null + +if git diff --check -- . ':(exclude)docs/design/agenet-v0.1.md' >/dev/null 2>&1; then + : +else + fail 'ReleaseDiffWhitespaceError' +fi + +printf '%s\n' "AgenNet v$version release preflight passed" diff --git a/skills/agenet-node-bootstrap/agents/openai.yaml b/skills/agenet-node-bootstrap/agents/openai.yaml index e4653a0..6eb6664 100644 --- a/skills/agenet-node-bootstrap/agents/openai.yaml +++ b/skills/agenet-node-bootstrap/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "AgenNet Node Bootstrap" short_description: "Prepare an AgenNet node without exposing enrollment secrets" - default_prompt: "Use $agenet-node-bootstrap to prepare this macOS, Linux, or WSL2 device as an AgenNet node; stop for local TTY enrollment." + default_prompt: "Use $agenet-node-bootstrap and fixed v0.2.0-preview.1 to prepare this macOS, Linux, or WSL2 device as an AgenNet node; stop for local TTY enrollment." diff --git a/tests/scripts/preview-release-preflight.sh b/tests/scripts/preview-release-preflight.sh new file mode 100755 index 0000000..8de4a2c --- /dev/null +++ b/tests/scripts/preview-release-preflight.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd) +preflight="$repo_root/scripts/preflight-preview-release.sh" + +test -x "$preflight" +first=$($preflight) +second=$($preflight) +test "$first" = 'AgenNet v0.2.0-preview.1 release preflight passed' +test "$second" = "$first" + +git -C "$repo_root" diff --check -- . \ + ':(exclude)docs/design/agenet-v0.1.md' + +echo 'preview release preflight tests passed' From 1608c26012f999d5b154eff755df6919b27fcc40 Mon Sep 17 00:00:00 2001 From: ouyangyipeng Date: Sat, 15 Aug 2026 19:36:05 +0800 Subject: [PATCH 54/67] [feat][Site] Publish AgenNet public experience Root cause: NA Solution: Add the bilingual Sites landing page, documentation, particle field, and generated fixed-release installation content. Risks: Physical two-device acceptance remains pending; WebGL is optional. Dependency: v0.2.0-preview.1 release candidate and canonical guides. Links: plan/03-v2-public-sites.md --- ROADMAP.md | 9 + site/.gitignore | 41 + site/.openai/hosting.json | 5 + site/README.md | 22 + site/app/components/CopyBlock.tsx | 18 + site/app/components/DocsPage.tsx | 89 + site/app/components/LandingPage.tsx | 107 + site/app/components/Logo.tsx | 14 + site/app/components/ParticleField.tsx | 110 + site/app/components/SiteShell.tsx | 44 + site/app/docs/architecture/page.tsx | 2 + site/app/docs/bootstrap/page.tsx | 2 + site/app/docs/install/page.tsx | 2 + site/app/docs/page.tsx | 2 + site/app/docs/status/page.tsx | 2 + site/app/docs/vision/page.tsx | 2 + site/app/en/docs/architecture/page.tsx | 2 + site/app/en/docs/bootstrap/page.tsx | 2 + site/app/en/docs/install/page.tsx | 2 + site/app/en/docs/page.tsx | 2 + site/app/en/docs/status/page.tsx | 2 + site/app/en/docs/vision/page.tsx | 2 + site/app/en/page.tsx | 5 + site/app/generated/public-content.ts | 6 + site/app/globals.css | 84 + site/app/layout.tsx | 27 + site/app/page.tsx | 5 + site/eslint.config.mjs | 41 + site/next-env.d.ts | 5 + site/next.config.ts | 7 + site/package-lock.json | 8747 ++++++++++++++++++++++++ site/package.json | 45 + site/postcss.config.mjs | 7 + site/public/favicon.svg | 5 + site/public/robots.txt | 2 + site/scripts/sync-public-content.mjs | 43 + site/tests/rendered-html.test.mjs | 84 + site/tsconfig.json | 29 + site/vite.config.ts | 59 + site/worker/index.ts | 46 + 40 files changed, 9730 insertions(+) create mode 100644 site/.gitignore create mode 100644 site/.openai/hosting.json create mode 100644 site/README.md create mode 100644 site/app/components/CopyBlock.tsx create mode 100644 site/app/components/DocsPage.tsx create mode 100644 site/app/components/LandingPage.tsx create mode 100644 site/app/components/Logo.tsx create mode 100644 site/app/components/ParticleField.tsx create mode 100644 site/app/components/SiteShell.tsx create mode 100644 site/app/docs/architecture/page.tsx create mode 100644 site/app/docs/bootstrap/page.tsx create mode 100644 site/app/docs/install/page.tsx create mode 100644 site/app/docs/page.tsx create mode 100644 site/app/docs/status/page.tsx create mode 100644 site/app/docs/vision/page.tsx create mode 100644 site/app/en/docs/architecture/page.tsx create mode 100644 site/app/en/docs/bootstrap/page.tsx create mode 100644 site/app/en/docs/install/page.tsx create mode 100644 site/app/en/docs/page.tsx create mode 100644 site/app/en/docs/status/page.tsx create mode 100644 site/app/en/docs/vision/page.tsx create mode 100644 site/app/en/page.tsx create mode 100644 site/app/generated/public-content.ts create mode 100644 site/app/globals.css create mode 100644 site/app/layout.tsx create mode 100644 site/app/page.tsx create mode 100644 site/eslint.config.mjs create mode 100644 site/next-env.d.ts create mode 100644 site/next.config.ts create mode 100644 site/package-lock.json create mode 100644 site/package.json create mode 100644 site/postcss.config.mjs create mode 100644 site/public/favicon.svg create mode 100644 site/public/robots.txt create mode 100644 site/scripts/sync-public-content.mjs create mode 100644 site/tests/rendered-html.test.mjs create mode 100644 site/tsconfig.json create mode 100644 site/vite.config.ts create mode 100644 site/worker/index.ts diff --git a/ROADMAP.md b/ROADMAP.md index 5146363..23d0d6d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # ROADMAP +## 2026-08-15 — Build the bilingual AgenNet public site + +- **Change**: Replaced the GPT Sites starter with a Chinese-default, English-mirrored AgenNet landing page and documentation site, including a bounded WebGL2 particle field, fixed release installation, Agent bootstrap/Skill guidance, protocol architecture, honest status, and the Agent Society vision. +- **Files**: `site/`, `plan/03-v2-public-sites.md`, and this roadmap. +- **Decision reason**: The public surface must explain why AgenNet exists and make a new node installable without maintaining a second, drifting source of release truth. The build therefore regenerates its version, status, verified install block, and one-sentence Agent prompts from the canonical repository guides. +- **Visual boundary**: WebGL2 is progressive enhancement only. Server-rendered semantic content, the mineral-dark CSS atmosphere, mobile layout, keyboard landmarks, and reduced-motion fallback remain complete without graphics or motion. The site has no forms, authentication, telemetry, database, D1, R2, credential inputs, or live private node state. +- **Evidence**: All fourteen routes server-render in both languages; build, ESLint, zero-vulnerability npm audit, desktop/mobile browser inspection, no-overflow checks, reduced-motion source validation, and zero browser console warnings/errors pass. The install page uses the exact fixed `v0.2.0-preview.1` checksum-verifying block and rejects pipe-to-shell copy. +- **Boundary**: Publication retains **Developer Preview — physical acceptance pending**. The site and release do not claim the pending fresh WSL2 Agent run or two-physical-device overlay acceptance. + ## 2026-08-15 — Seal the preview release candidate - **Change**: Added the public `v0.2.0-preview.1` release note, a single aggregate release preflight, concise README installation entrypoint, and removal of a real local development path from the archive-bound README. diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 0000000..220290e --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage +*.tsbuildinfo + +# next.js +/.next/ +/.vinext/ +/out/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +/dist/ +/.wrangler/ +/outputs/ +/work/ diff --git a/site/.openai/hosting.json b/site/.openai/hosting.json new file mode 100644 index 0000000..5edbed0 --- /dev/null +++ b/site/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "d1": null, + "r2": null, + "project_id": "appgprj_6a804ef139688191beaf9e697f0d5967" +} diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..4ad0e4e --- /dev/null +++ b/site/README.md @@ -0,0 +1,22 @@ +# AgenNet public site + +The bilingual AgenNet landing page and documentation site. It uses Vinext, +Vite, React, and the OpenAI Sites hosting adapter. + +Public release facts are not independently maintained here. Before every +build, `scripts/sync-public-content.mjs` reads the canonical installation and +Agent bootstrap guides from the repository root and regenerates +`app/generated/public-content.ts`. The build fails if the bilingual installer +blocks diverge, lose their fixed release, lose checksum verification, or use a +pipe-to-shell pattern. + +```sh +npm ci +npm test +npm run lint +``` + +The particle field uses WebGL2 when available. The semantic content renders on +the server, remains usable without WebGL, and becomes static when the visitor +requests reduced motion. The site has no analytics, forms, authentication, +database, secrets, D1, or R2 dependency. diff --git a/site/app/components/CopyBlock.tsx b/site/app/components/CopyBlock.tsx new file mode 100644 index 0000000..7f0e91b --- /dev/null +++ b/site/app/components/CopyBlock.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useState } from "react"; + +export function CopyBlock({ value, label }: Readonly<{ value: string; label: string }>) { + const [copied, setCopied] = useState(false); + async function copy() { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1800); + } + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/site/app/components/DocsPage.tsx b/site/app/components/DocsPage.tsx new file mode 100644 index 0000000..12d425d --- /dev/null +++ b/site/app/components/DocsPage.tsx @@ -0,0 +1,89 @@ +import { agentPhraseEn, agentPhraseZh, installCommand, releaseStatus, releaseVersion } from "../generated/public-content"; +import { CopyBlock } from "./CopyBlock"; +import { Locale, localized, SiteShell } from "./SiteShell"; + +export type DocKind = "index" | "install" | "bootstrap" | "architecture" | "vision" | "status"; + +const nav = { + zh: [["文档首页", ""], ["安装", "/install"], ["Agent 接入", "/bootstrap"], ["架构", "/architecture"], ["长期愿景", "/vision"], ["项目状态", "/status"]], + en: [["Overview", ""], ["Install", "/install"], ["Agent setup", "/bootstrap"], ["Architecture", "/architecture"], ["Vision", "/vision"], ["Status", "/status"]], +}; + +function DocsLayout({ locale, kind, children }: Readonly<{ locale: Locale; kind: DocKind; children: React.ReactNode }>) { + return ( + +
+ +
{children}
+
+ + ); +} + +const zh = { + install: { + title: "校验并安装", + intro: "安装器只把固定版本的公开二进制写入 ~/.local/bin/agenet。它不会接收 Invitation、密码、私钥或模型密钥。", + }, + bootstrap: { + title: "让 Agent 把设备接入 AgenNet", + intro: "把下面这句话交给装在目标电脑上的 Agent。Agent 可以安装、检查环境并停在安全边界前;Invitation 和密码始终由人在本机 TTY 输入。", + }, +}; + +const en = { + install: { + title: "Verify and install", + intro: "The installer writes one fixed public binary to ~/.local/bin/agenet. It never accepts an Invitation, passphrase, private key, or model key.", + }, + bootstrap: { + title: "Let an Agent prepare a new AgenNet node", + intro: "Give this sentence to the Agent on the target device. It may install and inspect public readiness, but it must stop before enrollment. The human enters every Invitation and passphrase in the local TTY.", + }, +}; + +export function DocsIndex({ locale }: Readonly<{ locale: Locale }>) { + const isZh = locale === "zh"; + const cards = isZh ? [ + ["安装", "固定版本、SHA-256 校验、macOS/Linux/WSL2", "/install"], + ["Agent 节点", "一句话交给 Agent,敏感输入保留在人类 TTY", "/bootstrap"], + ["架构", "身份、路由、Contract、Evidence 与撤销", "/architecture"], + ["长期愿景", "从 Agent Network 到 Agent Society", "/vision"], + ["项目状态", "现在做到了什么,还有什么没有验证", "/status"], + ] : [ + ["Install", "Fixed release, SHA-256 verification, macOS/Linux/WSL2", "/install"], + ["Agent node", "One sentence for an Agent; secrets stay in the human TTY", "/bootstrap"], + ["Architecture", "Identity, routing, Contracts, Evidence, and revocation", "/architecture"], + ["Vision", "From Agent Network to Agent Society", "/vision"], + ["Status", "What works now and what remains unverified", "/status"], + ]; + return

DOCUMENTATION

{isZh ? "文档中心" : "Documentation"}

{isZh ? "从安装第一台节点开始,理解 AgenNet 当前可验证的能力与长期方向。" : "Start with one node, then understand AgenNet's verified capability boundary and long-term direction."}

{cards.map(([title, text, path]) =>

{title}

{text}

)}
; +} + +export function InstallDoc({ locale }: Readonly<{ locale: Locale }>) { + const c = locale === "zh" ? zh.install : en.install; + return

{releaseVersion} · FIXED RELEASE

{c.title}

{c.intro}

{releaseStatus}{locale === "zh" ? "Windows 请使用启用 systemd 的 WSL2;native Windows 暂不支持。" : "On Windows, use WSL2 with systemd. Native Windows is not supported yet."}

{locale === "zh" ? "固定安装步骤" : "Fixed installation procedure"}

{locale === "zh" ? "整段复制到本机终端。脚本先下载 install.sh 与 SHA256SUMS,核验摘要后才执行安装器。" : "Copy the complete block into the local terminal. It verifies install.sh against SHA256SUMS before execution."}

{locale === "zh" ? "公开检查" : "Public checks"}

{locale === "zh" ? "运行条件" : "Runtime requirements"}

  • macOS arm64/x86_64, Linux arm64/x86_64, or WSL2.
  • {locale === "zh" ? "Tailscale 或 WireGuard 私有覆盖网络。" : "A private Tailscale or WireGuard overlay."}
  • {locale === "zh" ? "秘密操作必须使用真实 controlling TTY。" : "Secret-bearing operations require a real controlling TTY."}
; +} + +export function BootstrapDoc({ locale }: Readonly<{ locale: Locale }>) { + const c = locale === "zh" ? zh.bootstrap : en.bootstrap; + const phrase = locale === "zh" ? agentPhraseZh : agentPhraseEn; + return

AGENT-OPERATED SETUP

{c.title}

{c.intro}

{locale === "zh" ? "一句话开始" : "Start with one sentence"}

{locale === "zh" ? "这条路径不要求目标 Agent 预先装好 Skill:固定指南会引导它安装公开二进制并遵守同一秘密边界。想预装 Skill 的用户也可以下载 release 中经过校验的确定性包。" : "The target Agent does not need the Skill in advance: the fixed guide leads it through the public install with the same secret boundary. Operators may also preinstall the deterministic, verified Skill package from the release."}

{locale === "zh" ? "下载 agenet-node-bootstrap Skill 包" : "Download the agenet-node-bootstrap Skill package"}

{locale === "zh" ? "Agent 必须停下来的地方" : "Where the Agent must stop"}

{locale === "zh" ? "秘密不进入对话" : "Secrets stay out of chat"}{locale === "zh" ? "不要把 Invitation、passphrase、私钥、token、私有地址、CIDR、原始 JSON 或终端日志发给 Agent。" : "Never give the Agent an Invitation, passphrase, private key, token, private address, CIDR, raw JSON, or terminal transcript."}

{locale === "zh" ? "Agent 能做什么" : "What the Agent may do"}

  1. {locale === "zh" ? "识别 macOS、Linux 或 WSL2 与 CPU 架构。" : "Identify macOS, Linux, or WSL2 and the CPU architecture."}
  2. {locale === "zh" ? "使用固定版本与 checksum 安装公开二进制。" : "Install the fixed public binary with checksum verification."}
  3. {locale === "zh" ? "检查 systemd user session 与私有 overlay 的公开就绪状态。" : "Check the systemd user session and public overlay readiness."}
  4. {locale === "zh" ? "在 enrollment 前停下,让人类接管 TTY。" : "Stop before enrollment and hand control to the human TTY."}
; +} + +export function ArchitectureDoc({ locale }: Readonly<{ locale: Locale }>) { + const isZh = locale === "zh"; + return

PROTOCOL v0.2

{isZh ? "协议闭环,而不是假网络" : "A real protocol loop, not a simulated network"}

{isZh ? "AgenNet 把 Agent 的推理与跨节点协作分开。模型决定需要什么能力,协议负责证明身份、限制权限并保存可审计的结果。" : "AgenNet separates Agent reasoning from cross-node coordination. Models decide what capability is needed; the protocol proves identity, limits authority, and preserves auditable outcomes."}

{[["01","Intent"],["02","Directory"],["03","Contract"],["04","Execute"],["05","Verify"],["06","Accepted"]].map(([n,t]) =>
{n}{t}
)}

{isZh ? "核心对象" : "Core objects"}

Identity

{isZh ? "Domain Root、Authority 与 Node Credential 构成可验证的签名链。" : "Domain Root, Authority, and Node Credentials form a verifiable signed chain."}

Capability

{isZh ? "Provider 只发布凭证授权范围内的具体能力。" : "Providers publish only capabilities authorized by their credential ceiling."}

Contract

{isZh ? "双方签署能力、Artifact、Grant、期限与验收规则。" : "Both parties sign the capability, artifact, grant, expiry, and acceptance rule."}

Evidence

{isZh ? "交付结果附带证据;Verifier 独立重算。" : "Delivery carries evidence; a Verifier recomputes independently."}

Revocation

{isZh ? "权限不是永久的。实时 effect 前重新检查凭证与撤销状态。" : "Authority is not permanent. Credentials and revocation are rechecked before live effects."}

Transport

{isZh ? "当前跨机边界是私有 overlay 上的 NodeId 绑定 mTLS。" : "The current cross-host boundary is NodeId-bound mTLS over a private overlay."}

{isZh ? "当前真实 workload" : "Current real workload"}

{isZh ? "source.metrics.v1 读取授权的 UTF-8 源码 Artifact,计算 SHA-256、字节数、总行数和非空行数。Executor 与 Verifier 独立计算,结果完全一致后 Requester 才签署 Accepted。" : "source.metrics.v1 reads an authorized UTF-8 source artifact and computes SHA-256, byte count, line count, and non-empty line count. Executor and Verifier compute independently before the Requester signs Accepted."}

; +} + +export function VisionDoc({ locale }: Readonly<{ locale: Locale }>) { + const isZh = locale === "zh"; + return

LONG-TERM HYPOTHESIS

{isZh ? "从 Agent Network 到 Agent Society" : "From Agent Network to Agent Society"}

{isZh ? "最终目标不是把更多 Agent 接到一张网里,而是让足够多的 Agent 与资源能够在低冲突、可纠正的制度下形成社会化协作,并尽可能高效地服务人的目标。" : "The goal is not merely to put more Agents on a network. It is to let enough Agents and resources coordinate under low-conflict, corrigible institutions and pursue human goals efficiently."}

NOWAgenNet

{isZh ? "身份、Capability、Contract、Evidence、撤销和传输语义。" : "Identity, Capability, Contract, Evidence, revocation, and transport semantics."}

NEXTAgent Network

{isZh ? "跨设备、跨所有者的异构 Agent 与确定性资源协作。" : "Cross-device, cross-owner coordination among heterogeneous Agents and deterministic resources."}

HYPOTHESISAgent Society

{isZh ? "组织、教育、知识传承、资源调度、冲突处置与公共服务。" : "Organization, education, knowledge inheritance, resource allocation, conflict resolution, and public services."}

{isZh ? "水平连接与纵向传承" : "Horizontal connection and vertical inheritance"}

{isZh ? "水平连接解决正在运行的 Agent 如何分工;纵向连接解决下一代 Agent 如何继承文明。Agent Library 保存有来源与修订记录的知识,Agent School 教方法并给出窄范围、可复验、会过期的资格,Agent Organization 围绕目标动态组建团队。未来还可能出现维修、应急、公检法和分级资源通道。" : "Horizontal links coordinate work happening now. Vertical links let future Agents inherit accumulated knowledge and methods. An Agent Library preserves sourced and revisable knowledge; an Agent School teaches methodology and issues narrow, reproducible, expiring qualifications; Agent Organizations form around goals. Maintenance, emergency response, justice, and service-class resource transit may follow."}

{isZh ? "这是一条研究路径,不是当前能力" : "This is a research path, not a current capability"}{isZh ? "连接和规模不会自动产生 AGI。真正的 Agent Society 还必须处理目标冲突、权力边界、资源稀缺、纠错、追责与多个人类 Principal 的分歧。" : "Connectivity and scale do not automatically produce AGI. A real Agent Society must still handle goal conflict, power boundaries, scarcity, correction, accountability, and disagreement among human Principals."}
; +} + +export function StatusDoc({ locale }: Readonly<{ locale: Locale }>) { + const isZh = locale === "zh"; + const works = isZh ? ["四种原生 macOS/Linux 架构与 WSL2 安装路径", "Ed25519 身份链与 NodeId 绑定 mTLS", "Capability 动态发现与双边 Contract", "独立 Verifier 与 evidence-gated Accepted", "加密 Root、撤销、续期、leave、doctor 与用户服务"] : ["Four native macOS/Linux architectures and WSL2 installation", "Ed25519 identity chains and NodeId-bound mTLS", "Dynamic Capability discovery and bilateral Contracts", "Independent verification and evidence-gated acceptance", "Encrypted Root, revocation, renewal, leave, doctor, and user services"]; + const pending = isZh ? ["尚未完成两台物理设备验收", "不是任意代码 sandbox,也不开放 shell Capability", "没有验证 Internet-scale discovery、复制状态或故障转移", "没有 quota、payment、reputation 或跨 Domain federation", "协议与持久化格式仍可能在 preview 阶段改变"] : ["Two-physical-device acceptance is still pending", "Not an arbitrary-code sandbox; no shell Capability is exposed", "No Internet-scale discovery, replicated state, or failover proof", "No quota, payment, reputation, or cross-Domain federation", "Wire and persistence formats may change during preview"]; + return

PUBLIC STATUS

{releaseVersion}

{releaseStatus}

{isZh ? "代码、安装器、Skill 与本地真实 mTLS 闭环已通过严格门禁;物理跨机证据仍待完成。" : "Code, installer, Skill, and the real local mTLS loop passed strict gates; physical cross-host evidence remains pending."}

{isZh ? "已经实现并验证" : "Implemented and verified"}

    {works.map(item =>
  • {item}
  • )}

{isZh ? "没有声称完成" : "Not claimed"}

    {pending.map(item =>
  • {item}
  • )}
{isZh ? "发布口径" : "Release posture"}{isZh ? "这是 Developer Preview,不是 stable release。Agent Society 与 collective AGI 是长期假设,不是本版本能力。" : "This is a Developer Preview, not a stable release. Agent Society and collective AGI are long-term hypotheses, not v0.2 capabilities."}
; +} diff --git a/site/app/components/LandingPage.tsx b/site/app/components/LandingPage.tsx new file mode 100644 index 0000000..d80b6fa --- /dev/null +++ b/site/app/components/LandingPage.tsx @@ -0,0 +1,107 @@ +import { releaseStatus, releaseVersion } from "../generated/public-content"; +import { ParticleField } from "./ParticleField"; +import { Locale, localized, SiteShell } from "./SiteShell"; + +const copy = { + zh: { + eyebrow: "A COORDINATION LAYER FOR AGENT SOCIETY", + title: "连接一切网络可触达的 Agent 与资源", + lead: "让独立运行、彼此陌生的智能体,通过可验证的身份、能力、合同与证据,在明确边界内完成协作。", + install: "开始安装", + docs: "阅读文档", + protocol: "不是另一个 Agent 框架", + protocolText: "AgenNet 不规定 Agent 如何思考。它处理更基础的问题:谁能发现谁、谁被允许做什么、结果如何验收,以及权限何时失效。", + journey: "从一次可信协作,到一个 Agent Society", + journeyText: "当前我们先把跨设备协作闭环做对。长期方向,是让大量异构 Agent、工具、算力、数据与设备能够低冲突地形成组织、调度资源并服务人的目标。", + honest: "愿景不是能力声明。网络规模不会自动产生 AGI;每一步都必须能被验证、推翻和纠正。", + cards: [ + ["01 / Identity", "身份先于连接", "每个参与者用签名凭证证明自己。TLS 连接、协议签名与 NodeId 三者严格绑定。"], + ["02 / Contract", "授权先于执行", "自然语言目标不会直接变成远程副作用。能力、Artifact、期限和验收条件进入双边 Contract。"], + ["03 / Evidence", "证据先于接受", "Executor 交付不等于完成。独立 Verifier 重算结果,Requester 才能签署 Accepted。"], + ], + institutions: ["Agent Library", "Agent School", "Agent Organizations", "Maintenance & Recovery", "Justice & Emergency", "Resource Transit"], + }, + en: { + eyebrow: "A COORDINATION LAYER FOR AGENT SOCIETY", + title: "Connect every network-reachable Agent and resource", + lead: "Enable independently operated Agents to collaborate through verifiable identity, capabilities, contracts, and evidence — within explicit boundaries.", + install: "Install preview", + docs: "Read the docs", + protocol: "Not another Agent framework", + protocolText: "AgenNet does not prescribe how an Agent thinks. It answers a lower-level question: who can discover whom, what is authorized, how outcomes are accepted, and when authority expires.", + journey: "From one trusted exchange to an Agent Society", + journeyText: "Today, we are making the cross-device coordination loop correct. Long term, heterogeneous Agents, tools, compute, data, and devices may form organizations, allocate resources, and pursue human goals with fewer conflicts.", + honest: "A vision is not a capability claim. Scale alone does not produce AGI; every step must remain testable, falsifiable, and corrigible.", + cards: [ + ["01 / Identity", "Identity before connectivity", "Every participant proves its identity with signed credentials. TLS, protocol signatures, and NodeId remain exactly bound."], + ["02 / Contract", "Authorization before effects", "Natural language never becomes an unbounded remote effect. Capability, artifact, expiry, and acceptance enter a bilateral Contract."], + ["03 / Evidence", "Evidence before acceptance", "Delivery is not completion. An independent Verifier recomputes the result before the Requester signs Accepted."], + ], + institutions: ["Agent Library", "Agent School", "Agent Organizations", "Maintenance & Recovery", "Justice & Emergency", "Resource Transit"], + }, +}; + +export function LandingPage({ locale }: Readonly<{ locale: Locale }>) { + const c = copy[locale]; + return ( + +
+
+ +
+
+
{c.eyebrow}
+

{c.title}

+

{c.lead}

+ + + {releaseVersion}{releaseStatus} + +
+ +
+ +
+
01
+

THE PROTOCOL LAYER

{c.protocol}

+

{c.protocolText}

+
+ +
+ {c.cards.map(([eyebrow, title, body]) => ( +
+ {eyebrow}

{title}

{body}

+ ))} +
+ +
+

ONE VERIFIABLE LOOP

Intent → Route → Contract → Execute → Verify → Accept

+
+ {["Intent", "Directory", "Contract", "Executor", "Verifier", "Accepted"].map((item, index) => ( +
{String(index + 1).padStart(2, "0")}{item}
+ ))} +
+
+ +
+
02
+

LONG-TERM RESEARCH DIRECTION

{c.journey}

{c.journeyText}

+ +
+ {c.institutions.map((item, index) =>
{String(index + 1).padStart(2, "0")}{item}
)} +
+
+ +
+

START WITH ONE NODE

+

{locale === "zh" ? "把一台设备接入网络。" : "Bring one device into the network."}
{locale === "zh" ? "让一次协作变得可信。" : "Make one exchange trustworthy."}

+ {c.install} +
+
+
+ ); +} diff --git a/site/app/components/Logo.tsx b/site/app/components/Logo.tsx new file mode 100644 index 0000000..bfa8d7d --- /dev/null +++ b/site/app/components/Logo.tsx @@ -0,0 +1,14 @@ +export function Logo() { + return ( + + + AgenNet + + ); +} diff --git a/site/app/components/ParticleField.tsx b/site/app/components/ParticleField.tsx new file mode 100644 index 0000000..5e1b04d --- /dev/null +++ b/site/app/components/ParticleField.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +const vertex = `#version 300 es +precision highp float; +uniform float u_time; +uniform vec2 u_pointer; +uniform float u_pixel_ratio; +in vec3 a_position; +in float a_seed; +out float v_alpha; + +void main() { + vec3 p = a_position; + float wave = sin(p.x * 3.7 + u_time * .19 + a_seed * 5.0) * .045; + p.y += wave + cos(p.z * 4.2 - u_time * .14) * .025; + vec2 delta = p.xy - u_pointer; + float pull = exp(-dot(delta, delta) * 5.5) * .08; + p.xy += normalize(delta + .0001) * pull; + float perspective = 1.0 / (1.6 - p.z * .34); + gl_Position = vec4(p.xy * perspective, 0.0, 1.0); + gl_PointSize = (1.2 + a_seed * 1.9) * u_pixel_ratio * perspective; + v_alpha = (.2 + .65 * a_seed) * smoothstep(1.35, .15, length(p.xy)); +}`; + +const fragment = `#version 300 es +precision highp float; +in float v_alpha; +out vec4 out_color; +void main() { + vec2 p = gl_PointCoord - .5; + float d = length(p); + float core = smoothstep(.5, .04, d); + out_color = vec4(.68, .86, 1.0, core * v_alpha); +}`; + +function shader(gl: WebGL2RenderingContext, type: number, source: string) { + const value = gl.createShader(type); + if (!value) return null; + gl.shaderSource(value, source); + gl.compileShader(value); + if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) return null; + return value; +} + +export function ParticleField() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const gl = canvas.getContext("webgl2", { alpha: true, antialias: false, powerPreference: "high-performance" }); + if (!gl) return; + const vs = shader(gl, gl.VERTEX_SHADER, vertex); + const fs = shader(gl, gl.FRAGMENT_SHADER, fragment); + const program = gl.createProgram(); + if (!vs || !fs || !program) return; + gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return; + + const count = innerWidth < 700 ? 1050 : 2200; + const data = new Float32Array(count * 4); + let state = 0x4a6e6574; + const random = () => ((state = Math.imul(state ^ (state >>> 15), 1 | state) + 0x6d2b79f5) >>> 0) / 4294967296; + for (let i = 0; i < count; i += 1) { + const r = Math.sqrt(random()) * 1.55; + const angle = random() * Math.PI * 2; + data[i * 4] = Math.cos(angle) * r; + data[i * 4 + 1] = Math.sin(angle) * r * .72; + data[i * 4 + 2] = random() * 2 - 1; + data[i * 4 + 3] = random(); + } + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); + const position = gl.getAttribLocation(program, "a_position"); + const seed = gl.getAttribLocation(program, "a_seed"); + gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 3, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(seed); gl.vertexAttribPointer(seed, 1, gl.FLOAT, false, 16, 12); + const time = gl.getUniformLocation(program, "u_time"); + const pointerUniform = gl.getUniformLocation(program, "u_pointer"); + const ratioUniform = gl.getUniformLocation(program, "u_pixel_ratio"); + const pointer = { x: 2, y: 2 }; + const onPointer = (event: PointerEvent) => { + pointer.x = event.clientX / innerWidth * 2 - 1; + pointer.y = -(event.clientY / innerHeight * 2 - 1); + }; + addEventListener("pointermove", onPointer, { passive: true }); + let frame = 0; + const started = performance.now(); + const draw = () => { + const ratio = Math.min(devicePixelRatio, 1.75); + const width = Math.floor(canvas.clientWidth * ratio); + const height = Math.floor(canvas.clientHeight * ratio); + if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } + gl.viewport(0, 0, width, height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); + gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE); + gl.useProgram(program); + gl.uniform1f(time, (performance.now() - started) / 1000); + gl.uniform2f(pointerUniform, pointer.x, pointer.y); + gl.uniform1f(ratioUniform, ratio); + gl.drawArrays(gl.POINTS, 0, count); + frame = requestAnimationFrame(draw); + }; + draw(); + return () => { cancelAnimationFrame(frame); removeEventListener("pointermove", onPointer); }; + }, []); + + return