diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 90416e4f8..9d0dfd033 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -9,6 +9,7 @@ on: env: CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" jobs: static: @@ -21,6 +22,45 @@ jobs: - name: Check for panics run: ./tests/nopanic.ci + keylimectl-features: + name: keylimectl feature flags (${{ matrix.features }}) + runs-on: ubuntu-latest + container: + image: quay.io/keylime/keylime-ci:latest + strategy: + fail-fast: false + matrix: + features: + - "api-v2,api-v3" + - "api-v2" + - "api-v3" + steps: + - uses: actions/checkout@v7 + - name: Set git safe.directory for the working directory + run: git config --system --add safe.directory "$PWD" + - name: Build + run: cargo build -p keylimectl --no-default-features --features "${{ matrix.features }}" + - name: Test + run: cargo test -p keylimectl --no-default-features --features "${{ matrix.features }}" + - name: Clippy + run: cargo clippy -p keylimectl --all-targets --no-default-features --features "${{ matrix.features }}" -- -D clippy::all + + keylimectl-no-features: + name: keylimectl compile_error guard + runs-on: ubuntu-latest + container: + image: quay.io/keylime/keylime-ci:latest + steps: + - uses: actions/checkout@v7 + - name: Set git safe.directory for the working directory + run: git config --system --add safe.directory "$PWD" + - name: Verify compile_error triggers with no features + run: | + if cargo build -p keylimectl --no-default-features 2>&1; then + echo "ERROR: Build should have failed with no features enabled" + exit 1 + fi + tests: name: Fedora tests runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 56e5e363e..d0f5c98d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,7 +59,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -179,7 +179,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.3", + "socket2 0.6.4", "time", "tracing", "url", @@ -194,7 +194,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -277,6 +277,27 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -287,6 +308,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -295,7 +331,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -306,9 +342,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -316,6 +352,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.72.1" @@ -325,17 +367,32 @@ dependencies = [ "bitflags 2.11.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", "quote", "regex", "rustc-hash", - "shlex", - "syn", + "shlex 1.3.0", + "syn 2.0.118", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", ] +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bitfield" version = "0.19.4" @@ -353,7 +410,7 @@ checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -368,6 +425,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -377,11 +443,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "buffered-reader" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db26bf1f092fd5e05b5ab3be2f290915aeb6f3f20c4e9f86ce0f07f336c2412f" +dependencies = [ + "libc", +] + [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -404,14 +490,35 @@ dependencies = [ "bytes", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cc" -version = "1.2.61" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", - "shlex", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] @@ -420,7 +527,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -496,7 +603,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -519,12 +626,37 @@ checksum = "23738e11972c7643e4ec947840fc463b6a571afcd3e735bdfce7d03c7a784aca" dependencies = [ "async-trait", "lazy_static", - "nom", + "nom 7.1.3", "pathdiff", "serde", "toml 0.5.11", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -577,6 +709,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -639,10 +790,28 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.118", "unicode-xid", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console 0.16.3", + "shell-words", + "tempfile", + "zeroize", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -651,24 +820,46 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" @@ -679,6 +870,28 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-display-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ef37b2a9b242295d61a154ee91ae884afff6b8b933b486b12481cc58310ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "enum-primitive-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba7795da175654fe16979af73f81f26a8ea27638d8d9823d317016888a63dc4c" +dependencies = [ + "num-traits", + "quote", + "syn 2.0.118", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -696,7 +909,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -746,6 +959,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -756,6 +975,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -848,7 +1076,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -984,9 +1212,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -1076,9 +1304,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1129,7 +1357,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -1281,11 +1509,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993f007684f2e9727160da8b960ec161264703bfd1af084fd2e34d040c9a0dd4" +dependencies = [ + "console 0.16.3", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1318,17 +1559,36 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -1336,6 +1596,15 @@ 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 = "keylime" version = "0.2.10" @@ -1383,7 +1652,7 @@ name = "keylime-macros" version = "0.2.10" dependencies = [ "quote", - "syn", + "syn 2.0.118", "thiserror", "trybuild", ] @@ -1453,6 +1722,76 @@ dependencies = [ "wiremock", ] +[[package]] +name = "keylimectl" +version = "0.2.10" +dependencies = [ + "anyhow", + "assert_cmd", + "base64", + "bzip2", + "chrono", + "clap", + "config", + "console 0.15.11", + "dialoguer", + "flate2", + "hex", + "indicatif", + "keylime", + "log", + "openssl", + "predicates", + "pretty_env_logger", + "quick-xml", + "rayon", + "reqwest", + "reqwest-middleware", + "rpm", + "sequoia-openpgp", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "toml 0.8.23", + "tss-esapi", + "uuid", + "xz2", + "zeroize", + "zstd", +] + +[[package]] +name = "lalrpop" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a80a963123205c7157323c99611bc4abb65dcbd62ef46dc4bac74a3941bc75" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.14.0", + "lalrpop-util", + "petgraph", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884f3e747ed2dcee867cda1b0c31a048f9e20de2d916a248949319921a2e666e" +dependencies = [ + "regex-automata", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -1520,6 +1859,17 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mbox" version = "0.7.1" @@ -1530,11 +1880,27 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memsec" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492" [[package]] name = "metadeps" @@ -1571,9 +1937,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -1598,6 +1964,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nom" version = "7.1.3" @@ -1608,6 +1980,21 @@ dependencies = [ "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 = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1617,11 +2004,44 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -1631,7 +2051,38 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", ] [[package]] @@ -1696,7 +2147,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1740,6 +2191,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -1782,7 +2244,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1795,6 +2257,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "picky-asn1" version = "0.10.1" @@ -1848,6 +2330,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1872,6 +2360,42 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "pretty_env_logger" version = "0.5.0" @@ -1889,7 +2413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.118", ] [[package]] @@ -1901,6 +2425,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -1953,6 +2486,12 @@ 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" + [[package]] name = "rand_core" version = "0.9.5" @@ -1968,6 +2507,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2091,6 +2650,32 @@ dependencies = [ "rand 0.10.1", ] +[[package]] +name = "rpm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c0e45982d67e35e3bbffb24d95e3d0282211ac2dad755b2ab24b9ffc07b199" +dependencies = [ + "base64", + "bitflags 2.11.1", + "digest", + "enum-display-derive", + "enum-primitive-derive", + "hex", + "itertools 0.14.0", + "log", + "md-5", + "nom 8.0.0", + "num", + "num-derive", + "num-traits", + "sha1", + "sha2", + "sha3", + "thiserror", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2140,6 +2725,15 @@ 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" @@ -2184,6 +2778,33 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "sequoia-openpgp" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c847f0f148cf238c3aec88d092fd3c4301c21e906829ea9e415ea7531f7ec094" +dependencies = [ + "anyhow", + "argon2", + "base64", + "buffered-reader", + "chrono", + "dyn-clone", + "getrandom 0.2.17", + "idna", + "lalrpop", + "lalrpop-util", + "libc", + "memsec", + "openssl", + "openssl-sys", + "regex", + "regex-syntax", + "sha1collisiondetection", + "thiserror", + "xxhash-rust", +] + [[package]] name = "serde" version = "1.0.228" @@ -2221,7 +2842,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2237,6 +2858,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2258,6 +2888,27 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha1collisiondetection" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f606421e4a6012877e893c399822a4ed4b089164c5969424e1b9d1e66e6964b" +dependencies = [ + "digest", + "generic-array", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2269,6 +2920,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2278,12 +2939,24 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -2310,6 +2983,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2334,9 +3013,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2354,12 +3033,41 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[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 = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.118" @@ -2388,7 +3096,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2416,6 +3124,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -2425,6 +3142,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -2442,7 +3165,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2497,9 +3220,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2507,7 +3230,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -2520,7 +3243,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2572,6 +3295,18 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -2580,11 +3315,20 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -2596,15 +3340,35 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -2628,9 +3392,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.9" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", @@ -2676,7 +3440,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2771,9 +3535,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -2789,9 +3553,15 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -2799,6 +3569,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "url" version = "2.5.8" @@ -2852,6 +3628,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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" @@ -2887,9 +3682,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2900,9 +3695,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -2910,9 +3705,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2920,22 +3715,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -2990,9 +3785,19 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -3028,7 +3833,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3039,7 +3844,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3075,6 +3880,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" @@ -3150,9 +3964,18 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.2" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "wiremock" @@ -3213,7 +4036,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.118", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3229,7 +4052,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3277,6 +4100,21 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.2" @@ -3296,35 +4134,35 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -3337,7 +4175,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -3358,7 +4196,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3391,7 +4229,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3433,3 +4271,31 @@ dependencies = [ "libc", "metadeps", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index d006826e3..96e97f9fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "keylime-agent", "keylime-macros", "keylime-ima-emulator", "keylime-push-model-agent", + "keylimectl", ] resolver = "2" diff --git a/GNUmakefile b/GNUmakefile index 548bcda88..d74224995 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -3,6 +3,7 @@ RELEASE ?= 0 TARGETDIR ?= target +PREFIX ?= /usr CONFFILE ?= ./keylime-agent.conf ifeq ($(RELEASE),1) @@ -18,7 +19,8 @@ systemdsystemunitdir := $(shell pkg-config systemd --variable=systemdsystemunitd programs = \ ${TARGETDIR}/${PROFILE}/keylime_agent \ ${TARGETDIR}/${PROFILE}/keylime_ima_emulator \ - ${TARGETDIR}/${PROFILE}/keylime_push_model_agent + ${TARGETDIR}/${PROFILE}/keylime_push_model_agent \ + ${TARGETDIR}/${PROFILE}/keylimectl .PHONY: all all: $(programs) @@ -35,8 +37,9 @@ install: all mkdir -p ${DESTDIR}/etc/keylime/ mkdir -p ${DESTDIR}/etc/keylime/agent.conf.d cp ${CONFFILE} ${DESTDIR}/etc/keylime/agent.conf + cp keylimectl/keylimectl.conf ${DESTDIR}/etc/keylime/keylimectl.conf for f in $(programs); do \ - install -D -t ${DESTDIR}/usr/bin "$$f"; \ + install -D -t ${DESTDIR}${PREFIX}/bin "$$f"; \ done install -D -m 644 -t ${DESTDIR}$(systemdsystemunitdir) dist/systemd/system/keylime_agent.service install -D -m 644 -t ${DESTDIR}$(systemdsystemunitdir) dist/systemd/system/keylime_push_model_agent.service diff --git a/keylime-agent/Cargo.toml b/keylime-agent/Cargo.toml index 02c54e023..d250495d2 100644 --- a/keylime-agent/Cargo.toml +++ b/keylime-agent/Cargo.toml @@ -15,7 +15,7 @@ clap.workspace = true config.workspace = true futures.workspace = true hex.workspace = true -keylime.workspace = true +keylime = { workspace = true, features = [] } log.workspace = true openssl.workspace = true pretty_env_logger.workspace = true @@ -36,7 +36,7 @@ actix-rt.workspace = true [features] # The features enabled by default default = [] -testing = [] +testing = ["keylime/testing"] # Whether the agent should be compiled with support to listen for notification # messages on ZeroMQ # diff --git a/keylime-agent/src/agent_handler.rs b/keylime-agent/src/agent_handler.rs index 8f30f916c..751682eea 100644 --- a/keylime-agent/src/agent_handler.rs +++ b/keylime-agent/src/agent_handler.rs @@ -46,23 +46,21 @@ async fn info( /// Configure the endpoints for the /agent scope async fn agent_default(req: HttpRequest) -> impl Responder { - let error; - let response; - let message; - - match req.head().method { + let (error, message, response) = match req.head().method { http::Method::GET => { - error = 400; - message = "URI not supported, only /info is supported for GET in /agent interface"; - response = HttpResponse::BadRequest() + let error = 400; + let message = "URI not supported, only /info is supported for GET in /agent interface"; + let response = HttpResponse::BadRequest() .json(JsonWrapper::error(error, message)); + (error, message, response) } _ => { - error = 405; - message = "Method is not supported in /agent interface"; - response = HttpResponse::MethodNotAllowed() + let error = 405; + let message = "Method is not supported in /agent interface"; + let response = HttpResponse::MethodNotAllowed() .insert_header(http::header::Allow(vec![http::Method::GET])) .json(JsonWrapper::error(error, message)); + (error, message, response) } }; diff --git a/keylime-agent/src/api.rs b/keylime-agent/src/api.rs index 4d135a9b3..fa877a228 100644 --- a/keylime-agent/src/api.rs +++ b/keylime-agent/src/api.rs @@ -64,34 +64,33 @@ pub async fn version( /// Handles the default case for the API version scope async fn api_default(req: HttpRequest) -> impl Responder { - let error; - let response; - let message; - - match req.head().method { + let (error, message, response) = match req.head().method { http::Method::GET => { - error = 400; - message = + let error = 400; + let message = "Not Implemented: Use /agent, /keys, or /quotes interfaces"; - response = HttpResponse::BadRequest() + let response = HttpResponse::BadRequest() .json(JsonWrapper::error(error, message)); + (error, message, response) } http::Method::POST => { - error = 400; - message = + let error = 400; + let message = "Not Implemented: Use /keys or /notifications interfaces"; - response = HttpResponse::BadRequest() + let response = HttpResponse::BadRequest() .json(JsonWrapper::error(error, message)); + (error, message, response) } _ => { - error = 405; - message = "Method is not supported"; - response = HttpResponse::MethodNotAllowed() + let error = 405; + let message = "Method is not supported"; + let response = HttpResponse::MethodNotAllowed() .insert_header(http::header::Allow(vec![ http::Method::GET, http::Method::POST, ])) .json(JsonWrapper::error(error, message)); + (error, message, response) } }; diff --git a/keylime-agent/src/errors_handler.rs b/keylime-agent/src/errors_handler.rs index fad64f866..d5b2488ff 100644 --- a/keylime-agent/src/errors_handler.rs +++ b/keylime-agent/src/errors_handler.rs @@ -16,10 +16,6 @@ pub(crate) async fn app_default( req: HttpRequest, quote_data: web::Data>, ) -> impl Responder { - let error; - let response; - let message; - let api_versions = quote_data .api_versions .iter() @@ -27,32 +23,35 @@ pub(crate) async fn app_default( .collect::>() .join(", "); - match req.head().method { + let (error, message, response) = match req.head().method { http::Method::GET => { - error = 400; - message = format!( + let error = 400; + let message = format!( "Not Implemented: Use {api_versions} or /version interfaces" ); - response = HttpResponse::BadRequest() + let response = HttpResponse::BadRequest() .json(JsonWrapper::error(error, &message)); + (error, message, response) } http::Method::POST => { - error = 400; - message = format!( + let error = 400; + let message = format!( "Not Implemented: Use {api_versions} or /version interfaces" ); - response = HttpResponse::BadRequest() + let response = HttpResponse::BadRequest() .json(JsonWrapper::error(error, &message)); + (error, message, response) } _ => { - error = 405; - message = "Method is not supported".to_string(); - response = HttpResponse::MethodNotAllowed() + let error = 405; + let message = "Method is not supported".to_string(); + let response = HttpResponse::MethodNotAllowed() .insert_header(http::header::Allow(vec![ http::Method::GET, http::Method::POST, ])) .json(JsonWrapper::error(error, &message)); + (error, message, response) } }; diff --git a/keylime-agent/src/keys_handler.rs b/keylime-agent/src/keys_handler.rs index d60b827fb..9fbe8dbc6 100644 --- a/keylime-agent/src/keys_handler.rs +++ b/keylime-agent/src/keys_handler.rs @@ -550,32 +550,31 @@ pub(crate) async fn worker( /// Handles the default case for the /keys scope async fn keys_default(req: HttpRequest) -> impl Responder { - let error; - let response; - let message; - - match req.head().method { + let (error, message, response) = match req.head().method { http::Method::GET => { - error = 400; - message = "URI not supported, only /pubkey and /verify are supported for GET in /keys interface"; - response = HttpResponse::BadRequest() + let error = 400; + let message = "URI not supported, only /pubkey and /verify are supported for GET in /keys interface"; + let response = HttpResponse::BadRequest() .json(JsonWrapper::error(error, message)); + (error, message, response) } http::Method::POST => { - error = 400; - message = "URI not supported, only /ukey and /vkey are supported for POST in /keys interface"; - response = HttpResponse::BadRequest() + let error = 400; + let message = "URI not supported, only /ukey and /vkey are supported for POST in /keys interface"; + let response = HttpResponse::BadRequest() .json(JsonWrapper::error(error, message)); + (error, message, response) } _ => { - error = 405; - message = "Method is not supported in /keys interface"; - response = HttpResponse::MethodNotAllowed() + let error = 405; + let message = "Method is not supported in /keys interface"; + let response = HttpResponse::MethodNotAllowed() .insert_header(http::header::Allow(vec![ http::Method::GET, http::Method::POST, ])) .json(JsonWrapper::error(error, message)); + (error, message, response) } }; diff --git a/keylime-agent/src/notifications_handler.rs b/keylime-agent/src/notifications_handler.rs index 92e3e0aa8..557862470 100644 --- a/keylime-agent/src/notifications_handler.rs +++ b/keylime-agent/src/notifications_handler.rs @@ -36,23 +36,22 @@ async fn revocation( } async fn notifications_default(req: HttpRequest) -> impl Responder { - let error; - let response; - let message; - - match req.head().method { + let (error, message, response) = match req.head().method { http::Method::POST => { - error = 400; - message = "URI not supported, only /revocation is supported for POST in /notifications/ interface"; - response = HttpResponse::BadRequest() + let error = 400; + let message = "URI not supported, only /revocation is supported for POST in /notifications/ interface"; + let response = HttpResponse::BadRequest() .json(JsonWrapper::error(error, message)); + (error, message, response) } _ => { - error = 405; - message = "Method is not supported in /notifications/ interface"; - response = HttpResponse::MethodNotAllowed() + let error = 405; + let message = + "Method is not supported in /notifications/ interface"; + let response = HttpResponse::MethodNotAllowed() .insert_header(http::header::Allow(vec![http::Method::POST])) .json(JsonWrapper::error(error, message)); + (error, message, response) } }; diff --git a/keylime-agent/src/quotes_handler.rs b/keylime-agent/src/quotes_handler.rs index e8b5c1b8a..0841aa82f 100644 --- a/keylime-agent/src/quotes_handler.rs +++ b/keylime-agent/src/quotes_handler.rs @@ -330,23 +330,21 @@ async fn integrity( /// Handles the default case for the /quotes scope async fn quotes_default(req: HttpRequest) -> impl Responder { - let error; - let response; - let message; - - match req.head().method { + let (error, message, response) = match req.head().method { http::Method::GET => { - error = 400; - message = "URI not supported, only /identity and /integrity are supported for GET in /quotes/ interface"; - response = HttpResponse::BadRequest() + let error = 400; + let message = "URI not supported, only /identity and /integrity are supported for GET in /quotes/ interface"; + let response = HttpResponse::BadRequest() .json(JsonWrapper::error(error, message)); + (error, message, response) } _ => { - error = 405; - message = "Method is not supported in /quotes/ interface"; - response = HttpResponse::MethodNotAllowed() + let error = 405; + let message = "Method is not supported in /quotes/ interface"; + let response = HttpResponse::MethodNotAllowed() .insert_header(http::header::Allow(vec![http::Method::GET])) .json(JsonWrapper::error(error, message)); + (error, message, response) } }; diff --git a/keylime-push-model-agent/Cargo.toml b/keylime-push-model-agent/Cargo.toml index 1032b4fd3..b7fdc5d81 100644 --- a/keylime-push-model-agent/Cargo.toml +++ b/keylime-push-model-agent/Cargo.toml @@ -13,7 +13,7 @@ anyhow.workspace = true async-trait.workspace = true chrono.workspace = true clap.workspace = true -keylime.workspace = true +keylime = { workspace = true, features = [] } log.workspace = true pretty_env_logger.workspace = true reqwest.workspace = true diff --git a/keylime-push-model-agent/src/struct_filler.rs b/keylime-push-model-agent/src/struct_filler.rs index 48c28e58d..a029f4cdb 100644 --- a/keylime-push-model-agent/src/struct_filler.rs +++ b/keylime-push-model-agent/src/struct_filler.rs @@ -756,7 +756,6 @@ mod tests { let filler = FillerFromHardware::new(&mut ctx, &privileged_resources); assert!(filler.uefi_log_handler.is_none()); - assert!(ctx.flush_context().is_ok()); } } diff --git a/keylime/src/crypto.rs b/keylime/src/crypto.rs index 2feeda7c2..5cf019e5d 100644 --- a/keylime/src/crypto.rs +++ b/keylime/src/crypto.rs @@ -10,7 +10,7 @@ use base64::{engine::general_purpose, Engine as _}; use log::*; use openssl::{ ec::{EcGroupRef, EcKey}, - encrypt::Decrypter, + encrypt::{Decrypter, Encrypter}, hash::MessageDigest, memcmp, nid::Nid, @@ -181,6 +181,17 @@ pub enum CryptoError { source: openssl::error::ErrorStack, }, + /// RSA OAEP encrypt error + #[error("RSA OAEP encrypt error: {message}")] + RSAOAEPEncryptError { + message: String, + source: openssl::error::ErrorStack, + }, + + /// Error decoding public key from PEM + #[error("failed to decode public key from PEM")] + PublicKeyFromPEMError(#[source] openssl::error::ErrorStack), + /// Error signing data #[error("failed to sign data: {message}")] SignError { @@ -802,6 +813,14 @@ fn pkey_pub_from_priv( } } +/// Import an RSA or EC public key from PEM format +/// +/// This is the inverse of `pkey_pub_to_pem()`. +pub fn pkey_pub_from_pem(pem: &str) -> Result, CryptoError> { + PKey::::public_key_from_pem(pem.as_bytes()) + .map_err(CryptoError::PublicKeyFromPEMError) +} + pub fn pkey_pub_to_pem(pubkey: &PKey) -> Result { pubkey .public_key_to_pem() @@ -1028,6 +1047,66 @@ pub fn rsa_oaep_decrypt( Ok(decrypted) } +/* + * Inputs: OpenSSL RSA public key + * plaintext to be encrypted + * Output: encrypted ciphertext + * + * Take in plaintext and an RSA public key and encrypt the + * plaintext based on PKCS1 OAEP. This is the inverse of rsa_oaep_decrypt. + */ +pub fn rsa_oaep_encrypt( + pub_key: &PKey, + data: &[u8], +) -> Result, CryptoError> { + let mut encrypter = Encrypter::new(pub_key).map_err(|source| { + CryptoError::RSAOAEPEncryptError { + message: "failed to create RSA encrypter object".into(), + source, + } + })?; + + encrypter + .set_rsa_padding(Padding::PKCS1_OAEP) + .map_err(|source| CryptoError::RSAOAEPEncryptError { + message: "failed to set RSA encrypter padding".into(), + source, + })?; + encrypter + .set_rsa_mgf1_md(MessageDigest::sha1()) + .map_err(|source| CryptoError::RSAOAEPEncryptError { + message: "failed to set RSA encrypter MGF1 digest".into(), + source, + })?; + encrypter + .set_rsa_oaep_md(MessageDigest::sha1()) + .map_err(|source| CryptoError::RSAOAEPEncryptError { + message: "failed to set RSA encrypter OAEP digest".into(), + source, + })?; + + // Create an output buffer + let buffer_len = encrypter.encrypt_len(data).map_err(|source| { + CryptoError::RSAOAEPEncryptError { + message: "failed to get RSA encrypter output length".into(), + source, + } + })?; + let mut encrypted = vec![0; buffer_len]; + + // Encrypt and truncate the buffer + let encrypted_len = + encrypter.encrypt(data, &mut encrypted).map_err(|source| { + CryptoError::RSAOAEPEncryptError { + message: "failed to encrypt data with RSA OAEP".into(), + source, + } + })?; + encrypted.truncate(encrypted_len); + + Ok(encrypted) +} + /* * Inputs: secret key * message to sign @@ -1129,7 +1208,6 @@ pub fn decrypt_aead(key: &[u8], data: &[u8]) -> Result, CryptoError> { pub mod testing { use super::*; - use openssl::encrypt::Encrypter; use std::path::{Path, PathBuf}; #[derive(Error, Debug)] @@ -1163,29 +1241,15 @@ pub mod testing { pub fn pkey_pub_from_pem( pem: &str, ) -> Result, CryptoTestError> { - PKey::::public_key_from_pem(pem.as_bytes()) - .map_err(CryptoTestError::OpenSSLError) + super::pkey_pub_from_pem(pem).map_err(CryptoTestError::CryptoError) } pub fn rsa_oaep_encrypt( pub_key: &PKey, data: &[u8], ) -> Result, CryptoTestError> { - let mut encrypter = Encrypter::new(pub_key)?; - - encrypter.set_rsa_padding(Padding::PKCS1_OAEP)?; - encrypter.set_rsa_mgf1_md(MessageDigest::sha1())?; - encrypter.set_rsa_oaep_md(MessageDigest::sha1())?; - - // Create an output buffer - let buffer_len = encrypter.encrypt_len(data)?; - let mut encrypted = vec![0; buffer_len]; - - // Encrypt and truncate the buffer - let encrypted_len = encrypter.encrypt(data, &mut encrypted)?; - encrypted.truncate(encrypted_len); - - Ok(encrypted) + super::rsa_oaep_encrypt(pub_key, data) + .map_err(CryptoTestError::CryptoError) } pub fn encrypt_aead( @@ -2047,4 +2111,86 @@ mod tests { validate_key_algorithm(&private_key, EncryptionAlgorithm::Ecc256); assert!(validation.is_ok(), "Generated key should be ECC 256"); } + + #[test] + fn test_rsa_oaep_encrypt_decrypt_roundtrip() { + let (pub_key, priv_key) = rsa_generate_pair(2048).unwrap(); //#[allow_ci] + let plaintext = b"test data for RSA-OAEP roundtrip"; + + let encrypted = rsa_oaep_encrypt(&pub_key, plaintext).unwrap(); //#[allow_ci] + assert_ne!( + &encrypted[..], + plaintext, + "Encrypted data should differ from plaintext" + ); + + let decrypted = rsa_oaep_decrypt(&priv_key, &encrypted).unwrap(); //#[allow_ci] + assert_eq!( + &decrypted[..], + plaintext, + "Decrypted data should match original plaintext" + ); + } + + #[test] + fn test_pkey_pub_from_pem_roundtrip() { + let (pub_key, _) = rsa_generate_pair(2048).unwrap(); //#[allow_ci] + + let pem = pkey_pub_to_pem(&pub_key).unwrap(); //#[allow_ci] + let reimported = pkey_pub_from_pem(&pem).unwrap(); //#[allow_ci] + let pem2 = pkey_pub_to_pem(&reimported).unwrap(); //#[allow_ci] + + assert_eq!( + pem, pem2, + "PEM roundtrip should produce identical output" + ); + } + + // Negative security tests for pkey_pub_from_pem + + #[test] + fn test_pkey_pub_from_pem_empty() { + assert!(pkey_pub_from_pem("").is_err()); + } + + #[test] + fn test_pkey_pub_from_pem_garbage() { + assert!(pkey_pub_from_pem("not-a-pem").is_err()); + } + + #[test] + fn test_pkey_pub_from_pem_truncated() { + assert!(pkey_pub_from_pem( + "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----" + ) + .is_err()); + } + + // Negative security tests for rsa_oaep_encrypt + + #[test] + fn test_rsa_oaep_encrypt_with_ec_key() { + use openssl::ec::{EcGroup, EcKey}; + use openssl::nid::Nid; + use openssl::pkey::PKey; + + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); //#[allow_ci] + let ec = EcKey::generate(&group).unwrap(); //#[allow_ci] + let pkey = PKey::from_ec_key(ec).unwrap(); //#[allow_ci] + + // Extract public key only + let pem = pkey.public_key_to_pem().unwrap(); //#[allow_ci] + let pub_key = PKey::public_key_from_pem(&pem).unwrap(); //#[allow_ci] + + let result = rsa_oaep_encrypt(&pub_key, b"test data"); + assert!(result.is_err()); + } + + #[test] + fn test_rsa_oaep_encrypt_oversized_plaintext() { + // RSA-2048 with OAEP/SHA-256 can encrypt at most ~190 bytes + let (pub_key, _) = rsa_generate_pair(2048).unwrap(); //#[allow_ci] + let result = rsa_oaep_encrypt(&pub_key, &[0u8; 256]); + assert!(result.is_err()); + } } diff --git a/keylime/src/uefi/uefi_log_handler.rs b/keylime/src/uefi/uefi_log_handler.rs index bed3221fd..edf051cc4 100644 --- a/keylime/src/uefi/uefi_log_handler.rs +++ b/keylime/src/uefi/uefi_log_handler.rs @@ -378,6 +378,8 @@ impl UefiLogHandler { "EV_EFI_PLATFORM_FIRMWARE_BLOB" => 0x80000008, "EV_EFI_HANDOFF_TABLES" => 0x80000009, "EV_EFI_HCRTM_EVENT" => 0x8000000A, + "EV_EFI_PLATFORM_FIRMWARE_BLOB2" => 0x8000000B, + "EV_EFI_VARIABLE_AUTHORITY" => 0x800000E0, _ => 0xFFFFFFFF, // Default for EV_UNKNOWN_TYPE } } @@ -413,6 +415,8 @@ impl UefiLogHandler { 0x80000008 => "EV_EFI_PLATFORM_FIRMWARE_BLOB", 0x80000009 => "EV_EFI_HANDOFF_TABLES", 0x8000000A => "EV_EFI_HCRTM_EVENT", + 0x8000000B => "EV_EFI_PLATFORM_FIRMWARE_BLOB2", + 0x800000E0 => "EV_EFI_VARIABLE_AUTHORITY", _ => "EV_UNKNOWN_TYPE", } } @@ -548,6 +552,8 @@ mod tests { (0x80000008, "EV_EFI_PLATFORM_FIRMWARE_BLOB"), (0x80000009, "EV_EFI_HANDOFF_TABLES"), (0x8000000A, "EV_EFI_HCRTM_EVENT"), + (0x8000000B, "EV_EFI_PLATFORM_FIRMWARE_BLOB2"), + (0x800000E0, "EV_EFI_VARIABLE_AUTHORITY"), (0xFFFFFFFF, "EV_UNKNOWN_TYPE"), ]; for (event_type, expected_str) in event_tuples { diff --git a/keylimectl/Cargo.toml b/keylimectl/Cargo.toml new file mode 100644 index 000000000..b6d3db19f --- /dev/null +++ b/keylimectl/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "keylimectl" +description = "Command-line tool for Keylime remote attestation" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "keylimectl" +path = "src/main.rs" + +[features] +default = ["api-v2", "api-v3", "wizard"] +api-v2 = [] +api-v3 = [] +tpm-local = ["dep:tss-esapi"] +tpm-quote-validation = ["dep:tss-esapi"] +rpm-repo = ["dep:rpm", "dep:quick-xml", "dep:sequoia-openpgp"] +wizard = ["dep:dialoguer"] + +[dependencies] +anyhow.workspace = true +base64.workspace = true +chrono.workspace = true +clap.workspace = true +config.workspace = true +hex.workspace = true +keylime.workspace = true +log.workspace = true +openssl.workspace = true +pretty_env_logger.workspace = true +reqwest.workspace = true +reqwest-middleware.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = {workspace = true, features = ["rt-multi-thread"]} +tss-esapi = {workspace = true, optional = true} +uuid.workspace = true +dialoguer = { version = "0.12", optional = true } +rpm = { version = "0.19", optional = true, default-features = false } +quick-xml = { version = "0.41", optional = true } +sequoia-openpgp = { version = "2", optional = true, default-features = false, features = ["crypto-openssl"] } +flate2 = "1" +xz2 = "0.1" +bzip2 = "0.5" +zstd = { version = "0.13", default-features = false } +toml = "0.8" +zeroize = "1" +indicatif = "0.18" +rayon = "1" +console = "0.15" + +[lints.clippy] +all = "deny" +must_use_candidate = "warn" + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/keylimectl/keylimectl.conf b/keylimectl/keylimectl.conf new file mode 100644 index 000000000..085253e37 --- /dev/null +++ b/keylimectl/keylimectl.conf @@ -0,0 +1,231 @@ +# keylimectl Configuration File +# +# This file contains all available configuration options for keylimectl, +# the modern command-line tool for Keylime remote attestation. +# +# Configuration files are completely optional. keylimectl will work out-of-the-box +# with sensible defaults if no configuration file is provided. +# +# Configuration precedence (highest to lowest): +# 1. Command-line arguments +# 2. Environment variables (KEYLIME_*) +# 3. Configuration files (this file) +# 4. Default values +# +# This file uses TOML format. For more information about TOML syntax, +# see: https://toml.io/ + +# +# VERIFIER CONFIGURATION +# +# The verifier continuously monitors agent integrity and manages attestation policies. +# It receives attestation evidence from agents and verifies their trustworthiness. +# +[verifier] + +# IP address of the Keylime verifier service +# Default: "127.0.0.1" +# Environment variable: KEYLIME_VERIFIER__IP +ip = "127.0.0.1" + +# Port number of the Keylime verifier service +# Default: 8881 +# Environment variable: KEYLIME_VERIFIER__PORT +port = 8881 + +# Optional verifier identifier for multi-verifier deployments +# Default: None +# Environment variable: KEYLIME_VERIFIER__ID +# id = "verifier-1" + +# +# REGISTRAR CONFIGURATION +# +# The registrar maintains a database of registered agents and their TPM public keys. +# Agents must register with the registrar before they can be added to the verifier. +# +[registrar] + +# IP address of the Keylime registrar service +# Default: "127.0.0.1" +# Environment variable: KEYLIME_REGISTRAR__IP +ip = "127.0.0.1" + +# Port number of the Keylime registrar service +# Default: 8891 +# Environment variable: KEYLIME_REGISTRAR__PORT +port = 8891 + +# +# TLS/SSL SECURITY CONFIGURATION +# +# This section controls secure communication with Keylime services. +# Proper TLS configuration is essential for production deployments. +# +[tls] + +# Path to client certificate file for mutual TLS authentication +# Default: None (no client certificate) +# Environment variable: KEYLIME_TLS__CLIENT_CERT +client_cert = "/var/lib/keylime/cv_ca/client-cert.crt" + +# Path to client private key file for mutual TLS authentication +# Default: None (no client key) +# Environment variable: KEYLIME_TLS__CLIENT_KEY +client_key = "/var/lib/keylime/cv_ca/client-private.pem" + +# Password for encrypted client private key (if applicable) +# Default: None (no password) +# Environment variable: KEYLIME_TLS__CLIENT_KEY_PASSWORD +# client_key_password = "your-key-password" + +# List of trusted CA certificate file paths for server verification +# Default: [] (empty list - uses system CA store) +# Environment variable: KEYLIME_TLS__TRUSTED_CA (comma-separated) +trusted_ca = ["/var/lib/keylime/cv_ca/cacert.crt"] + +# Whether to verify server certificates +# Default: true +# Environment variable: KEYLIME_TLS__VERIFY_SERVER_CERT +# WARNING: Only disable for testing - never in production! +verify_server_cert = true + +# Whether to enable mutual TLS for agent communications +# Default: true +# Environment variable: KEYLIME_TLS__ENABLE_AGENT_MTLS +enable_agent_mtls = true + +# +# HTTP CLIENT CONFIGURATION +# +# This section controls HTTP client behavior including timeouts and retry logic. +# These settings affect reliability and performance of API communications. +# +[client] + +# Request timeout in seconds +# Default: 60 +# Environment variable: KEYLIME_CLIENT__TIMEOUT +timeout = 60 + +# Base retry interval in seconds +# Default: 1.0 +# Environment variable: KEYLIME_CLIENT__RETRY_INTERVAL +retry_interval = 1.0 + +# Whether to use exponential backoff for retries +# Default: true +# Environment variable: KEYLIME_CLIENT__EXPONENTIAL_BACKOFF +# When true, retry delays increase exponentially: 1s, 2s, 4s, 8s, etc. +# When false, retry delay remains constant at retry_interval +exponential_backoff = true + +# Maximum number of retry attempts +# Default: 3 +# Environment variable: KEYLIME_CLIENT__MAX_RETRIES +max_retries = 3 + +# +# EXAMPLE CONFIGURATIONS +# + +# Example 1: Production configuration with custom services +# [verifier] +# ip = "keylime-verifier.company.com" +# port = 8881 +# id = "prod-verifier-01" +# +# [registrar] +# ip = "keylime-registrar.company.com" +# port = 8891 +# +# [tls] +# client_cert = "/etc/keylime/certs/client.crt" +# client_key = "/etc/keylime/certs/client.key" +# trusted_ca = ["/etc/keylime/certs/ca.crt"] +# verify_server_cert = true +# enable_agent_mtls = true +# +# [client] +# timeout = 30 +# retry_interval = 2.0 +# exponential_backoff = true +# max_retries = 5 + +# Example 2: Development/testing configuration +# [verifier] +# ip = "192.168.1.100" +# port = 8881 +# +# [registrar] +# ip = "192.168.1.101" +# port = 8891 +# +# [tls] +# verify_server_cert = false # WARNING: Testing only! +# enable_agent_mtls = false # WARNING: Testing only! +# +# [client] +# timeout = 10 +# retry_interval = 0.5 +# max_retries = 1 + +# Example 3: IPv6 configuration +# [verifier] +# ip = "2001:db8::1" +# port = 8881 +# +# [registrar] +# ip = "2001:db8::2" +# port = 8891 + +# +# ENVIRONMENT VARIABLE REFERENCE +# +# All configuration options can be overridden using environment variables +# with the KEYLIME_ prefix and double underscores as section separators: +# +# KEYLIME_VERIFIER__IP=192.168.1.100 +# KEYLIME_VERIFIER__PORT=8881 +# KEYLIME_VERIFIER__ID=verifier-1 +# KEYLIME_REGISTRAR__IP=192.168.1.101 +# KEYLIME_REGISTRAR__PORT=8891 +# KEYLIME_TLS__CLIENT_CERT=/path/to/client.crt +# KEYLIME_TLS__CLIENT_KEY=/path/to/client.key +# KEYLIME_TLS__CLIENT_KEY_PASSWORD=password +# KEYLIME_TLS__TRUSTED_CA=/path/ca1.crt,/path/ca2.crt +# KEYLIME_TLS__VERIFY_SERVER_CERT=true +# KEYLIME_TLS__ENABLE_AGENT_MTLS=true +# KEYLIME_CLIENT__TIMEOUT=60 +# KEYLIME_CLIENT__RETRY_INTERVAL=1.0 +# KEYLIME_CLIENT__EXPONENTIAL_BACKOFF=true +# KEYLIME_CLIENT__MAX_RETRIES=3 + +# +# COMMAND-LINE ARGUMENT REFERENCE +# +# Configuration can also be overridden via command-line arguments: +# +# --verifier-ip Override verifier IP address +# --verifier-port Override verifier port +# --registrar-ip Override registrar IP address +# --registrar-port Override registrar port +# -c, --config Specify explicit configuration file path +# -v, --verbose Enable verbose logging +# -q, --quiet Suppress non-essential output +# --format Output format (json, table, yaml) + +# +# CONFIGURATION FILE LOCATIONS +# +# keylimectl searches for configuration files in this order: +# 1. Explicit path provided via -c/--config (required to exist) +# 2. ./keylimectl.toml (current directory) +# 3. ./keylimectl.conf (current directory) +# 4. /etc/keylime/keylimectl.conf (system-wide) +# 5. /usr/etc/keylime/keylimectl.conf (alternative system-wide) +# 6. ~/.config/keylime/keylimectl.conf (user-specific) +# 7. ~/.keylimectl.toml (user-specific) +# 8. $XDG_CONFIG_HOME/keylime/keylimectl.conf (XDG standard) +# +# If no configuration files are found, keylimectl works with defaults. diff --git a/keylimectl/src/api_versions.rs b/keylimectl/src/api_versions.rs new file mode 100644 index 000000000..a34b21a43 --- /dev/null +++ b/keylimectl/src/api_versions.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! API version constants — single source of truth for all clients +//! +//! Version lists are derived from enabled feature flags at compile time. +//! This module eliminates version constant duplication across the verifier, +//! registrar, and agent clients. + +/// All supported API versions for verifier and registrar communication. +/// +/// The array is conditionally compiled based on enabled features: +/// - `api-v2`: includes 2.0, 2.1, 2.2, 2.3 +/// - `api-v3`: includes 3.0 +/// - both (default): includes all versions +/// +/// Versions are ordered oldest to newest. Version detection iterates in +/// reverse (newest first) for optimal detection. +pub const SUPPORTED_API_VERSIONS: &[&str] = &[ + #[cfg(feature = "api-v2")] + "2.0", + #[cfg(feature = "api-v2")] + "2.1", + #[cfg(feature = "api-v2")] + "2.2", + #[cfg(feature = "api-v2")] + "2.3", + #[cfg(feature = "api-v3")] + "3.0", +]; + +/// Supported API versions for direct agent communication (pull model only). +/// +/// Only compiled when `api-v2` is enabled, since direct agent communication +/// is exclusively a pull-model operation. +#[cfg(feature = "api-v2")] +pub const SUPPORTED_AGENT_API_VERSIONS: &[&str] = &["2.0", "2.1", "2.2"]; + +/// Default API version used when version detection fails. +/// +/// When `api-v2` is enabled (with or without `api-v3`), defaults to "2.1" +/// for backward compatibility. When only `api-v3` is enabled, defaults +/// to "3.0". +pub const DEFAULT_API_VERSION: &str = if cfg!(feature = "api-v2") { + "2.1" +} else { + "3.0" +}; + +/// Parse a `"major.minor"` version string into a `(major, minor)` tuple. +/// +/// Returns `(2, 1)` as the fallback when the string is malformed, matching +/// `DEFAULT_API_VERSION`. Using integer tuples avoids floating-point +/// representation issues (e.g. `"2.10"` must compare greater than `"2.9"`). +#[must_use] +pub fn parse_version(s: &str) -> (u32, u32) { + let mut parts = s.splitn(2, '.'); + let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(2); + let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(1); + (major, minor) +} + +/// Check if a version string represents a v3.0+ API version. +/// +/// When the `api-v3` feature is disabled, this always returns `false` +/// so call sites can use a simple `if is_v3()` without `#[cfg]` blocks. +/// The compiler optimises the dead branch away. +#[must_use] +pub fn is_v3(version: &str) -> bool { + if cfg!(feature = "api-v3") { + parse_version(version).0 >= 3 + } else { + let _ = version; // suppress unused warning + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_supported_versions_not_empty() { + assert!( + !SUPPORTED_API_VERSIONS.is_empty(), + "At least one API version must be supported" + ); + } + + #[test] + fn test_supported_versions_ascending_order() { + for i in 1..SUPPORTED_API_VERSIONS.len() { + let prev = parse_version(SUPPORTED_API_VERSIONS[i - 1]); + let curr = parse_version(SUPPORTED_API_VERSIONS[i]); + assert!( + prev < curr, + "Versions must be in ascending order: {} >= {}", + SUPPORTED_API_VERSIONS[i - 1], + SUPPORTED_API_VERSIONS[i] + ); + } + } + + #[test] + fn test_default_api_version_is_supported() { + assert!( + SUPPORTED_API_VERSIONS.contains(&DEFAULT_API_VERSION), + "Default version {} must be in supported versions", + DEFAULT_API_VERSION + ); + } + + #[test] + fn test_is_v3() { + assert!(!is_v3("2.0")); + assert!(!is_v3("2.1")); + assert!(!is_v3("2.3")); + // When api-v3 is enabled, 3.x returns true; otherwise always false + assert_eq!(is_v3("3.0"), cfg!(feature = "api-v3")); + assert_eq!(is_v3("3.1"), cfg!(feature = "api-v3")); + assert!(!is_v3("invalid")); + } + + #[cfg(all(feature = "api-v2", feature = "api-v3"))] + #[test] + fn test_both_features_all_versions() { + assert_eq!( + SUPPORTED_API_VERSIONS, + &["2.0", "2.1", "2.2", "2.3", "3.0"] + ); + } + + #[cfg(all(feature = "api-v2", not(feature = "api-v3")))] + #[test] + fn test_v2_only_versions() { + assert_eq!(SUPPORTED_API_VERSIONS, &["2.0", "2.1", "2.2", "2.3"]); + } + + #[cfg(all(feature = "api-v3", not(feature = "api-v2")))] + #[test] + fn test_v3_only_versions() { + assert_eq!(SUPPORTED_API_VERSIONS, &["3.0"]); + } + + #[cfg(feature = "api-v2")] + #[test] + fn test_agent_api_versions() { + assert_eq!(SUPPORTED_AGENT_API_VERSIONS, &["2.0", "2.1", "2.2"]); + } +} diff --git a/keylimectl/src/client/agent.rs b/keylimectl/src/client/agent.rs new file mode 100644 index 000000000..2ea13a8af --- /dev/null +++ b/keylimectl/src/client/agent.rs @@ -0,0 +1,883 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Agent client for communicating with Keylime agents (API < 3.0 pull model) +//! +//! This module provides a client interface for interacting with Keylime agents +//! when using API versions less than 3.0, where agents act as complete web servers +//! (pull model). In this model, the tenant communicates directly with the agent +//! to perform attestation operations like TPM quote retrieval, key delivery, +//! and verification. +//! +//! # API Version Support +//! +//! This client is designed for API versions < 3.0 where: +//! - Agents run as HTTP servers listening on a port +//! - Tenant connects directly to agent for attestation +//! - Agent provides endpoints for quotes, keys, and verification +//! +//! For API >= 3.0 (push model), agents connect to the verifier instead. +//! +//! # Agent Endpoints +//! +//! The client supports these agent endpoints: +//! - `GET /v{version}/quotes/identity?nonce={nonce}` - Get TPM quote +//! - `POST /v{version}/keys/ukey` - Deliver encrypted U key and payload +//! - `GET /v{version}/keys/verify?challenge={challenge}` - Verify key derivation +//! +//! # Security +//! +//! - Supports mutual TLS authentication with agent certificates +//! - Validates TPM quotes against agent's AIK +//! - Encrypts sensitive keys before transmission +//! - Provides HMAC-based verification of key derivation +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::client::agent::AgentClient; +//! use keylimectl::config::Config; +//! +//! # async fn example() -> Result<(), Box> { +//! let config = Config::default(); +//! let client = AgentClient::new("192.168.1.100", 9002, &config).await?; +//! +//! // Get TPM quote +//! let nonce = "random_nonce_12345"; +//! let quote_response = client.get_quote(nonce).await?; +//! +//! // Deliver encrypted key +//! let encrypted_key = b"encrypted_u_key_data"; +//! let auth_tag = "authentication_tag"; +//! client.deliver_key(encrypted_key, auth_tag, None).await?; +//! +//! // Verify key derivation +//! let challenge = "verification_challenge"; +//! let is_valid = client.verify_key_derivation(challenge, "expected_hmac").await?; +//! # Ok(()) +//! # } +//! ``` + +use crate::client::base::BaseClient; +use crate::config::Config; +use crate::error::{ErrorContext, KeylimectlError}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use log::{debug, info, warn}; +use reqwest::{Method, StatusCode}; +use serde_json::{json, Value}; + +use crate::api_versions::SUPPORTED_AGENT_API_VERSIONS; + +/// Response structure for agent version endpoint +#[derive(serde::Deserialize, Debug)] +struct AgentVersionResponse { + #[allow(dead_code)] + code: serde_json::Number, + #[allow(dead_code)] + status: String, + results: AgentVersionResults, +} + +/// Agent version results structure +#[derive(serde::Deserialize, Debug)] +struct AgentVersionResults { + supported_version: String, +} + +/// Client for communicating with Keylime agents in pull model (API < 3.0) +/// +/// The `AgentClient` provides direct communication with Keylime agents when +/// using API versions less than 3.0. In this model, agents run as HTTP servers +/// and the tenant connects directly to them for attestation operations. +/// +/// # Deprecation Notice +/// +/// This client is designed for the legacy pull model and should be considered +/// deprecated for new deployments. The push model (API >= 3.0) is recommended +/// for new installations. +/// +/// # Connection Management +/// +/// The client maintains a persistent HTTP connection pool and automatically +/// handles connection failures with exponential backoff retry logic. +/// +/// # Thread Safety +/// +/// `AgentClient` is thread-safe and can be shared across multiple tasks +/// or threads using `Arc`. +#[derive(Debug)] +pub struct AgentClient { + base: BaseClient, + api_version: String, + agent_ip: String, + agent_port: u16, +} + +/// Builder for creating AgentClient instances with flexible configuration +/// +/// The `AgentClientBuilder` provides a fluent interface for configuring +/// and creating `AgentClient` instances. It allows for optional API version +/// detection and custom API version specification. +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::client::agent::AgentClient; +/// use keylimectl::config::Config; +/// +/// # async fn example() -> Result<(), Box> { +/// let config = Config::default(); +/// +/// // Create client with automatic version detection +/// let client = AgentClient::builder() +/// .agent_ip("192.168.1.100") +/// .agent_port(9002) +/// .config(&config) +/// .build() +/// .await?; +/// +/// // Create client without version detection (for testing) +/// let client = AgentClient::builder() +/// .agent_ip("192.168.1.100") +/// .agent_port(9002) +/// .config(&config) +/// .skip_version_detection() +/// .build_sync()?; +/// +/// // Create client with specific API version +/// let client = AgentClient::builder() +/// .agent_ip("192.168.1.100") +/// .agent_port(9002) +/// .config(&config) +/// .api_version("2.0") +/// .skip_version_detection() +/// .build_sync()?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct AgentClientBuilder<'a> { + agent_ip: Option, + agent_port: Option, + config: Option<&'a Config>, + agent_cert_pem: Option, +} + +impl<'a> AgentClientBuilder<'a> { + /// Create a new builder instance + pub fn new() -> Self { + Self { + agent_ip: None, + agent_port: None, + config: None, + agent_cert_pem: None, + } + } + + /// Set the agent IP address + pub fn agent_ip>(mut self, ip: S) -> Self { + self.agent_ip = Some(ip.into()); + self + } + + /// Set the agent port + pub fn agent_port(mut self, port: u16) -> Self { + self.agent_port = Some(port); + self + } + + /// Set the configuration for the client + pub fn config(mut self, config: &'a Config) -> Self { + self.config = Some(config); + self + } + + /// Set the agent's mTLS certificate (PEM) from the registrar. + /// + /// When provided, this certificate is added as a trusted root CA + /// so the agent's self-signed TLS certificate can be verified + /// without disabling certificate verification globally. + /// + /// A value of `"disabled"` is treated as `None`. + pub fn agent_cert(mut self, cert_pem: Option<&str>) -> Self { + self.agent_cert_pem = + cert_pem.filter(|c| *c != "disabled").map(|c| c.to_string()); + self + } + + /// Build the AgentClient with automatic API version detection + /// + /// This is the recommended way to create a client for production use, + /// as it will automatically detect the optimal API version supported + /// by the agent. + pub async fn build(self) -> Result { + let agent_ip = self.agent_ip.ok_or_else(|| { + KeylimectlError::validation( + "Agent IP is required for AgentClient", + ) + })?; + let agent_port = self.agent_port.ok_or_else(|| { + KeylimectlError::validation( + "Agent port is required for AgentClient", + ) + })?; + let config = self.config.ok_or_else(|| { + KeylimectlError::validation( + "Configuration is required for AgentClient", + ) + })?; + + AgentClient::new( + &agent_ip, + agent_port, + config, + self.agent_cert_pem.as_deref(), + ) + .await + } +} + +impl<'a> Default for AgentClientBuilder<'a> { + fn default() -> Self { + Self::new() + } +} + +impl AgentClient { + /// Create a new builder for configuring an AgentClient + /// + /// This is the recommended way to create AgentClient instances, + /// as it provides a flexible interface for configuration. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::agent::AgentClient; + /// use keylimectl::config::Config; + /// + /// # async fn example() -> Result<(), Box> { + /// let config = Config::default(); + /// let client = AgentClient::builder() + /// .agent_ip("192.168.1.100") + /// .agent_port(9002) + /// .config(&config) + /// .build() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn builder() -> AgentClientBuilder<'static> { + AgentClientBuilder::new() + } + /// Create a new agent client with automatic API version detection + /// + /// Initializes a new `AgentClient` for communicating with the specified agent + /// and automatically detects the best API version to use. + /// + /// # Arguments + /// + /// * `agent_ip` - IP address of the agent + /// * `agent_port` - Port number the agent is listening on + /// * `config` - Configuration containing TLS and client settings + /// + /// # Returns + /// + /// Returns a configured `AgentClient` with detected API version. + /// + /// # Errors + /// + /// This method can fail if: + /// - TLS certificate files cannot be read + /// - Certificate/key files are invalid + /// - HTTP client initialization fails + /// - Version detection fails (falls back to default version) + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::agent::AgentClient; + /// use keylimectl::config::Config; + /// + /// # async fn example() -> Result<(), Box> { + /// let config = Config::default(); + /// let client = AgentClient::new("192.168.1.100", 9002, &config).await?; + /// println!("Agent client created for {}:{}", "192.168.1.100", 9002); + /// # Ok(()) + /// # } + /// ``` + pub async fn new( + agent_ip: &str, + agent_port: u16, + config: &Config, + agent_cert_pem: Option<&str>, + ) -> Result { + let mut client = Self::new_without_version_detection( + agent_ip, + agent_port, + config, + agent_cert_pem, + )?; + + client.detect_api_version().await.map_err(|e| { + KeylimectlError::Client( + crate::client::error::ClientError::Configuration { + message: format!( + "Failed to detect agent API version: {e}" + ), + }, + ) + })?; + + Ok(client) + } + + /// Create a new agent client without API version detection + /// + /// Initializes a new `AgentClient` with the provided configuration + /// using the default API version without attempting to detect the + /// agent's supported version. This is mainly useful for testing. + /// + /// # Arguments + /// + /// * `agent_ip` - IP address of the agent + /// * `agent_port` - Port number the agent is listening on + /// * `config` - Configuration containing TLS and client settings + /// + /// # Returns + /// + /// Returns a configured `AgentClient` with default API version. + pub(crate) fn new_without_version_detection( + agent_ip: &str, + agent_port: u16, + config: &Config, + agent_cert_pem: Option<&str>, + ) -> Result { + let base_url = if agent_ip.contains(':') && !agent_ip.starts_with('[') + { + // IPv6 address without brackets + format!("https://[{agent_ip}]:{agent_port}") + } else if agent_ip.starts_with('[') && agent_ip.ends_with(']') { + // IPv6 address with brackets + format!("https://{agent_ip}:{agent_port}") + } else { + // IPv4 address or hostname + format!("https://{agent_ip}:{agent_port}") + }; + + let base = BaseClient::new(base_url, config, agent_cert_pem) + .map_err(KeylimectlError::from)?; + + Ok(Self { + base, + api_version: crate::api_versions::DEFAULT_API_VERSION.to_string(), + agent_ip: agent_ip.to_string(), + agent_port, + }) + } + + /// Auto-detect and set the API version + /// + /// Attempts to determine the agent's API version by first trying the `/version` endpoint + /// and then falling back to testing each API version individually if needed. + /// + /// # Returns + /// + /// Returns `Ok(())` if version detection succeeded or failed gracefully. + /// Returns `Err()` only for critical errors that prevent client operation. + /// + /// # Behavior + /// + /// 1. First try the `/version` endpoint to get the supported_version + /// 2. If `/version` fails, fall back to testing each API version from newest to oldest + /// 3. On success, caches the detected version for future requests + /// 4. On complete failure, leaves default version unchanged + async fn detect_api_version(&mut self) -> Result<(), KeylimectlError> { + info!("Starting agent API version detection"); + + // Step 1: Try the /version endpoint first + match self.get_agent_api_version().await { + Ok(version) => { + info!("Successfully detected agent API version from /version endpoint: {version}"); + self.api_version = version; + return Ok(()); + } + Err(e) => { + debug!("Failed to get version from /version endpoint ({e}), falling back to version probing"); + } + } + + // Step 2: Fall back to testing each version individually (newest to oldest) + info!("Falling back to individual version testing"); + for &api_version in SUPPORTED_AGENT_API_VERSIONS.iter().rev() { + debug!("Testing agent API version {api_version}"); + + // Test this version by making a simple request (quotes endpoint with dummy nonce) + if self.test_api_version(api_version).await.is_ok() { + info!( + "Successfully detected agent API version: {api_version}" + ); + self.api_version = api_version.to_string(); + return Ok(()); + } + } + + // If all versions failed, continue with default version + warn!( + "Could not detect agent API version, using default: {}", + self.api_version + ); + Ok(()) + } + + /// Get the agent API version from the '/version' endpoint + /// + /// Attempts to retrieve the agent's supported API version using the `/version` endpoint. + /// The expected response format is: + /// ```json + /// { + /// "code": 200, + /// "status": "Success", + /// "results": { + /// "supported_version": "2.2" + /// } + /// } + /// ``` + async fn get_agent_api_version(&self) -> Result { + let url = format!("{}/version", self.base.base_url); + + info!("Requesting agent API version from {url}"); + debug!("GET {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + format!("Failed to send version request to agent at {url}") + })?; + + if !response.status().is_success() { + return Err(KeylimectlError::api_error( + response.status().as_u16(), + "Agent does not support the /version endpoint".to_string(), + None, + )); + } + + let resp: AgentVersionResponse = + response.json().await.with_context(|| { + "Failed to parse version response from agent".to_string() + })?; + + Ok(resp.results.supported_version) + } + + /// Test if a specific API version works by making a simple request + async fn test_api_version( + &self, + api_version: &str, + ) -> Result<(), KeylimectlError> { + let url = format!( + "{}/v{}/quotes/identity?nonce=test", + self.base.base_url, api_version + ); + + debug!("Testing agent API version {api_version} with URL: {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + format!("Failed to test API version {api_version}") + })?; + + if response.status().is_success() + || response.status() == StatusCode::BAD_REQUEST + { + // Accept 400 as well since the test nonce might be rejected but the endpoint exists + Ok(()) + } else { + Err(KeylimectlError::api_error( + response.status().as_u16(), + format!("API version {api_version} not supported"), + None, + )) + } + } + + /// Get TPM quote from the agent + /// + /// Requests a TPM quote from the agent using the provided nonce. + /// This is used during the attestation process to verify the agent's + /// TPM state and integrity. + /// + /// # Arguments + /// + /// * `nonce` - Random nonce to include in the quote for freshness + /// + /// # Returns + /// + /// Returns JSON containing: + /// - `quote`: Base64-encoded TPM quote + /// - `pubkey`: Agent's public key for verification + /// - `tpm_version`: TPM version information + /// + /// # Errors + /// + /// This method can fail if: + /// - Agent is not reachable + /// - Agent rejects the nonce + /// - TPM quote generation fails + /// - Network communication fails + /// + /// # Examples + /// + /// ```rust + /// # use keylimectl::client::agent::AgentClient; + /// # async fn example(client: &AgentClient) -> Result<(), Box> { + /// let nonce = "random_nonce_value_12345"; + /// let quote_response = client.get_quote(nonce).await?; + /// + /// if let Some(quote) = quote_response["results"]["quote"].as_str() { + /// println!("Received TPM quote: {}", quote); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_quote( + &self, + nonce: &str, + ) -> Result { + debug!( + "Getting TPM quote from agent {}:{} with nonce: {}", + self.agent_ip, self.agent_port, nonce + ); + + let url = format!( + "{}/v{}/quotes/identity?nonce={}", + self.base.base_url, self.api_version, nonce + ); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send quote request to agent".to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Deliver encrypted U key and optional payload to the agent + /// + /// Sends the encrypted U key (and optionally a payload) to the agent + /// after successful TPM quote verification. The U key is encrypted + /// with the agent's public key before transmission. + /// + /// # Arguments + /// + /// * `encrypted_key` - Base64-encoded encrypted U key + /// * `auth_tag` - Authentication tag for the key + /// * `payload` - Optional payload to deliver to the agent + /// + /// # Returns + /// + /// Returns the agent's response confirming key delivery. + /// + /// # Errors + /// + /// This method can fail if: + /// - Agent is not reachable + /// - Key format is invalid + /// - Agent rejects the key or payload + /// - Network communication fails + /// + /// # Examples + /// + /// ```rust + /// # use keylimectl::client::agent::AgentClient; + /// # async fn example(client: &AgentClient) -> Result<(), Box> { + /// let encrypted_key = b"base64_encoded_encrypted_key"; + /// let auth_tag = "authentication_tag_value"; + /// let payload = Some("configuration_data".to_string()); + /// + /// let result = client.deliver_key(encrypted_key, auth_tag, payload.as_deref()).await?; + /// println!("Key delivered successfully: {:?}", result); + /// # Ok(()) + /// # } + /// ``` + pub async fn deliver_key( + &self, + encrypted_key: &[u8], + auth_tag: &str, + payload: Option<&str>, + ) -> Result { + debug!( + "Delivering encrypted U key to agent {}:{}", + self.agent_ip, self.agent_port + ); + + let url = + format!("{}/v{}/keys/ukey", self.base.base_url, self.api_version); + + let mut data = json!({ + "encrypted_key": STANDARD.encode(encrypted_key), + "auth_tag": auth_tag + }); + + // Add payload if provided + if let Some(payload_data) = payload { + data["payload"] = json!(payload_data); + } + + let response = self + .base + .client + .get_json_request_from_struct(Method::POST, &url, &data, None) + .map_err(KeylimectlError::Json)? + .send() + .await + .with_context(|| { + "Failed to send key delivery request to agent".to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Verify key derivation using HMAC challenge + /// + /// Sends a challenge to the agent to verify that it can correctly + /// derive keys using the delivered U key. The agent should respond + /// with an HMAC of the challenge computed using the derived key. + /// + /// # Arguments + /// + /// * `challenge` - Random challenge string + /// * `expected_hmac` - Expected HMAC value for verification + /// + /// # Returns + /// + /// Returns `true` if the agent's HMAC matches the expected value, + /// `false` otherwise. + /// + /// # Errors + /// + /// This method can fail if: + /// - Agent is not reachable + /// - Agent cannot derive the key + /// - Network communication fails + /// - Response format is invalid + /// + /// # Examples + /// + /// ```rust + /// # use keylimectl::client::agent::AgentClient; + /// # async fn example(client: &AgentClient) -> Result<(), Box> { + /// let challenge = "random_challenge_12345"; + /// let expected_hmac = "computed_hmac_value"; + /// + /// let is_valid = client.verify_key_derivation(challenge, expected_hmac).await?; + /// if is_valid { + /// println!("Key derivation verified successfully"); + /// } else { + /// println!("Key derivation verification failed"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn verify_key_derivation( + &self, + challenge: &str, + expected_hmac: &str, + ) -> Result { + debug!( + "Verifying key derivation with agent {}:{}", + self.agent_ip, self.agent_port + ); + + let url = format!( + "{}/v{}/keys/verify?challenge={}", + self.base.base_url, self.api_version, challenge + ); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send verification request to agent".to_string() + })?; + + let response_json = self.base.handle_response(response).await?; + + // Extract HMAC from response and compare + if let Some(results) = response_json.get("results") { + if let Some(hmac) = results.get("hmac").and_then(|v| v.as_str()) { + return Ok(openssl::memcmp::eq( + hmac.as_bytes(), + expected_hmac.as_bytes(), + )); + } + } + + Err(KeylimectlError::validation( + "Invalid verification response format from agent", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{AgentConfig, ClientConfig, TlsConfig}; + + /// Create a test configuration + fn create_test_config() -> Config { + Config { + loaded_from: None, + cli_overrides: crate::config::CliOverrides::default(), + verifier: crate::config::VerifierConfig::default(), + registrar: crate::config::RegistrarConfig::default(), + tls: TlsConfig { + client_cert: None, + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: false, // Disable for testing + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }, + client: ClientConfig { + timeout: 30, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + }, + agent: AgentConfig::default(), + } + } + + #[test] + fn test_agent_client_new() { + let config = create_test_config(); + let result = AgentClient::new_without_version_detection( + "127.0.0.1", + 9002, + &config, + None, + ); + + assert!(result.is_ok()); + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://127.0.0.1:9002"); + assert_eq!(client.api_version, "2.1"); + assert_eq!(client.agent_ip, "127.0.0.1"); + assert_eq!(client.agent_port, 9002); + } + + #[test] + fn test_agent_client_ipv6() { + let config = create_test_config(); + + // Test IPv6 without brackets + let result = AgentClient::new_without_version_detection( + "::1", 9002, &config, None, + ); + assert!(result.is_ok()); + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://[::1]:9002"); + + // Test IPv6 with brackets + let result = AgentClient::new_without_version_detection( + "[2001:db8::1]", + 9002, + &config, + None, + ); + assert!(result.is_ok()); + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://[2001:db8::1]:9002"); + } + + #[test] + fn test_supported_api_versions() { + // Verify our supported versions are all < 3.0 + for &version in SUPPORTED_AGENT_API_VERSIONS { + let parsed: f32 = + version.parse().expect("Version should be parseable"); + assert!( + parsed < 3.0, + "Agent API version {version} should be < 3.0" + ); + } + + // Verify versions are in ascending order + for i in 1..SUPPORTED_AGENT_API_VERSIONS.len() { + let prev: f32 = + SUPPORTED_AGENT_API_VERSIONS[i - 1].parse().unwrap(); //#[allow_ci] + let curr: f32 = SUPPORTED_AGENT_API_VERSIONS[i].parse().unwrap(); //#[allow_ci] + assert!(prev < curr, "API versions should be in ascending order"); + } + } + + #[test] + fn test_base_url_construction() { + let config = create_test_config(); + + // IPv4 + let client = AgentClient::new_without_version_detection( + "192.168.1.100", + 9002, + &config, + None, + ) + .unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://192.168.1.100:9002"); + + // IPv6 without brackets + let client = AgentClient::new_without_version_detection( + "2001:db8::1", + 9002, + &config, + None, + ) + .unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://[2001:db8::1]:9002"); + + // IPv6 with brackets + let client = AgentClient::new_without_version_detection( + "[2001:db8::1]", + 9002, + &config, + None, + ) + .unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://[2001:db8::1]:9002"); + + // Hostname + let client = AgentClient::new_without_version_detection( + "agent.example.com", + 9002, + &config, + None, + ) + .unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://agent.example.com:9002"); + } +} diff --git a/keylimectl/src/client/base.rs b/keylimectl/src/client/base.rs new file mode 100644 index 000000000..7cf6abe65 --- /dev/null +++ b/keylimectl/src/client/base.rs @@ -0,0 +1,472 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Base client functionality shared across all Keylime service clients +//! +//! This module provides shared HTTP client creation and TLS configuration logic +//! that is used by all service-specific clients (verifier, registrar, agent). +//! This eliminates code duplication and ensures consistent behavior across clients. + +use crate::client::error::{ApiResponseError, ClientError, TlsError}; +use crate::config::Config; +use keylime::resilient_client::ResilientClient; +use log::{debug, warn}; +use reqwest::StatusCode; +use serde_json::Value; +use std::time::Duration; + +/// Validate that an agent identifier is safe for use in URL paths. +/// +/// Rejects characters that could cause path traversal, query injection, +/// or fragment injection when interpolated into URLs. +pub fn validate_agent_id(agent_id: &str) -> Result<(), ClientError> { + if agent_id.is_empty() { + return Err(ClientError::Configuration { + message: "Agent ID must not be empty".to_string(), + }); + } + if !agent_id.chars().all(|c| { + c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' + }) { + return Err(ClientError::Configuration { + message: format!( + "Agent ID contains invalid characters: '{agent_id}'. \ + Only alphanumeric characters, '.', '_', and '-' are allowed." + ), + }); + } + Ok(()) +} + +/// Base HTTP client functionality shared across all service clients +/// +/// This structure encapsulates the common HTTP client setup and TLS configuration +/// logic that is used by all Keylime service clients. It provides a consistent +/// foundation for secure communication with Keylime services. +/// +/// # Features +/// +/// - **TLS Configuration**: Mutual TLS with client certificates +/// - **Retry Logic**: Exponential backoff with configurable retries +/// - **Connection Pooling**: Persistent HTTP connections for performance +/// - **Security**: Proper certificate validation and verification +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::client::base::BaseClient; +/// use keylimectl::config::Config; +/// +/// # fn example() -> Result<(), Box> { +/// let config = Config::default(); +/// let base_url = "https://localhost:8881".to_string(); +/// let base_client = BaseClient::new(base_url, &config)?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct BaseClient { + /// The underlying resilient HTTP client + pub client: ResilientClient, + /// Base URL for the service + pub base_url: String, +} + +impl BaseClient { + /// Create a new base client with the specified configuration + /// + /// Initializes a new HTTP client with TLS configuration, retry logic, + /// and connection pooling based on the provided configuration. + /// + /// # Arguments + /// + /// * `base_url` - Base URL for the service (e.g., "https://localhost:8881") + /// * `config` - Configuration containing TLS and client settings + /// + /// # Returns + /// + /// Returns a configured `BaseClient` ready for HTTP communication. + /// + /// # Errors + /// + /// This method can fail if: + /// - TLS certificate files cannot be read + /// - Certificate/key files are invalid + /// - HTTP client initialization fails + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::base::BaseClient; + /// use keylimectl::config::Config; + /// + /// # fn example() -> Result<(), Box> { + /// let config = Config::default(); + /// let base_url = config.verifier_base_url(); + /// let client = BaseClient::new(base_url, &config)?; + /// # Ok(()) + /// # } + /// ``` + pub fn new( + base_url: String, + config: &Config, + agent_cert_pem: Option<&str>, + ) -> Result { + debug!("Creating BaseClient for {base_url} with TLS config: verify_server_cert={}, client_cert={:?}, client_key={:?}", + config.tls.verify_server_cert, config.tls.client_cert, config.tls.client_key); + + // Create HTTP client with TLS configuration + let mut builder = Self::create_http_client_builder(config)?; + + // Add agent's self-signed cert as trusted CA for pull-model + // connections. The cert comes from the registrar database. + if let Some(pem) = agent_cert_pem { + let cert = reqwest::Certificate::from_pem(pem.as_bytes()) + .map_err(|e| { + ClientError::Tls(TlsError::configuration(format!( + "Failed to parse agent mTLS certificate: {e}" + ))) + })?; + builder = builder.add_root_certificate(cert); + debug!("Added agent mTLS certificate as trusted root"); + } + + let http_client = builder.build().map_err(|e| { + ClientError::configuration(format!( + "Failed to create HTTP client: {e}" + )) + })?; + + // Create resilient client with retry logic + let client = ResilientClient::new( + Some(http_client), + Duration::from_secs(1), // Initial delay + config.client.max_retries, + &[ + StatusCode::OK, + StatusCode::CREATED, + StatusCode::ACCEPTED, + StatusCode::NO_CONTENT, + ], + Some(Duration::from_secs(60)), // Max delay + ); + + Ok(Self { client, base_url }) + } + + /// Build a `reqwest::ClientBuilder` with TLS settings from config. + /// + /// Returns the builder *before* calling `.build()` so callers can + /// inject extra root certificates (e.g. the agent's self-signed + /// mTLS cert) before finalizing the client. + fn create_http_client_builder( + config: &Config, + ) -> Result { + debug!("Creating HTTP client with TLS config: verify_server_cert={}, client_cert={:?}, client_key={:?}, trusted_ca={:?}", + config.tls.verify_server_cert, config.tls.client_cert, config.tls.client_key, config.tls.trusted_ca); + + let mut builder = reqwest::Client::builder() + .timeout(Duration::from_secs(config.client.timeout)) + .min_tls_version(reqwest::tls::Version::TLS_1_2) + .danger_accept_invalid_hostnames( + config.tls.accept_invalid_hostnames, + ); + + if config.tls.accept_invalid_hostnames { + warn!( + "TLS hostname verification is disabled. \ + Set tls.accept_invalid_hostnames = false for stricter security." + ); + } + + // Configure TLS + if !config.tls.verify_server_cert { + builder = builder.danger_accept_invalid_certs(true); + warn!("Server certificate verification is disabled"); + } + + // Add trusted CA certificates for server verification + debug!( + "Attempting to load {} trusted CA certificate(s)", + config.tls.trusted_ca.len() + ); + let mut loaded_cas = 0; + for ca_path in &config.tls.trusted_ca { + debug!("Checking CA certificate: {ca_path}"); + if std::path::Path::new(ca_path).exists() { + debug!("CA certificate file exists, attempting to load: {ca_path}"); + let ca_cert = std::fs::read(ca_path).map_err(|e| { + ClientError::Tls(TlsError::ca_certificate_file( + ca_path, + format!("Failed to read file: {e}"), + )) + })?; + + let ca_cert = reqwest::Certificate::from_pem(&ca_cert) + .map_err(|e| { + ClientError::Tls(TlsError::ca_certificate_file( + ca_path, + format!("Failed to parse PEM: {e}"), + )) + })?; + + builder = builder.add_root_certificate(ca_cert); + loaded_cas += 1; + debug!("Successfully loaded CA certificate: {ca_path}"); + } else if config.tls.verify_server_cert { + return Err(ClientError::Tls(TlsError::certificate_file( + ca_path, + "Trusted CA certificate file not found (required because verify_server_cert is enabled)" + .to_string(), + ))); + } else { + debug!("Skipping missing CA certificate (verify_server_cert is disabled): {ca_path}"); + } + } + debug!( + "Loaded {loaded_cas} CA certificate(s) for server verification" + ); + + // Add client certificate if configured + if let (Some(cert_path), Some(key_path)) = + (&config.tls.client_cert, &config.tls.client_key) + { + let cert = std::fs::read(cert_path).map_err(|e| { + ClientError::Tls(TlsError::certificate_file( + cert_path, + format!("Failed to read file: {e}"), + )) + })?; + + let key = std::fs::read(key_path).map_err(|e| { + ClientError::Tls(TlsError::private_key_file( + key_path, + format!("Failed to read file: {e}"), + )) + })?; + + // Parse the private key with OpenSSL (handles PKCS#8, SEC1/EC, + // and traditional RSA formats) then re-encode as PKCS#8 PEM + // which is what reqwest::Identity::from_pkcs8_pem requires. + let pkey = openssl::pkey::PKey::private_key_from_pem(&key) + .map_err(|e| { + ClientError::Tls(TlsError::configuration(format!( + "Failed to parse private key from {key_path}: {e}" + ))) + })?; + let pkcs8_key = pkey.private_key_to_pem_pkcs8() + .map_err(|e| ClientError::Tls(TlsError::configuration( + format!("Failed to convert private key to PKCS#8 from {key_path}: {e}") + )))?; + + let identity = reqwest::Identity::from_pkcs8_pem(&cert, &pkcs8_key) + .map_err(|e| ClientError::Tls(TlsError::configuration( + format!("Failed to create client identity from cert {cert_path} and key {key_path}: {e}") + )))?; + + debug!("Successfully created TLS identity from cert {cert_path} and key {key_path}"); + + builder = builder.identity(identity); + } + + Ok(builder) + } + + /// Create HTTP client with TLS configuration (convenience wrapper). + /// + /// Equivalent to `create_http_client_builder(config)?.build()`. + #[cfg(test)] + pub fn create_http_client( + config: &Config, + ) -> Result { + Self::create_http_client_builder(config)? + .build() + .map_err(|e| { + ClientError::configuration(format!( + "Failed to create HTTP client: {e}" + )) + }) + } + + /// Handle HTTP response and convert to JSON + /// + /// Processes HTTP responses from Keylime services, handling both + /// success and error cases. Converts successful responses to JSON + /// and transforms HTTP errors into appropriate `ClientError` types. + /// + /// # Arguments + /// + /// * `response` - HTTP response from a Keylime service + /// + /// # Returns + /// + /// Returns parsed JSON data for successful responses. + /// + /// # Response Handling + /// + /// - **2xx responses**: Parsed as JSON or default success object + /// - **4xx/5xx responses**: Converted to `ClientError::Api` with details + /// - **Empty responses**: Returns `{"status": "success"}` + /// - **Invalid JSON**: Returns parsing error with response text + /// + /// # Error Details + /// + /// For error responses, attempts to extract meaningful error messages + /// from the JSON response body, falling back to HTTP status descriptions. + /// + /// # Errors + /// + /// This method can fail if: + /// - Response body cannot be read + /// - Response contains invalid JSON + /// - Service returns an error status code + pub async fn handle_response( + &self, + response: reqwest::Response, + ) -> Result { + let status = response.status(); + let response_text = + response.text().await.map_err(ClientError::Network)?; + + match status { + StatusCode::OK + | StatusCode::CREATED + | StatusCode::ACCEPTED + | StatusCode::NO_CONTENT => { + if response_text.is_empty() { + Ok(serde_json::json!({"status": "success"})) + } else { + serde_json::from_str(&response_text) + .map_err(ClientError::Json) + } + } + _ => { + let error_message = if response_text.is_empty() { + format!("HTTP {} error", status.as_u16()) + } else { + // Try to parse as JSON for better error message + match serde_json::from_str::(&response_text) { + Ok(json_error) => json_error + .get("status") + .or_else(|| json_error.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or(&response_text) + .to_string(), + Err(_) => response_text.clone(), + } + }; + + // Try to parse the response as JSON for additional context + let response_json = serde_json::from_str(&response_text).ok(); + + Err(ClientError::Api(ApiResponseError::ServerError { + status: status.as_u16(), + message: error_message, + response: response_json, + })) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + AgentConfig, ClientConfig, RegistrarConfig, TlsConfig, VerifierConfig, + }; + + /// Create a test configuration for base client testing + fn create_test_config() -> Config { + Config { + loaded_from: None, + cli_overrides: crate::config::CliOverrides::default(), + verifier: VerifierConfig { + ip: "127.0.0.1".to_string(), + port: 8881, + id: Some("test-verifier".to_string()), + }, + registrar: RegistrarConfig::default(), + tls: TlsConfig { + client_cert: None, + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: false, // Disable for testing + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }, + client: ClientConfig { + timeout: 30, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + }, + agent: AgentConfig::default(), + } + } + + #[test] + fn test_base_client_new() { + let config = create_test_config(); + let base_url = "https://127.0.0.1:8881".to_string(); + let result = BaseClient::new(base_url.clone(), &config, None); + + assert!(result.is_ok()); + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base_url, base_url); + } + + #[test] + fn test_create_http_client_basic() { + let config = create_test_config(); + let result = BaseClient::create_http_client(&config); + + assert!(result.is_ok()); + // Basic validation that client was created + let _client = result.unwrap(); //#[allow_ci] + } + + #[test] + fn test_create_http_client_with_timeout() { + let mut config = create_test_config(); + config.client.timeout = 60; + + let result = BaseClient::create_http_client(&config); + assert!(result.is_ok()); + } + + #[test] + fn test_create_http_client_with_cert_files_nonexistent() { + let mut config = create_test_config(); + config.tls.client_cert = Some("/nonexistent/cert.pem".to_string()); + config.tls.client_key = Some("/nonexistent/key.pem".to_string()); + + let result = BaseClient::create_http_client(&config); + // Should fail because cert files don't exist + assert!(result.is_err()); + + let error = result.unwrap_err(); + assert!(error.to_string().contains("Certificate file error")); + } + + #[test] + fn test_tls_config_no_verification() { + let mut config = create_test_config(); + config.tls.verify_server_cert = false; + + let result = BaseClient::create_http_client(&config); + assert!(result.is_ok()); + // Client should be created successfully with verification disabled + } + + #[test] + fn test_tls_config_with_verification() { + let mut config = create_test_config(); + config.tls.verify_server_cert = true; + + let result = BaseClient::create_http_client(&config); + assert!(result.is_ok()); + // Client should be created successfully with verification enabled + } +} diff --git a/keylimectl/src/client/error.rs b/keylimectl/src/client/error.rs new file mode 100644 index 000000000..e2782dbdd --- /dev/null +++ b/keylimectl/src/client/error.rs @@ -0,0 +1,229 @@ +//! Client-specific error types for keylimectl +//! +//! This module provides error types specific to HTTP client operations, +//! including network errors, API errors, and client configuration issues. +//! These errors can be converted to the main `KeylimectlError` type for +//! user-facing error messages. +//! +//! # Error Types +//! +//! - [`ClientError`] - Main error type for client operations +//! - [`ApiResponseError`] - Specific API response parsing errors +//! - [`TlsError`] - TLS/SSL configuration and connection errors +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::client::error::{ClientError, ApiResponseError}; +//! +//! // Create an API error +//! let api_err = ClientError::Api(ApiResponseError::InvalidStatus { +//! status: 404, +//! message: "Not found".to_string() +//! }); +//! +//! // Create a network error +//! let network_err = ClientError::network("Connection timeout"); +//! ``` + +use serde_json::Value; +use thiserror::Error; + +/// Client-specific error types +/// +/// This enum covers all error conditions that can occur during HTTP client operations, +/// from network connectivity issues to API response parsing problems. +#[derive(Error, Debug)] +pub enum ClientError { + /// Network/HTTP errors from reqwest + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + /// Request middleware errors + #[error("Request middleware error: {0}")] + RequestMiddleware(#[from] reqwest_middleware::Error), + + /// API response errors + #[error("API error: {0}")] + Api(#[from] ApiResponseError), + + /// TLS configuration errors + #[error("TLS error: {0}")] + Tls(#[from] TlsError), + + /// JSON parsing errors + #[error("JSON parsing error: {0}")] + Json(#[from] serde_json::Error), + + /// Client configuration errors + #[error("Client configuration error: {message}")] + Configuration { message: String }, +} + +/// API response specific errors +/// +/// These errors represent issues with API responses from Keylime services, +/// including HTTP status codes and response parsing issues. +#[derive(Error, Debug)] +pub enum ApiResponseError { + /// Server returned an error response + #[error("Server error: {message} (status: {status})")] + ServerError { + status: u16, + message: String, + response: Option, + }, +} + +/// TLS configuration and connection errors +/// +/// These errors represent issues with TLS/SSL setup and connections, +/// including certificate validation and configuration problems. +#[derive(Error, Debug)] +pub enum TlsError { + /// Certificate file not found or unreadable + #[error("Certificate file error: {path} - {reason}")] + CertificateFile { path: String, reason: String }, + + /// Private key file not found or unreadable + #[error("Private key file error: {path} - {reason}")] + PrivateKeyFile { path: String, reason: String }, + + /// CA certificate file not found or unreadable + #[error("CA certificate file error: {path} - {reason}")] + CaCertificateFile { path: String, reason: String }, + + /// TLS configuration error + #[error("TLS configuration error: {message}")] + Configuration { message: String }, +} + +impl ClientError { + /// Create a new configuration error + pub fn configuration>(message: T) -> Self { + Self::Configuration { + message: message.into(), + } + } +} + +impl ApiResponseError {} + +impl TlsError { + /// Create a certificate file error + pub fn certificate_file, R: Into>( + path: P, + reason: R, + ) -> Self { + Self::CertificateFile { + path: path.into(), + reason: reason.into(), + } + } + + /// Create a private key file error + pub fn private_key_file, R: Into>( + path: P, + reason: R, + ) -> Self { + Self::PrivateKeyFile { + path: path.into(), + reason: reason.into(), + } + } + + /// Create a CA certificate file error + pub fn ca_certificate_file, R: Into>( + path: P, + reason: R, + ) -> Self { + Self::CaCertificateFile { + path: path.into(), + reason: reason.into(), + } + } + + /// Create a configuration error + pub fn configuration>(message: M) -> Self { + Self::Configuration { + message: message.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_client_error_creation() { + let _config_err = ClientError::configuration("Invalid timeout"); + } + + #[test] + fn test_api_response_error_creation() { + let _server_err = ApiResponseError::ServerError { + status: 500, + message: "Internal error".to_string(), + response: Some(json!({"error": "database down"})), + }; + } + + #[test] + fn test_tls_error_creation() { + let cert_err = + TlsError::certificate_file("/path/to/cert.pem", "File not found"); + match cert_err { + TlsError::CertificateFile { path, reason } => { + assert_eq!(path, "/path/to/cert.pem"); + assert_eq!(reason, "File not found"); + } + _ => panic!("Expected CertificateFile error"), //#[allow_ci] + } + + let key_err = TlsError::private_key_file( + "/path/to/key.pem", + "Permission denied", + ); + match key_err { + TlsError::PrivateKeyFile { path, reason } => { + assert_eq!(path, "/path/to/key.pem"); + assert_eq!(reason, "Permission denied"); + } + _ => panic!("Expected PrivateKeyFile error"), //#[allow_ci] + } + } + + #[test] + fn test_client_error_types() { + // Server errors (5xx) + let _server_err = ClientError::Api(ApiResponseError::ServerError { + status: 500, + message: "Internal error".to_string(), + response: None, + }); + + // Configuration errors + let _config_err = ClientError::configuration("Invalid timeout"); + } + + #[test] + fn test_error_display() { + let api_err = ApiResponseError::ServerError { + status: 500, + message: "Database connection failed".to_string(), + response: None, + }; + assert!(api_err.to_string().contains("500")); + assert!(api_err.to_string().contains("Database connection failed")); + + let tls_err = + TlsError::certificate_file("/path/cert.pem", "Not found"); + assert!(tls_err.to_string().contains("/path/cert.pem")); + assert!(tls_err.to_string().contains("Not found")); + + let client_err = ClientError::configuration("Invalid timeout value"); + assert!(client_err.to_string().contains("Invalid timeout value")); + } +} diff --git a/keylimectl/src/client/factory.rs b/keylimectl/src/client/factory.rs new file mode 100644 index 000000000..a327374f5 --- /dev/null +++ b/keylimectl/src/client/factory.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Client factory for caching client instances +//! +//! This module provides a factory pattern for creating and caching client +//! instances (VerifierClient, RegistrarClient). Each client is created once +//! per command execution and reused to avoid redundant API version detection. +//! +//! The factory uses `std::sync::OnceLock` to cache clients. Since keylimectl +//! is single-threaded (one command per execution), this provides efficient +//! caching. Note that `OnceLock` can only be initialized once per process +//! lifetime, which is perfect for our use case. + +use crate::client::{registrar::RegistrarClient, verifier::VerifierClient}; +use crate::config::singleton::get_config; +use crate::error::KeylimectlError; +use std::sync::OnceLock; + +static VERIFIER_CLIENT: OnceLock = OnceLock::new(); +static REGISTRAR_CLIENT: OnceLock = OnceLock::new(); + +/// Get or create the verifier client +/// +/// This function returns a cached verifier client if one exists, or creates +/// a new one if this is the first call. The client is cached for the duration +/// of the process (which is typically one command execution for keylimectl). +/// +/// # Errors +/// +/// Returns an error if the client cannot be created (e.g., network issues, +/// invalid configuration, or API version detection failure). +/// +/// # Examples +/// +/// ```rust,ignore +/// use keylimectl::client::factory; +/// +/// let verifier = factory::get_verifier().await?; +/// let agents = verifier.list_agents(None).await?; +/// ``` +pub async fn get_verifier() -> Result<&'static VerifierClient, KeylimectlError> +{ + if let Some(client) = VERIFIER_CLIENT.get() { + return Ok(client); + } + + // Create and initialize the client + let config = get_config(); + let client = VerifierClient::builder().config(config).build().await?; + + // Try to set it (might fail if another task beat us to it, which is fine) + match VERIFIER_CLIENT.set(client) { + Ok(()) => Ok(VERIFIER_CLIENT.get().unwrap()), //#[allow_ci] + Err(client) => { + // Another task already set it, return the existing one + // But this shouldn't happen in single-threaded keylimectl + drop(client); + Ok(VERIFIER_CLIENT.get().unwrap()) //#[allow_ci] + } + } +} + +/// Get or create the registrar client +/// +/// This function returns a cached registrar client if one exists, or creates +/// a new one if this is the first call. The client is cached for the duration +/// of the process. +/// +/// # Errors +/// +/// Returns an error if the client cannot be created. +/// +/// # Examples +/// +/// ```rust,ignore +/// use keylimectl::client::factory; +/// +/// let registrar = factory::get_registrar().await?; +/// let agent_data = registrar.get_agent("agent-uuid").await?; +/// ``` +pub async fn get_registrar( +) -> Result<&'static RegistrarClient, KeylimectlError> { + if let Some(client) = REGISTRAR_CLIENT.get() { + return Ok(client); + } + + // Create and initialize the client + let config = get_config(); + let client = RegistrarClient::builder().config(config).build().await?; + + // Try to set it + match REGISTRAR_CLIENT.set(client) { + Ok(()) => Ok(REGISTRAR_CLIENT.get().unwrap()), //#[allow_ci] + Err(client) => { + drop(client); + Ok(REGISTRAR_CLIENT.get().unwrap()) //#[allow_ci] + } + } +} + +#[cfg(test)] +mod tests { + // Note: These tests are limited because we can't easily reset OnceLock + // in unit tests (it's designed to be set once per process lifetime). + // Integration tests would be better for testing the factory pattern. + + #[test] + fn test_factory_exists() { + // Just verify the module compiles and functions are callable + // No assertions needed - compilation success is the test + } +} diff --git a/keylimectl/src/client/mod.rs b/keylimectl/src/client/mod.rs new file mode 100644 index 000000000..116736e0a --- /dev/null +++ b/keylimectl/src/client/mod.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Client implementations for communicating with Keylime services + +#[cfg(feature = "api-v2")] +pub mod agent; +pub mod base; +pub mod error; +pub mod factory; +pub mod registrar; +pub mod verifier; diff --git a/keylimectl/src/client/registrar.rs b/keylimectl/src/client/registrar.rs new file mode 100644 index 000000000..6becf0014 --- /dev/null +++ b/keylimectl/src/client/registrar.rs @@ -0,0 +1,1222 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Registrar client for communicating with the Keylime registrar +//! +//! This module provides a comprehensive client interface for interacting with the Keylime registrar service. +//! The registrar maintains a database of registered agents and their TPM public keys, serving as the +//! trusted authority for agent identity verification. +//! +//! # Features +//! +//! - **Agent Registry**: Manage agent registration and identity +//! - **TPM Key Management**: Store and retrieve TPM endorsement keys +//! - **Agent Discovery**: Search agents by UUID or EK hash +//! - **Resilient Communication**: Built-in retry logic and error handling +//! - **TLS Support**: Mutual TLS authentication with configurable certificates +//! +//! # Architecture +//! +//! The [`RegistrarClient`] wraps a [`ResilientClient`] from the keylime library, +//! providing automatic retries, exponential backoff, and proper error handling +//! for all registrar operations. +//! +//! # Agent Lifecycle +//! +//! 1. **Registration**: Agent registers with registrar, providing TPM keys +//! 2. **Verification**: Registrar validates TPM endorsement keys +//! 3. **Storage**: Agent identity and keys stored in database +//! 4. **Lookup**: Other services query registrar for agent information +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::client::registrar::RegistrarClient; +//! use keylimectl::config::Config; +//! +//! # async fn example() -> Result<(), Box> { +//! let config = Config::default(); +//! let client = RegistrarClient::new(&config)?; +//! +//! // Get agent information from registrar +//! if let Some(agent) = client.get_agent("agent-uuid").await? { +//! println!("Agent found: {:?}", agent); +//! } +//! +//! // List all registered agents +//! let agents = client.list_agents().await?; +//! println!("Found {} agents", agents["results"].as_object().unwrap().len()); //#[allow_ci] +//! +//! // Delete agent from registrar +//! let result = client.delete_agent("agent-uuid").await?; +//! println!("Agent deleted: {:?}", result); +//! # Ok(()) +//! # } +//! ``` + +use crate::client::base::BaseClient; +use crate::config::Config; +use crate::error::{ErrorContext, KeylimectlError}; +use keylime::version::KeylimeRegistrarVersion; +use log::{debug, info, warn}; +use reqwest::{Method, StatusCode}; +use serde_json::Value; + +use crate::api_versions::SUPPORTED_API_VERSIONS; + +/// Response structure for version endpoint +#[derive(serde::Deserialize, Debug)] +struct Response { + #[allow(dead_code)] + code: serde_json::Number, + #[allow(dead_code)] + status: String, + results: T, +} + +/// Client for communicating with the Keylime registrar service +/// +/// The `RegistrarClient` provides a high-level interface for all registrar operations, +/// including agent registration, key management, and agent discovery. It handles +/// authentication, retries, and error processing automatically. +/// +/// # Configuration +/// +/// The client is configured through the [`Config`] struct, which specifies: +/// - Registrar service endpoint (IP and port) +/// - TLS certificate configuration +/// - Retry and timeout settings +/// +/// # Database Operations +/// +/// The registrar maintains a persistent database of: +/// - Agent UUIDs and metadata +/// - TPM endorsement keys (EK) +/// - TPM attestation identity keys (AIK) +/// - Agent registration timestamps +/// +/// # Security Model +/// +/// The registrar serves as the root of trust for agent identity: +/// - Validates TPM endorsement keys against known manufacturers +/// - Stores cryptographic proof of agent identity +/// - Prevents agent UUID collisions and spoofing +/// +/// # Thread Safety +/// +/// `RegistrarClient` is thread-safe and can be shared across multiple tasks +/// or threads using `Arc`. +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::client::registrar::RegistrarClient; +/// use keylimectl::config::Config; +/// +/// # fn example() -> Result<(), Box> { +/// let mut config = Config::default(); +/// config.registrar.ip = "10.0.0.2".to_string(); +/// config.registrar.port = 8891; +/// +/// let client = RegistrarClient::new(&config)?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct RegistrarClient { + base: BaseClient, + api_version: String, + supported_api_versions: Option>, +} + +/// Builder for creating RegistrarClient instances with flexible configuration +/// +/// The `RegistrarClientBuilder` provides a fluent interface for configuring +/// and creating `RegistrarClient` instances. It allows for optional API version +/// detection and custom API version specification. +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::client::registrar::RegistrarClient; +/// use keylimectl::config::Config; +/// +/// # async fn example() -> Result<(), Box> { +/// let config = Config::default(); +/// +/// // Create client with automatic version detection +/// let client = RegistrarClient::builder() +/// .config(&config) +/// .build() +/// .await?; +/// +/// // Create client without version detection (for testing) +/// let client = RegistrarClient::builder() +/// .config(&config) +/// .skip_version_detection() +/// .build_sync()?; +/// +/// // Create client with specific API version +/// let client = RegistrarClient::builder() +/// .config(&config) +/// .api_version("2.0") +/// .skip_version_detection() +/// .build_sync()?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct RegistrarClientBuilder<'a> { + config: Option<&'a Config>, +} + +impl<'a> RegistrarClientBuilder<'a> { + /// Create a new builder instance + pub fn new() -> Self { + Self { config: None } + } + + /// Set the configuration for the client + pub fn config(mut self, config: &'a Config) -> Self { + self.config = Some(config); + self + } + + /// Build the RegistrarClient with automatic API version detection + /// + /// This is the recommended way to create a client for production use, + /// as it will automatically detect the optimal API version supported + /// by the registrar service. + pub async fn build(self) -> Result { + let config = self.config.ok_or_else(|| { + KeylimectlError::validation( + "Configuration is required for RegistrarClient", + ) + })?; + + RegistrarClient::new(config).await + } +} + +impl<'a> Default for RegistrarClientBuilder<'a> { + fn default() -> Self { + Self::new() + } +} + +impl RegistrarClient { + /// Create a new builder for configuring a RegistrarClient + /// + /// This is the recommended way to create RegistrarClient instances, + /// as it provides a flexible interface for configuration. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::registrar::RegistrarClient; + /// use keylimectl::config::Config; + /// + /// # async fn example() -> Result<(), Box> { + /// let config = Config::default(); + /// let client = RegistrarClient::builder() + /// .config(&config) + /// .build() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn builder() -> RegistrarClientBuilder<'static> { + RegistrarClientBuilder::new() + } + /// Create a new registrar client with automatic API version detection + /// + /// Initializes a new `RegistrarClient` with the provided configuration and + /// automatically detects the API version supported by the registrar service. + /// This sets up the HTTP client with TLS configuration, retry logic, + /// and connection pooling, then attempts to determine the optimal API version. + /// + /// # Arguments + /// + /// * `config` - Configuration containing registrar endpoint and TLS settings + /// + /// # Returns + /// + /// Returns a configured `RegistrarClient` with detected API version. + /// + /// # Errors + /// + /// This method can fail if: + /// - TLS certificate files cannot be read + /// - Certificate/key files are invalid + /// - HTTP client initialization fails + /// - Version detection fails (falls back to default version) + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::registrar::RegistrarClient; + /// use keylimectl::config::Config; + /// + /// # async fn example() -> Result<(), Box> { + /// let config = Config::default(); + /// let client = RegistrarClient::new(&config).await?; + /// println!("Registrar client created for {}", config.registrar_base_url()); + /// # Ok(()) + /// # } + /// ``` + pub async fn new(config: &Config) -> Result { + let mut client = Self::new_without_version_detection(config)?; + + client.detect_api_version().await.map_err(|e| { + KeylimectlError::Client( + crate::client::error::ClientError::Configuration { + message: format!( + "Failed to detect registrar API version: {e}" + ), + }, + ) + })?; + + Ok(client) + } + + /// Create a new registrar client without API version detection + /// + /// Initializes a new `RegistrarClient` with the provided configuration + /// using the default API version without attempting to detect the + /// server's supported version. This is mainly useful for testing. + /// + /// # Arguments + /// + /// * `config` - Configuration containing registrar endpoint and TLS settings + /// + /// # Returns + /// + /// Returns a configured `RegistrarClient` with default API version. + /// + /// # Errors + /// + /// This method can fail if: + /// - TLS certificate files cannot be read + /// - Certificate/key files are invalid + /// - HTTP client initialization fails + pub(crate) fn new_without_version_detection( + config: &Config, + ) -> Result { + let base_url = config.registrar_base_url(); + let base = BaseClient::new(base_url, config, None) + .map_err(KeylimectlError::from)?; + + Ok(Self { + base, + api_version: crate::api_versions::DEFAULT_API_VERSION.to_string(), + supported_api_versions: None, + }) + } + + /// Auto-detect and set the API version + /// + /// Attempts to determine the registrar's API version by first trying the `/version` endpoint. + /// If that fails, it tries each supported API version from oldest to newest until one works. + /// This follows the same pattern used in the rust-keylime agent's registrar client. + /// + /// # Returns + /// + /// Returns `Ok(())` if version detection succeeded or failed gracefully. + /// Returns `Err()` only for critical errors that prevent client operation. + /// + /// # Behavior + /// + /// 1. First tries `/version` endpoint to get current and supported versions + /// 2. If `/version` fails, tries API versions from newest to oldest + /// 3. On success, caches the detected version for future requests + /// 4. On complete failure, leaves default version unchanged + /// + /// # Examples + /// + /// ```rust + /// # use keylimectl::client::registrar::RegistrarClient; + /// # use keylimectl::config::Config; + /// # async fn example() -> Result<(), Box> { + /// let mut client = RegistrarClient::new(&Config::default())?; + /// + /// // Detect API version manually if needed + /// client.detect_api_version().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn detect_api_version( + &mut self, + ) -> Result<(), KeylimectlError> { + info!("Starting registrar API version detection"); + + // Step 1: Try the /version endpoint first + match self.get_registrar_api_version().await { + Ok(version) => { + info!("Successfully detected registrar API version from /version endpoint: {version}"); + self.api_version = version; + return Ok(()); + } + #[cfg(feature = "api-v3")] + Err(KeylimectlError::Api { status: 410, .. }) => { + info!("/version endpoint returned 410 Gone - this indicates a v3.0+ registrar"); + + // Step 2: Confirm v3.0 support by testing the v3.0 endpoint + if self.test_api_version_v3("3.0").await.is_ok() { + info!("Confirmed registrar supports API v3.0"); + self.api_version = "3.0".to_string(); + return Ok(()); + } else { + warn!("Got 410 from /version but v3.0 endpoint test failed - falling back to version probing"); + } + } + Err(e) => { + debug!("Failed to get version from /version endpoint ({e}), falling back to version probing"); + } + } + + // Step 3: Fall back to testing each version individually (newest to oldest) + info!("Falling back to individual version testing"); + for &api_version in SUPPORTED_API_VERSIONS.iter().rev() { + debug!("Testing registrar API version {api_version}"); + + let version_works = if api_version.starts_with("3.") { + #[cfg(feature = "api-v3")] + { + self.test_api_version_v3(api_version).await.is_ok() + } + #[cfg(not(feature = "api-v3"))] + { + false + } + } else { + #[cfg(feature = "api-v2")] + { + self.test_api_version(api_version).await.is_ok() + } + #[cfg(not(feature = "api-v2"))] + { + false + } + }; + + if version_works { + info!("Successfully detected registrar API version: {api_version}"); + self.api_version = api_version.to_string(); + return Ok(()); + } + } + + // If all versions failed, continue with default version + warn!( + "Could not detect registrar API version, using default: {}", + self.api_version + ); + Ok(()) + } + + /// Get the registrar API version from the '/version' endpoint + async fn get_registrar_api_version( + &mut self, + ) -> Result { + let url = format!("{}/version", self.base.base_url); + + info!("Requesting registrar API version from {url}"); + + debug!("GET {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send version request to registrar".to_string() + })?; + + if !response.status().is_success() { + return Err(KeylimectlError::api_error( + response.status().as_u16(), + "Registrar does not support the /version endpoint" + .to_string(), + None, + )); + } + + let resp: Response = + response.json().await.with_context(|| { + "Failed to parse version response from registrar".to_string() + })?; + + self.supported_api_versions = + Some(resp.results.supported_versions.clone()); + Ok(resp.results.current_version) + } + + /// Test if a specific API version v3.0+ works by testing the versioned root endpoint + /// In API v3.0+, the /version endpoint was removed, so we test endpoint availability directly + #[cfg(feature = "api-v3")] + async fn test_api_version_v3( + &self, + api_version: &str, + ) -> Result<(), KeylimectlError> { + let url = format!("{}/v{}/", self.base.base_url, api_version); + + debug!("Testing registrar API version {api_version} with root endpoint: {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + format!("Failed to test API version {api_version}") + })?; + + if response.status().is_success() { + Ok(()) + } else { + Err(KeylimectlError::api_error( + response.status().as_u16(), + format!("API version {api_version} not supported"), + None, + )) + } + } + + /// Test if a specific API version v2.x works by making a simple request + #[cfg(feature = "api-v2")] + async fn test_api_version( + &self, + api_version: &str, + ) -> Result<(), KeylimectlError> { + let url = format!("{}/v{}/agents/", self.base.base_url, api_version); + + debug!("Testing registrar API version {api_version} with URL: {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + format!("Failed to test API version {api_version}") + })?; + + if response.status().is_success() { + Ok(()) + } else { + Err(KeylimectlError::api_error( + response.status().as_u16(), + format!("API version {api_version} not supported"), + None, + )) + } + } + + /// Get agent information from the registrar + /// + /// Retrieves agent registration information and TPM keys from the registrar. + /// This is the primary method for looking up agent identity and cryptographic + /// credentials stored during registration. + /// + /// # Arguments + /// + /// * `agent_uuid` - Unique identifier for the agent + /// + /// # Returns + /// + /// Returns `Some(Value)` containing agent registration data if found, + /// or `None` if the agent is not registered. + /// + /// # Agent Data Format + /// + /// The returned data includes: + /// ```json + /// { + /// "aik_tpm": "base64-encoded-aik", + /// "ek_tpm": "base64-encoded-ek", + /// "ekcert": "base64-encoded-ek-certificate", + /// "ip": "192.168.1.100", + /// "port": 9002, + /// "regcount": 1, + /// "active": true + /// } + /// ``` + /// + /// # Key Components + /// + /// - `aik_tpm`: Attestation Identity Key (AIK) public portion + /// - `ek_tpm`: Endorsement Key (EK) public portion + /// - `ekcert`: EK certificate from TPM manufacturer + /// - `regcount`: Number of times agent has registered + /// - `active`: Whether agent is currently active + /// + /// # Errors + /// + /// This method can fail if: + /// - Agent UUID format is invalid + /// - Network communication fails + /// - Registrar service returns an error + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::registrar::RegistrarClient; + /// + /// # async fn example(client: &RegistrarClient) -> Result<(), Box> { + /// match client.get_agent("550e8400-e29b-41d4-a716-446655440000").await? { + /// Some(agent) => { + /// println!("Agent IP: {}", agent["ip"]); + /// println!("Registration count: {}", agent["regcount"]); + /// println!("Active: {}", agent["active"]); + /// } + /// None => println!("Agent not registered with registrar"), + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_agent( + &self, + agent_uuid: &str, + ) -> Result, KeylimectlError> { + crate::client::base::validate_agent_id(agent_uuid)?; + debug!("Getting agent {agent_uuid} from registrar"); + + let url = format!( + "{}/v{}/agents/{}", + self.base.base_url, self.api_version, agent_uuid + ); + + debug!("GET {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send get agent request to registrar".to_string() + })?; + + match response.status() { + StatusCode::OK => { + let json_response: Value = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from)?; + + // Extract agent data from registrar response format + // The registrar API returns agent data directly in "results", not nested under agent UUID + if let Some(results) = json_response.get("results") { + Ok(Some(results.clone())) + } else { + Ok(Some(json_response)) + } + } + StatusCode::NOT_FOUND => Ok(None), + _ => { + let error_response: Result = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from); + match error_response { + Ok(_) => Ok(None), + Err(e) => Err(e), + } + } + } + } + + /// Delete an agent from the registrar + /// + /// Removes an agent's registration and all associated cryptographic + /// materials from the registrar database. This is typically done + /// when decommissioning an agent. + /// + /// # Arguments + /// + /// * `agent_uuid` - Unique identifier for the agent to remove + /// + /// # Returns + /// + /// Returns the registrar's response confirming deletion. + /// + /// # Behavior + /// + /// - Removes agent UUID from registrar database + /// - Deletes all stored TPM keys (EK, AIK) + /// - Removes EK certificate and metadata + /// - Marks agent as inactive/deleted + /// - Gracefully handles requests for non-existent agents + /// + /// # Security Implications + /// + /// - Agent cannot re-register with same UUID until database cleanup + /// - TPM keys are permanently removed from trust database + /// - Verifier will no longer trust agent identity + /// + /// # Errors + /// + /// This method can fail if: + /// - Agent UUID format is invalid + /// - Network communication fails + /// - Registrar service returns an error + /// - Database constraints prevent deletion + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::registrar::RegistrarClient; + /// + /// # async fn example(client: &RegistrarClient) -> Result<(), Box> { + /// let result = client.delete_agent("550e8400-e29b-41d4-a716-446655440000").await?; + /// println!("Agent removed from registrar: {:?}", result); + /// # Ok(()) + /// # } + /// ``` + pub async fn delete_agent( + &self, + agent_uuid: &str, + ) -> Result { + crate::client::base::validate_agent_id(agent_uuid)?; + debug!("Deleting agent {agent_uuid} from registrar"); + + let url = format!( + "{}/v{}/agents/{}", + self.base.base_url, self.api_version, agent_uuid + ); + + debug!("DELETE {url}"); + + let response = self + .base + .client + .get_request(Method::DELETE, &url) + .send() + .await + .with_context(|| { + "Failed to send delete agent request to registrar".to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// List all agents registered with the registrar + /// + /// Retrieves a comprehensive list of all agents in the registrar database. + /// This provides an overview of the entire agent population and their + /// registration status. + /// + /// # Returns + /// + /// Returns a JSON object containing all registered agents: + /// ```json + /// { + /// "results": { + /// "agent-uuid-1": { + /// "ip": "192.168.1.100", + /// "port": 9002, + /// "regcount": 1, + /// "active": true, + /// "aik_tpm": "base64-encoded-aik", + /// "ek_tpm": "base64-encoded-ek" + /// }, + /// ... + /// } + /// } + /// ``` + /// + /// # Use Cases + /// + /// - Infrastructure inventory and monitoring + /// - Agent deployment verification + /// - Security auditing and compliance + /// - Bulk operations planning + /// + /// # Performance Considerations + /// + /// - Response size grows with agent count + /// - May include large cryptographic keys + /// - Consider pagination for very large deployments + /// - Use filtering options when available + /// + /// # Errors + /// + /// This method can fail if: + /// - Network communication fails + /// - Registrar service returns an error + /// - Database query fails + /// - Response payload exceeds size limits + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::registrar::RegistrarClient; + /// + /// # async fn example(client: &RegistrarClient) -> Result<(), Box> { + /// let agents = client.list_agents().await?; + /// + /// if let Some(results) = agents["results"].as_object() { + /// println!("Found {} registered agents:", results.len()); + /// for (uuid, info) in results { + /// let active = info["active"].as_bool().unwrap_or(false); + /// let status = if active { "active" } else { "inactive" }; + /// println!(" {}: {} ({}:{})", uuid, status, info["ip"], info["port"]); + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + /// Get the detected API version + pub fn api_version(&self) -> &str { + &self.api_version + } + + /// List all agents registered with the registrar + pub async fn list_agents(&self) -> Result { + debug!("Listing agents on registrar"); + + let url = + format!("{}/v{}/agents/", self.base.base_url, self.api_version); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send list agents request to registrar".to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::base::BaseClient; + use crate::config::{ + AgentConfig, ClientConfig, RegistrarConfig, TlsConfig, + }; + use serde_json::json; + + /// Create a test configuration for registrar + fn create_test_config() -> Config { + Config { + loaded_from: None, + cli_overrides: crate::config::CliOverrides::default(), + verifier: crate::config::VerifierConfig::default(), + registrar: RegistrarConfig { + ip: "127.0.0.1".to_string(), + port: 8891, + }, + tls: TlsConfig { + client_cert: None, + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: false, // Disable for testing + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }, + client: ClientConfig { + timeout: 30, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + }, + agent: AgentConfig::default(), + } + } + + #[test] + fn test_registrar_client_new() { + let config = create_test_config(); + let result = RegistrarClient::new_without_version_detection(&config); + + assert!(result.is_ok()); + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://127.0.0.1:8891"); + assert_eq!( + client.api_version, + crate::api_versions::DEFAULT_API_VERSION + ); + } + + #[test] + fn test_registrar_client_new_with_custom_port() { + let mut config = create_test_config(); + config.registrar.port = 9000; + + let result = RegistrarClient::new_without_version_detection(&config); + assert!(result.is_ok()); + + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://127.0.0.1:9000"); + } + + #[test] + fn test_registrar_client_new_with_ipv6() { + let mut config = create_test_config(); + config.registrar.ip = "::1".to_string(); + + let result = RegistrarClient::new_without_version_detection(&config); + assert!(result.is_ok()); + + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://[::1]:8891"); + } + + #[test] + fn test_registrar_client_new_with_bracketed_ipv6() { + let mut config = create_test_config(); + config.registrar.ip = "[2001:db8::1]".to_string(); + + let result = RegistrarClient::new_without_version_detection(&config); + assert!(result.is_ok()); + + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://[2001:db8::1]:8891"); + } + + #[test] + fn test_create_http_client_with_invalid_cert_files() { + let mut config = create_test_config(); + config.tls.client_cert = Some("/nonexistent/cert.pem".to_string()); + config.tls.client_key = Some("/nonexistent/key.pem".to_string()); + + let result = BaseClient::create_http_client(&config); + // Should fail because cert files don't exist + assert!(result.is_err()); + + let error = result.unwrap_err(); + assert!(error.to_string().contains("Certificate file error")); + } + + #[test] + fn test_config_validation() { + let config = create_test_config(); + + // Test that our test config is valid + assert!(config.validate().is_ok()); + + // Test base URL generation + assert_eq!(config.registrar_base_url(), "https://127.0.0.1:8891"); + } + + #[test] + fn test_base_url_construction() { + // Test IPv4 with custom port + let mut config = create_test_config(); + config.registrar.ip = "10.0.0.5".to_string(); + config.registrar.port = 9500; + + let client = + RegistrarClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://10.0.0.5:9500"); + + // Test IPv6 + config.registrar.ip = "2001:db8:85a3::8a2e:370:7334".to_string(); + config.registrar.port = 8891; + + let client = + RegistrarClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + assert_eq!( + client.base.base_url, + "https://[2001:db8:85a3::8a2e:370:7334]:8891" + ); + } + + // Error handling tests + mod error_tests { + use super::*; + + #[test] + fn test_api_error_handling() { + // Test different types of API errors that registrar might return + let not_found_error = KeylimectlError::api_error( + 404, + "Agent not found".to_string(), + Some(json!({"error": "Agent UUID not in registrar"})), + ); + + assert_eq!(not_found_error.error_code(), "API_ERROR"); + assert!(!not_found_error.is_retryable()); // 404 should not be retryable + + let server_error = KeylimectlError::api_error( + 500, + "Database connection failed".to_string(), + None, + ); + + assert!(server_error.is_retryable()); // 500 should be retryable + } + + #[test] + fn test_agent_not_found_error() { + let error = + KeylimectlError::agent_not_found("test-uuid", "registrar"); + + assert_eq!(error.error_code(), "AGENT_NOT_FOUND"); + assert!(!error.is_retryable()); + + let json_output = error.to_json(); + assert_eq!(json_output["error"]["code"], "AGENT_NOT_FOUND"); + assert_eq!( + json_output["error"]["details"]["agent_uuid"], + "test-uuid" + ); + assert_eq!( + json_output["error"]["details"]["service"], + "registrar" + ); + } + } + + // Configuration edge cases + mod config_tests { + use super::*; + + #[test] + fn test_multiple_trusted_ca() { + let mut config = create_test_config(); + config.tls.trusted_ca = vec![ + "/path/to/ca1.pem".to_string(), + "/path/to/ca2.pem".to_string(), + ]; + + // Client creation should succeed even with non-existent CA files + // (they're only validated when actually used) + let result = + RegistrarClient::new_without_version_detection(&config); + assert!(result.is_ok()); + } + } + + // Integration-style tests (commented out as they require running services) + /* + #[tokio::test] + async fn test_get_agent_integration() { + let config = create_test_config(); + let client = RegistrarClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + + // This would require a running registrar service + // let result = client.get_agent("test-agent-uuid").await; + // Should handle both Some(agent) and None cases + } + + #[tokio::test] + async fn test_list_agents_integration() { + let config = create_test_config(); + let client = RegistrarClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + + // This would require a running registrar service + // let result = client.list_agents().await; + // assert!(result.is_ok()); + // + // let agents = result.unwrap(); //#[allow_ci] + // assert!(agents.get("results").is_some()); + } + + #[tokio::test] + async fn test_delete_agent_integration() { + let config = create_test_config(); + let client = RegistrarClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + + // This would require a running registrar service + // let result = client.delete_agent("test-agent-uuid").await; + // Should handle successful deletion + } + */ + + // API Version Detection Tests + mod api_version_tests { + use super::*; + use keylime::version::KeylimeRegistrarVersion; + + #[test] + fn test_supported_api_versions_constant() { + // Test that the constant contains expected versions based on enabled features + assert!(!SUPPORTED_API_VERSIONS.is_empty()); + + #[cfg(all(feature = "api-v2", feature = "api-v3"))] + assert_eq!( + SUPPORTED_API_VERSIONS, + &["2.0", "2.1", "2.2", "2.3", "3.0"] + ); + + #[cfg(all(feature = "api-v2", not(feature = "api-v3")))] + assert_eq!(SUPPORTED_API_VERSIONS, &["2.0", "2.1", "2.2", "2.3"]); + + #[cfg(all(not(feature = "api-v2"), feature = "api-v3"))] + assert_eq!(SUPPORTED_API_VERSIONS, &["3.0"]); + + // Verify versions are in ascending order (oldest to newest) + for i in 1..SUPPORTED_API_VERSIONS.len() { + let prev: f32 = + SUPPORTED_API_VERSIONS[i - 1].parse().unwrap(); //#[allow_ci] + let curr: f32 = SUPPORTED_API_VERSIONS[i].parse().unwrap(); //#[allow_ci] + assert!( + prev < curr, + "API versions should be in ascending order" + ); + } + } + + #[test] + fn test_response_structure_deserialization() { + let json_str = r#"{ + "code": 200, + "status": "OK", + "results": { + "current_version": "2.1", + "supported_versions": ["2.0", "2.1", "2.2", "3.0"] + } + }"#; + + let response: Result, _> = + serde_json::from_str(json_str); + + assert!(response.is_ok()); + let response = response.unwrap(); //#[allow_ci] + assert_eq!(response.results.current_version, "2.1"); + assert_eq!( + response.results.supported_versions, + vec!["2.0", "2.1", "2.2", "3.0"] + ); + } + + #[test] + fn test_api_version_iteration_order() { + // Test that iter().rev() gives us newest to oldest as expected + let versions: Vec<&str> = + SUPPORTED_API_VERSIONS.iter().rev().copied().collect(); + + // Should be newest first (last element of ascending array) + assert_eq!( + versions[0], + *SUPPORTED_API_VERSIONS.last().unwrap() //#[allow_ci] + ); + + // Verify it's actually newest to oldest + for i in 1..versions.len() { + let prev: f32 = versions[i - 1].parse().unwrap(); //#[allow_ci] + let curr: f32 = versions[i].parse().unwrap(); //#[allow_ci] + assert!( + prev > curr, + "Reversed iteration should give newest to oldest" + ); + } + } + + #[test] + fn test_version_string_parsing() { + // Test that our version strings can be parsed as valid version numbers + for version in SUPPORTED_API_VERSIONS { + let parsed: Result = version.parse(); + assert!( + parsed.is_ok(), + "Version string '{version}' should parse as number" + ); + + let num = parsed.unwrap(); //#[allow_ci] + assert!(num >= 1.0, "Version should be >= 1.0"); + assert!(num < 10.0, "Version should be reasonable"); + } + } + + #[test] + fn test_base_url_construction_with_different_versions() { + let config = create_test_config(); + let mut client = + RegistrarClient::new_without_version_detection(&config) + .unwrap(); //#[allow_ci] + + // Test URL construction with different API versions + for version in SUPPORTED_API_VERSIONS { + client.api_version = version.to_string(); + + // Simulate how URLs would be constructed in actual methods + let expected_pattern = format!("/v{version}/agents/"); + let test_url = format!( + "{}/v{}/agents/test-uuid", + client.base.base_url, client.api_version + ); + + assert!(test_url.contains(&expected_pattern)); + assert!(test_url.contains(&client.base.base_url)); + assert!(test_url.contains("test-uuid")); + } + } + + #[test] + #[allow(clippy::const_is_empty)] + fn test_version_constants_consistency() { + // Ensure our constants are consistent with expected patterns + assert!(!SUPPORTED_API_VERSIONS.is_empty()); // Known constant value + + // All supported versions should be valid version strings + for version in SUPPORTED_API_VERSIONS { + assert!(!version.is_empty()); + assert!(version + .chars() + .all(|c| c.is_ascii_digit() || c == '.')); + assert!(version.contains('.')); + } + } + + #[test] + fn test_version_endpoint_url_construction() { + let config = create_test_config(); + let client = + RegistrarClient::new_without_version_detection(&config) + .unwrap(); //#[allow_ci] + + // Test version endpoint URL construction + let version_url = format!("{}/version", client.base.base_url); + + assert!(version_url.contains("/version")); + assert!(version_url.starts_with("https://")); + assert!(version_url.contains("8891")); // Default port + + // Should not contain /v{version}/ for version endpoint + assert!(!version_url.contains("/v2.")); + } + + #[test] + fn test_agents_endpoint_url_construction() { + let config = create_test_config(); + let mut client = + RegistrarClient::new_without_version_detection(&config) + .unwrap(); //#[allow_ci] + + // Test agents endpoint URL construction for different versions + for version in SUPPORTED_API_VERSIONS { + client.api_version = version.to_string(); + let agents_url = format!( + "{}/v{}/agents/", + client.base.base_url, client.api_version + ); + + assert!(agents_url.contains(&format!("/v{version}/agents/"))); + assert!(agents_url.starts_with("https://")); + assert!(agents_url.ends_with("/agents/")); + } + } + } +} diff --git a/keylimectl/src/client/verifier.rs b/keylimectl/src/client/verifier.rs new file mode 100644 index 000000000..5fa97cb56 --- /dev/null +++ b/keylimectl/src/client/verifier.rs @@ -0,0 +1,2320 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Verifier client for communicating with the Keylime verifier +//! +//! This module provides a comprehensive client interface for interacting with the Keylime verifier service. +//! The verifier is responsible for continuously monitoring agent integrity, managing attestation policies, +//! and providing cryptographic bootstrapping capabilities. +//! +//! # Features +//! +//! - **Agent Management**: Add, remove, and monitor agents +//! - **Policy Management**: Runtime and measured boot policy operations +//! - **Resilient Communication**: Built-in retry logic and error handling +//! - **TLS Support**: Mutual TLS authentication with configurable certificates +//! - **Bulk Operations**: Efficient batch operations for multiple agents +//! +//! # Architecture +//! +//! The [`VerifierClient`] wraps a [`ResilientClient`] from the keylime library, +//! providing automatic retries, exponential backoff, and proper error handling +//! for all verifier operations. +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::client::verifier::VerifierClient; +//! use keylimectl::config::Config; +//! use serde_json::json; +//! +//! # async fn example() -> Result<(), Box> { +//! let config = Config::default(); +//! let client = VerifierClient::new(&config)?; +//! +//! // Add an agent to the verifier +//! let agent_data = json!({ +//! "ip": "192.168.1.100", +//! "port": 9002, +//! "tpm_policy": "{}", +//! "ima_policy": "{}" +//! }); +//! let result = client.add_agent("agent-uuid", agent_data).await?; +//! +//! // Get agent information +//! if let Some(agent) = client.get_agent("agent-uuid").await? { +//! println!("Agent status: {:?}", agent); +//! } +//! +//! // List all agents +//! let agents = client.list_agents(None).await?; +//! println!("Found {} agents", agents["results"].as_object().unwrap().len()); //#[allow_ci] +//! # Ok(()) +//! # } +//! ``` + +use crate::client::base::BaseClient; +use crate::config::Config; +use crate::error::{ErrorContext, KeylimectlError}; +use keylime::version::KeylimeRegistrarVersion; +use log::{debug, info, warn}; +use reqwest::{Method, StatusCode}; +use serde_json::{json, Value}; + +use crate::api_versions::{is_v3, SUPPORTED_API_VERSIONS}; + +/// Content type for JSON:API requests (v3+) +const JSON_API_CONTENT_TYPE: &str = "application/vnd.api+json"; + +/// Wrap data in a JSON:API resource envelope. +/// +/// Produces `{"data": {"type": type, "attributes": attrs}}` with an optional `"id"`. +fn json_api_resource( + resource_type: &str, + id: Option<&str>, + attributes: Value, +) -> Value { + // Strip null values from attributes — the Python JSON:API validator + // rejects None (only allows dict, list, str, int, float, bool). + let cleaned_attrs = match attributes { + Value::Object(map) => Value::Object( + map.into_iter().filter(|(_, v)| !v.is_null()).collect(), + ), + other => other, + }; + + let mut data = json!({ + "type": resource_type, + "attributes": cleaned_attrs, + }); + if let Some(id) = id { + data["id"] = json!(id); + } + json!({ "data": data }) +} + +/// Response structure for version endpoint +#[derive(serde::Deserialize, Debug)] +struct Response { + #[allow(dead_code)] + code: serde_json::Number, + #[allow(dead_code)] + status: String, + results: T, +} + +/// Client for communicating with the Keylime verifier service +/// +/// The `VerifierClient` provides a high-level interface for all verifier operations, +/// including agent management, policy operations, and bulk queries. It handles +/// authentication, retries, and error processing automatically. +/// +/// # Configuration +/// +/// The client is configured through the [`Config`] struct, which specifies: +/// - Verifier service endpoint (IP and port) +/// - TLS certificate configuration +/// - Retry and timeout settings +/// +/// # Connection Management +/// +/// The client maintains a persistent HTTP connection pool and automatically +/// handles connection failures with exponential backoff retry logic. +/// +/// # Thread Safety +/// +/// `VerifierClient` is thread-safe and can be shared across multiple tasks +/// or threads using `Arc`. +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::client::verifier::VerifierClient; +/// use keylimectl::config::Config; +/// +/// # fn example() -> Result<(), Box> { +/// let mut config = Config::default(); +/// config.verifier.ip = "10.0.0.1".to_string(); +/// config.verifier.port = 8881; +/// +/// let client = VerifierClient::new(&config)?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct VerifierClient { + base: BaseClient, + api_version: String, + supported_api_versions: Option>, +} + +/// Builder for creating VerifierClient instances with flexible configuration +/// +/// The `VerifierClientBuilder` provides a fluent interface for configuring +/// and creating `VerifierClient` instances. It allows for optional API version +/// detection and custom API version specification. +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::client::verifier::VerifierClient; +/// use keylimectl::config::Config; +/// +/// # async fn example() -> Result<(), Box> { +/// let config = Config::default(); +/// +/// // Create client with automatic version detection +/// let client = VerifierClient::builder() +/// .config(&config) +/// .build() +/// .await?; +/// +/// // Create client without version detection (for testing) +/// let client = VerifierClient::builder() +/// .config(&config) +/// .skip_version_detection() +/// .build_sync()?; +/// +/// // Create client with specific API version +/// let client = VerifierClient::builder() +/// .config(&config) +/// .api_version("2.0") +/// .skip_version_detection() +/// .build_sync()?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct VerifierClientBuilder<'a> { + config: Option<&'a Config>, +} + +impl<'a> VerifierClientBuilder<'a> { + /// Create a new builder instance + pub fn new() -> Self { + Self { config: None } + } + + /// Set the configuration for the client + pub fn config(mut self, config: &'a Config) -> Self { + self.config = Some(config); + self + } + + /// Build the VerifierClient with automatic API version detection + /// + /// This is the recommended way to create a client for production use, + /// as it will automatically detect the optimal API version supported + /// by the verifier service. + pub async fn build(self) -> Result { + let config = self.config.ok_or_else(|| { + KeylimectlError::validation( + "Configuration is required for VerifierClient", + ) + })?; + + VerifierClient::new(config).await + } +} + +impl<'a> Default for VerifierClientBuilder<'a> { + fn default() -> Self { + Self::new() + } +} + +impl VerifierClient { + /// Create a new builder for configuring a VerifierClient + /// + /// This is the recommended way to create VerifierClient instances, + /// as it provides a flexible interface for configuration. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::verifier::VerifierClient; + /// use keylimectl::config::Config; + /// + /// # async fn example() -> Result<(), Box> { + /// let config = Config::default(); + /// let client = VerifierClient::builder() + /// .config(&config) + /// .build() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn builder() -> VerifierClientBuilder<'static> { + VerifierClientBuilder::new() + } + /// Create a new verifier client with automatic API version detection + /// + /// Initializes a new `VerifierClient` with the provided configuration and + /// automatically detects the API version supported by the verifier service. + /// This sets up the HTTP client with TLS configuration, retry logic, + /// and connection pooling, then attempts to determine the optimal API version. + /// + /// # Arguments + /// + /// * `config` - Configuration containing verifier endpoint and TLS settings + /// + /// # Returns + /// + /// Returns a configured `VerifierClient` with detected API version. + /// + /// # Errors + /// + /// This method can fail if: + /// - TLS certificate files cannot be read + /// - Certificate/key files are invalid + /// - HTTP client initialization fails + /// - Version detection fails (falls back to default version) + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::verifier::VerifierClient; + /// use keylimectl::config::Config; + /// + /// # async fn example() -> Result<(), Box> { + /// let config = Config::default(); + /// let client = VerifierClient::new(&config).await?; + /// println!("Verifier client created for {}", config.verifier_base_url()); + /// # Ok(()) + /// # } + /// ``` + pub async fn new(config: &Config) -> Result { + debug!("Creating VerifierClient with config: client_cert={:?}, client_key={:?}, trusted_ca={:?}", + config.tls.client_cert, config.tls.client_key, config.tls.trusted_ca); + let mut client = Self::new_without_version_detection(config)?; + + // Detect API version — propagate errors so callers know the + // verifier is unreachable instead of getting cryptic 404s later + client.detect_api_version().await.map_err(|e| { + KeylimectlError::Client( + crate::client::error::ClientError::Configuration { + message: format!( + "Failed to detect verifier API version: {e}" + ), + }, + ) + })?; + + Ok(client) + } + + /// Create a new verifier client without API version detection + /// + /// Initializes a new `VerifierClient` with the provided configuration + /// using the default API version without attempting to detect the + /// server's supported version. This is mainly useful for testing. + /// + /// # Arguments + /// + /// * `config` - Configuration containing verifier endpoint and TLS settings + /// + /// # Returns + /// + /// Returns a configured `VerifierClient` with default API version. + /// + /// # Errors + /// + /// This method can fail if: + /// - TLS certificate files cannot be read + /// - Certificate/key files are invalid + /// - HTTP client initialization fails + pub(crate) fn new_without_version_detection( + config: &Config, + ) -> Result { + let base_url = config.verifier_base_url(); + let base = BaseClient::new(base_url, config, None) + .map_err(KeylimectlError::from)?; + + Ok(Self { + base, + api_version: crate::api_versions::DEFAULT_API_VERSION.to_string(), + supported_api_versions: None, + }) + } + + /// Auto-detect and set the API version + /// + /// Implements a robust API version detection strategy that works with both old and new verifiers: + /// 1. First try `/version` endpoint - if it returns 410 Gone, we're likely talking to v3.0+ verifier + /// 2. If `/version` returns 410, confirm v3.0 support by testing `/v3.0/` endpoint + /// 3. If `/version` succeeds, use the returned version information + /// 4. If `/version` fails with other errors, fall back to testing individual versions + /// + /// This approach prevents false positives where old verifiers return 200 OK for `/v3.0/` + /// even though they don't actually support API v3.0. + /// + /// # Returns + /// + /// Returns `Ok(())` if version detection succeeded or failed gracefully. + /// Returns `Err()` only for critical errors that prevent client operation. + /// + /// # Examples + /// + /// ```rust + /// # use keylimectl::client::verifier::VerifierClient; + /// # use keylimectl::config::Config; + /// # async fn example() -> Result<(), Box> { + /// let mut client = VerifierClient::new(&Config::default())?; + /// + /// // Version detection happens automatically during client creation, + /// // but can be called manually if needed + /// client.detect_api_version().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn detect_api_version( + &mut self, + ) -> Result<(), KeylimectlError> { + info!("Starting verifier API version detection"); + + // Step 1: Try the /version endpoint first + match self.get_verifier_api_version().await { + Ok(version) => { + info!("Successfully detected verifier API version from /version endpoint: {version}"); + self.api_version = version; + return Ok(()); + } + #[cfg(feature = "api-v3")] + Err(KeylimectlError::Api { status: 410, .. }) => { + info!("/version endpoint returned 410 Gone - this indicates a v3.0+ verifier"); + + // Step 2: Confirm v3.0 support by testing the v3.0 endpoint + if self.test_api_version_v3("3.0").await.is_ok() { + info!("Confirmed verifier supports API v3.0"); + self.api_version = "3.0".to_string(); + return Ok(()); + } else { + warn!("Got 410 from /version but v3.0 endpoint test failed - falling back to version probing"); + } + } + Err(e) => { + debug!("Failed to get version from /version endpoint ({e}), falling back to version probing"); + } + } + + // Step 3: Fall back to testing each version individually (newest to oldest) + info!("Falling back to individual version testing"); + for &api_version in SUPPORTED_API_VERSIONS.iter().rev() { + debug!("Testing verifier API version {api_version}"); + + let version_works = if api_version.starts_with("3.") { + #[cfg(feature = "api-v3")] + { + self.test_api_version_v3(api_version).await.is_ok() + } + #[cfg(not(feature = "api-v3"))] + { + false + } + } else { + #[cfg(feature = "api-v2")] + { + self.test_api_version(api_version).await.is_ok() + } + #[cfg(not(feature = "api-v2"))] + { + false + } + }; + + if version_works { + info!("Successfully detected verifier API version: {api_version}"); + self.api_version = api_version.to_string(); + return Ok(()); + } + } + + // If all versions failed, continue with default version + warn!( + "Could not detect verifier API version, using default: {}", + self.api_version + ); + Ok(()) + } + + /// Get the verifier API version from the '/version' endpoint + async fn get_verifier_api_version( + &mut self, + ) -> Result { + let url = format!("{}/version", self.base.base_url); + + info!("Requesting verifier API version from {url}"); + + debug!("Sending version request to: {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + format!("Failed to send version request to verifier at {url}") + })?; + + if !response.status().is_success() { + return Err(KeylimectlError::api_error( + response.status().as_u16(), + "Verifier does not support the /version endpoint".to_string(), + None, + )); + } + + let resp: Response = + response.json().await.with_context(|| { + "Failed to parse version response from verifier".to_string() + })?; + + self.supported_api_versions = + Some(resp.results.supported_versions.clone()); + Ok(resp.results.current_version) + } + + /// Test if a specific API version v3.0+ works by testing the versioned root endpoint + /// In API v3.0+, the /version endpoint was removed, so we test endpoint availability directly + #[cfg(feature = "api-v3")] + async fn test_api_version_v3( + &self, + api_version: &str, + ) -> Result<(), KeylimectlError> { + let url = format!("{}/v{}/", self.base.base_url, api_version); + + debug!("Testing verifier API version {api_version} with root endpoint: {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + format!("Failed to test API version {api_version}") + })?; + + if response.status().is_success() { + Ok(()) + } else { + Err(KeylimectlError::api_error( + response.status().as_u16(), + format!("API version {api_version} not supported"), + None, + )) + } + } + + /// Test if a specific API version v2.x works by making a simple request + #[cfg(feature = "api-v2")] + async fn test_api_version( + &self, + api_version: &str, + ) -> Result<(), KeylimectlError> { + let url = format!("{}/v{}/agents/", self.base.base_url, api_version); + + debug!("Testing verifier API version {api_version} with URL: {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + format!("Failed to test API version {api_version}") + })?; + + if response.status().is_success() { + Ok(()) + } else { + Err(KeylimectlError::api_error( + response.status().as_u16(), + format!("API version {api_version} not supported"), + None, + )) + } + } + + /// Add an agent to the verifier for attestation monitoring + /// + /// Registers an agent with the verifier service, enabling continuous + /// integrity monitoring and attestation. The agent must already be + /// registered with the registrar before being added to the verifier. + /// + /// # Arguments + /// + /// * `agent_uuid` - Unique identifier for the agent + /// * `data` - Agent configuration including IP, port, and policies + /// + /// # Expected Data Format + /// + /// The `data` parameter should contain: + /// ```json + /// { + /// "ip": "192.168.1.100", + /// "port": 9002, + /// "tpm_policy": "{}", + /// "ima_policy": "{}", + /// "mb_refstate": null, + /// "allowlist": null, + /// "revocation_key": "", + /// "accept_tpm_hash_algs": ["sha1", "sha256"], + /// "accept_tpm_encryption_algs": ["ecc", "rsa"] + /// } + /// ``` + /// + /// # Returns + /// + /// Returns the verifier's response containing agent status and configuration. + /// + /// # Errors + /// + /// This method can fail if: + /// - Agent UUID is invalid or already exists + /// - Required agent data is missing or invalid + /// - Agent is not registered with the registrar + /// - Network communication fails + /// - Verifier service returns an error + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::verifier::VerifierClient; + /// use serde_json::json; + /// + /// # async fn example(client: &VerifierClient) -> Result<(), Box> { + /// let agent_data = json!({ + /// "ip": "192.168.1.100", + /// "port": 9002, + /// "tpm_policy": "{}", + /// "ima_policy": "{}" + /// }); + /// + /// let result = client.add_agent("550e8400-e29b-41d4-a716-446655440000", agent_data).await?; + /// println!("Agent added successfully: {:?}", result); + /// # Ok(()) + /// # } + /// ``` + pub async fn add_agent( + &self, + agent_uuid: &str, + data: Value, + ) -> Result { + crate::client::base::validate_agent_id(agent_uuid)?; + debug!("Adding agent {agent_uuid} to verifier"); + + // v3: POST /agents with JSON:API body (agent ID in resource "id") + // v2: POST /agents/:agent_uuid with plain JSON + let (url, body, content_type) = if is_v3(&self.api_version) { + ( + format!( + "{}/v{}/agents/", + self.base.base_url, self.api_version + ), + json_api_resource("agent", Some(agent_uuid), data), + Some(JSON_API_CONTENT_TYPE.to_string()), + ) + } else { + ( + format!( + "{}/v{}/agents/{}", + self.base.base_url, self.api_version, agent_uuid + ), + data, + None, + ) + }; + + debug!( + "POST {url} with data: {}", + serde_json::to_string_pretty(&body) + .unwrap_or_else(|_| "Invalid JSON".to_string()) + ); + + let response = self + .base + .client + .get_json_request_from_struct( + Method::POST, + &url, + &body, + content_type, + ) + .map_err(KeylimectlError::Json)? + .send() + .await + .with_context(|| { + "Failed to send add agent request to verifier".to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Get agent information from the verifier + /// + /// Retrieves detailed information about a specific agent, including its + /// current operational state, attestation status, and configuration. + /// + /// # Arguments + /// + /// * `agent_uuid` - Unique identifier for the agent + /// + /// # Returns + /// + /// Returns `Some(Value)` containing agent information if found, + /// or `None` if the agent doesn't exist on the verifier. + /// + /// # Agent Information + /// + /// The returned data includes: + /// - `operational_state`: Current state ("Start", "Tenant Start", "Get Quote", etc.) + /// - `ip`: Agent IP address + /// - `port`: Agent port + /// - `verifier_ip`: Verifier IP address + /// - `verifier_port`: Verifier port + /// - `tpm_policy`: Current TPM policy + /// - `ima_policy`: Current IMA policy + /// - `last_event_id`: Latest event identifier + /// + /// # Errors + /// + /// This method can fail if: + /// - Agent UUID format is invalid + /// - Network communication fails + /// - Verifier service returns an error + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::verifier::VerifierClient; + /// + /// # async fn example(client: &VerifierClient) -> Result<(), Box> { + /// match client.get_agent("550e8400-e29b-41d4-a716-446655440000").await? { + /// Some(agent) => { + /// println!("Agent state: {}", agent["operational_state"]); + /// println!("Agent IP: {}", agent["ip"]); + /// } + /// None => println!("Agent not found on verifier"), + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_agent( + &self, + agent_uuid: &str, + ) -> Result, KeylimectlError> { + crate::client::base::validate_agent_id(agent_uuid)?; + debug!("Getting agent {agent_uuid} from verifier"); + + // Try API v3.0+ first, fallback to v2.x if not implemented + #[cfg(feature = "api-v3")] + if is_v3(&self.api_version) { + match self.get_agent_v3(agent_uuid).await { + Ok(result) => return Ok(result), + Err(KeylimectlError::Api { status: 404, .. }) => { + debug!("V3.0 get agent endpoint not implemented, falling back to v2.x"); + // Continue to v2.x fallback below + } + Err(e) => return Err(e), + } + } + + // V2.x endpoint (or fallback from v3.0) + #[cfg(feature = "api-v2")] + { + let url = format!( + "{}/v2.1/agents/{}", // Use v2.1 as stable legacy version + self.base.base_url, agent_uuid + ); + + debug!("GET {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send get agent request to verifier".to_string() + })?; + + match response.status() { + StatusCode::OK => { + let json_response: Value = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from)?; + Ok(Some(json_response)) + } + StatusCode::NOT_FOUND => Ok(None), + _ => { + let error_response: Result = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from); + match error_response { + Ok(_) => Ok(None), + Err(e) => Err(e), + } + } + } + } + + #[cfg(not(feature = "api-v2"))] + Err(KeylimectlError::validation( + "v3.0 endpoint failed and v2.x fallback not enabled", + )) + } + + /// Get agent using v3.0 API (when implemented) + #[cfg(feature = "api-v3")] + async fn get_agent_v3( + &self, + agent_uuid: &str, + ) -> Result, KeylimectlError> { + let url = format!( + "{}/v{}/agents/{}", + self.base.base_url, self.api_version, agent_uuid + ); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send get agent request to verifier (v3.0)" + .to_string() + })?; + + match response.status() { + StatusCode::OK => { + let json_response: Value = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from)?; + Ok(Some(json_response)) + } + StatusCode::NOT_FOUND => Ok(None), + _ => { + let error_response: Result = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from); + match error_response { + Ok(_) => Ok(None), + Err(e) => Err(e), + } + } + } + } + + /// Delete an agent from the verifier + /// + /// Removes an agent from verifier monitoring, stopping all attestation + /// activities for that agent. The agent will no longer be monitored + /// for integrity violations. + /// + /// # Arguments + /// + /// * `agent_uuid` - Unique identifier for the agent to remove + /// + /// # Returns + /// + /// Returns the verifier's response confirming deletion. + /// + /// # Behavior + /// + /// - Stops all active monitoring for the agent + /// - Removes agent from verifier's active agent list + /// - Does NOT remove agent from registrar (separate operation) + /// - Gracefully handles requests for non-existent agents + /// + /// # Errors + /// + /// This method can fail if: + /// - Agent UUID format is invalid + /// - Network communication fails + /// - Verifier service returns an error + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::verifier::VerifierClient; + /// + /// # async fn example(client: &VerifierClient) -> Result<(), Box> { + /// let result = client.delete_agent("550e8400-e29b-41d4-a716-446655440000").await?; + /// println!("Agent removed: {:?}", result); + /// # Ok(()) + /// # } + /// ``` + pub async fn delete_agent( + &self, + agent_uuid: &str, + ) -> Result { + crate::client::base::validate_agent_id(agent_uuid)?; + debug!("Deleting agent {agent_uuid} from verifier"); + + // Try API v3.0+ first, fallback to v2.x if not implemented + #[cfg(feature = "api-v3")] + if is_v3(&self.api_version) { + match self.delete_agent_v3(agent_uuid).await { + Ok(result) => return Ok(result), + Err(KeylimectlError::Api { status: 404, .. }) => { + debug!("V3.0 delete endpoint not implemented, falling back to v2.x"); + // Continue to v2.x fallback below + } + Err(e) => return Err(e), + } + } + + // V2.x endpoint (or fallback from v3.0) + #[cfg(feature = "api-v2")] + { + let url = format!( + "{}/v2.1/agents/{}", // Use v2.1 as stable legacy version + self.base.base_url, agent_uuid + ); + + debug!("DELETE {url}"); + + let response = self + .base + .client + .get_request(Method::DELETE, &url) + .send() + .await + .with_context(|| { + "Failed to send delete agent request to verifier" + .to_string() + })?; + + let http_status = response.status().as_u16(); + let mut result = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from)?; + if let Some(obj) = result.as_object_mut() { + let _ = + obj.insert("http_status".to_string(), json!(http_status)); + } + Ok(result) + } + + #[cfg(not(feature = "api-v2"))] + Err(KeylimectlError::validation( + "v3.0 endpoint failed and v2.x fallback not enabled", + )) + } + + /// Delete agent using v3.0 API (when implemented) + #[cfg(feature = "api-v3")] + async fn delete_agent_v3( + &self, + agent_uuid: &str, + ) -> Result { + let url = format!( + "{}/v{}/agents/{}", + self.base.base_url, self.api_version, agent_uuid + ); + + debug!("DELETE {url}"); + + let response = self + .base + .client + .get_request(Method::DELETE, &url) + .send() + .await + .with_context(|| { + "Failed to send delete agent request to verifier (v3.0)" + .to_string() + })?; + + let http_status = response.status().as_u16(); + let mut result = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from)?; + if let Some(obj) = result.as_object_mut() { + let _ = obj.insert("http_status".to_string(), json!(http_status)); + } + Ok(result) + } + + /// Reactivate an agent on the verifier + pub async fn reactivate_agent( + &self, + agent_uuid: &str, + ) -> Result { + crate::client::base::validate_agent_id(agent_uuid)?; + debug!("Reactivating agent {agent_uuid} on verifier"); + + // Try API v3.0+ first, fallback to v2.x if not implemented + #[cfg(feature = "api-v3")] + if is_v3(&self.api_version) { + match self.reactivate_agent_v3(agent_uuid).await { + Ok(result) => return Ok(result), + Err(KeylimectlError::Api { status: 404, .. }) => { + debug!("V3.0 reactivate endpoint not implemented, falling back to v2.x"); + // Continue to v2.x fallback below + } + Err(e) => return Err(e), + } + } + + // V2.x endpoint (or fallback from v3.0) + #[cfg(feature = "api-v2")] + { + let url = format!( + "{}/v2.1/agents/{}/reactivate", // Use v2.1 as stable legacy version + self.base.base_url, agent_uuid + ); + + let response = self + .base + .client + .get_request(Method::PUT, &url) + .body("") + .send() + .await + .with_context(|| { + "Failed to send reactivate agent request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + #[cfg(not(feature = "api-v2"))] + Err(KeylimectlError::validation( + "v3.0 endpoint failed and v2.x fallback not enabled", + )) + } + + /// Reactivate agent using v3.0 API + /// + /// In v3, reactivation is done via PATCH /agents/:agent_id with + /// `accept_attestations: true` in the body (agent resource mutation). + #[cfg(feature = "api-v3")] + async fn reactivate_agent_v3( + &self, + agent_uuid: &str, + ) -> Result { + let url = format!( + "{}/v{}/agents/{}", + self.base.base_url, self.api_version, agent_uuid + ); + + let body = json_api_resource( + "agent", + Some(agent_uuid), + json!({"accept_attestations": true}), + ); + + let response = self + .base + .client + .get_json_request_from_struct( + Method::PATCH, + &url, + &body, + Some(JSON_API_CONTENT_TYPE.to_string()), + ) + .map_err(KeylimectlError::Json)? + .send() + .await + .with_context(|| { + "Failed to send reactivate agent request to verifier (v3.0)" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// List all agents on the verifier + /// + /// Retrieves a list of all agents currently being monitored by the verifier. + /// This provides a high-level overview of the attestation infrastructure. + /// + /// # Arguments + /// + /// * `verifier_id` - Optional verifier instance identifier for multi-verifier setups + /// + /// # Returns + /// + /// Returns a JSON object containing: + /// ```json + /// { + /// "results": { + /// "agent-uuid-1": "operational_state", + /// "agent-uuid-2": "operational_state", + /// ... + /// } + /// } + /// ``` + /// + /// # Operational States + /// + /// Common operational states include: + /// - `"Start"`: Agent initialization + /// - `"Tenant Start"`: Verifier-side initialization + /// - `"Get Quote"`: Requesting TPM quote + /// - `"Provide V"`: Providing verification data + /// - `"Provide V (Retry)"`: Retrying verification + /// - `"Failed"`: Agent failed attestation + /// - `"Terminated"`: Agent was terminated + /// + /// # Errors + /// + /// This method can fail if: + /// - Network communication fails + /// - Verifier service returns an error + /// - Invalid verifier_id specified + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::verifier::VerifierClient; + /// + /// # async fn example(client: &VerifierClient) -> Result<(), Box> { + /// // List all agents + /// let agents = client.list_agents(None).await?; + /// let agent_count = agents["results"].as_object().unwrap().len(); //#[allow_ci] + /// println!("Monitoring {} agents", agent_count); + /// + /// // List agents for specific verifier + /// let agents = client.list_agents(Some("verifier-1")).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn list_agents( + &self, + verifier_id: Option<&str>, + ) -> Result { + debug!("Listing agents on verifier"); + + // Try API v3.0+ first, fallback to v2.x if not implemented + #[cfg(feature = "api-v3")] + if is_v3(&self.api_version) { + match self.list_agents_v3(verifier_id).await { + Ok(result) => return Ok(result), + Err(KeylimectlError::Api { status: 404, .. }) => { + debug!("V3.0 list agents endpoint not implemented, falling back to v2.x"); + // Continue to v2.x fallback below + } + Err(e) => return Err(e), + } + } + + // V2.x endpoint (or fallback from v3.0) + #[cfg(feature = "api-v2")] + { + let mut url = format!("{}/v2.1/agents/", self.base.base_url); // Use v2.1 as stable legacy version + + if let Some(vid) = verifier_id { + url.push_str(&format!("?verifier={vid}")); + } + + debug!("GET {url}"); + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send list agents request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + #[cfg(not(feature = "api-v2"))] + Err(KeylimectlError::validation( + "v3.0 endpoint failed and v2.x fallback not enabled", + )) + } + + /// List agents using v3.0 API (when implemented) + #[cfg(feature = "api-v3")] + async fn list_agents_v3( + &self, + verifier_id: Option<&str>, + ) -> Result { + let mut url = + format!("{}/v{}/agents/", self.base.base_url, self.api_version); + + if let Some(vid) = verifier_id { + url.push_str(&format!("?verifier={vid}")); + } + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send list agents request to verifier (v3.0)" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Get bulk information for all agents + /// + /// Retrieves detailed information for all agents in a single request. + /// This is more efficient than calling `get_agent()` for each agent + /// individually when you need comprehensive agent data. + /// + /// # Arguments + /// + /// * `verifier_id` - Optional verifier instance identifier for multi-verifier setups + /// + /// # Returns + /// + /// Returns detailed information for all agents: + /// ```json + /// { + /// "results": { + /// "agent-uuid-1": { + /// "operational_state": "Get Quote", + /// "ip": "192.168.1.100", + /// "port": 9002, + /// "verifier_ip": "192.168.1.1", + /// "verifier_port": 8881, + /// "tpm_policy": "{}", + /// "ima_policy": "{}" + /// }, + /// ... + /// } + /// } + /// ``` + /// + /// # Performance + /// + /// This method is optimized for bulk operations and should be preferred + /// over multiple individual `get_agent()` calls when retrieving data + /// for multiple agents. + /// + /// # Errors + /// + /// This method can fail if: + /// - Network communication fails + /// - Verifier service returns an error + /// - Invalid verifier_id specified + /// - Response payload is too large (very large deployments) + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::client::verifier::VerifierClient; + /// + /// # async fn example(client: &VerifierClient) -> Result<(), Box> { + /// let bulk_info = client.get_bulk_info(None).await?; + /// + /// if let Some(results) = bulk_info["results"].as_object() { + /// for (uuid, info) in results { + /// println!("Agent {}: {}", uuid, info["operational_state"]); + /// } + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_bulk_info( + &self, + verifier_id: Option<&str>, + ) -> Result { + debug!("Getting bulk agent info from verifier"); + + // Try API v3.0+ first, fallback to v2.x if not implemented + #[cfg(feature = "api-v3")] + if is_v3(&self.api_version) { + match self.get_bulk_info_v3(verifier_id).await { + Ok(result) => return Ok(result), + Err(KeylimectlError::Api { status: 404, .. }) => { + debug!("V3.0 bulk info endpoint not implemented, falling back to v2.x"); + // Continue to v2.x fallback below + } + Err(e) => return Err(e), + } + } + + // V2.x endpoint (or fallback from v3.0) + #[cfg(feature = "api-v2")] + { + let mut url = format!( + "{}/v2.1/agents/?bulk=true", // Use v2.1 as stable legacy version + self.base.base_url + ); + + if let Some(vid) = verifier_id { + url.push_str(&format!("&verifier={vid}")); + } + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send bulk info request to verifier".to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + #[cfg(not(feature = "api-v2"))] + Err(KeylimectlError::validation( + "v3.0 endpoint failed and v2.x fallback not enabled", + )) + } + + /// Get bulk info using v3.0 API (when implemented) + #[cfg(feature = "api-v3")] + async fn get_bulk_info_v3( + &self, + verifier_id: Option<&str>, + ) -> Result { + let mut url = format!( + "{}/v{}/agents/?bulk=true", + self.base.base_url, self.api_version + ); + + if let Some(vid) = verifier_id { + url.push_str(&format!("&verifier={vid}")); + } + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send bulk info request to verifier (v3.0)" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Add a runtime policy + pub async fn add_runtime_policy( + &self, + policy_name: &str, + policy_data: Value, + ) -> Result { + debug!("Adding runtime policy {policy_name} to verifier"); + + // Try API v3.0+ first, fallback to v2.x if not implemented + #[cfg(feature = "api-v3")] + if is_v3(&self.api_version) { + match self + .add_runtime_policy_v3(policy_name, policy_data.clone()) + .await + { + Ok(result) => return Ok(result), + Err(KeylimectlError::Api { status: 404, .. }) => { + debug!("V3.0 runtime policy endpoint not implemented, falling back to v2.x"); + // Continue to v2.x fallback below + } + Err(e) => return Err(e), + } + } + + // V2.x endpoint (or fallback from v3.0) + #[cfg(feature = "api-v2")] + { + let url = format!( + "{}/v2.1/allowlists/{}", // Use v2.1 as stable legacy version + self.base.base_url, policy_name + ); + + debug!( + "POST {} with data: {}", + url, + serde_json::to_string_pretty(&policy_data) + .unwrap_or_else(|_| "Invalid JSON".to_string()) + ); + + let response = self + .base + .client + .get_json_request_from_struct( + Method::POST, + &url, + &policy_data, + None, + ) + .map_err(KeylimectlError::Json)? + .send() + .await + .with_context(|| { + "Failed to send add runtime policy request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + #[cfg(not(feature = "api-v2"))] + Err(KeylimectlError::validation( + "v3.0 endpoint failed and v2.x fallback not enabled", + )) + } + + /// Add runtime policy using v3.0 API + /// + /// In v3, POST /policies/ima with JSON:API body (name in attributes). + #[cfg(feature = "api-v3")] + async fn add_runtime_policy_v3( + &self, + policy_name: &str, + mut policy_data: Value, + ) -> Result { + let url = format!( + "{}/v{}/policies/ima", + self.base.base_url, self.api_version + ); + + if let Some(obj) = policy_data.as_object_mut() { + let _ = obj.entry("name").or_insert(json!(policy_name)); + } + + let body = json_api_resource("ima_policy", None, policy_data); + + let response = self + .base + .client + .get_json_request_from_struct( + Method::POST, + &url, + &body, + Some(JSON_API_CONTENT_TYPE.to_string()), + ) + .map_err(KeylimectlError::Json)? + .send() + .await + .with_context(|| { + "Failed to send add runtime policy request to verifier (v3.0)" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Get a runtime policy + pub async fn get_runtime_policy( + &self, + policy_name: &str, + ) -> Result, KeylimectlError> { + debug!("Getting runtime policy {policy_name} from verifier"); + + // v3: GET /policies/ima/:name + // v2: GET /allowlists/:name + let url = if is_v3(&self.api_version) { + format!( + "{}/v{}/policies/ima/{}", + self.base.base_url, self.api_version, policy_name + ) + } else { + format!( + "{}/v{}/allowlists/{}", + self.base.base_url, self.api_version, policy_name + ) + }; + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send get runtime policy request to verifier" + .to_string() + })?; + + match response.status() { + StatusCode::OK => { + let json_response: Value = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from)?; + Ok(Some(json_response)) + } + StatusCode::NOT_FOUND => Ok(None), + _ => { + let error_response: Result = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from); + match error_response { + Ok(_) => Ok(None), + Err(e) => Err(e), + } + } + } + } + + /// Update a runtime policy + pub async fn update_runtime_policy( + &self, + policy_name: &str, + mut policy_data: Value, + ) -> Result { + debug!("Updating runtime policy {policy_name} on verifier"); + + // v3: PATCH /policies/ima/:name with JSON:API body + // v2: PUT /allowlists/:name with plain JSON + let (method, url, body, content_type) = if is_v3(&self.api_version) { + if let Some(obj) = policy_data.as_object_mut() { + let _ = obj.entry("name").or_insert(json!(policy_name)); + } + ( + Method::PATCH, + format!( + "{}/v{}/policies/ima/{}", + self.base.base_url, self.api_version, policy_name + ), + json_api_resource("ima_policy", None, policy_data), + Some(JSON_API_CONTENT_TYPE.to_string()), + ) + } else { + ( + Method::PUT, + format!( + "{}/v{}/allowlists/{}", + self.base.base_url, self.api_version, policy_name + ), + policy_data, + None, + ) + }; + + let response = self + .base + .client + .get_json_request_from_struct(method, &url, &body, content_type) + .map_err(KeylimectlError::Json)? + .send() + .await + .with_context(|| { + "Failed to send update runtime policy request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Delete a runtime policy + pub async fn delete_runtime_policy( + &self, + policy_name: &str, + ) -> Result { + debug!("Deleting runtime policy {policy_name} from verifier"); + + // v3: DELETE /policies/ima/:name + // v2: DELETE /allowlists/:name + let url = if is_v3(&self.api_version) { + format!( + "{}/v{}/policies/ima/{}", + self.base.base_url, self.api_version, policy_name + ) + } else { + format!( + "{}/v{}/allowlists/{}", + self.base.base_url, self.api_version, policy_name + ) + }; + + let response = self + .base + .client + .get_request(Method::DELETE, &url) + .send() + .await + .with_context(|| { + "Failed to send delete runtime policy request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// List runtime policies + pub async fn list_runtime_policies( + &self, + ) -> Result { + debug!("Listing runtime policies on verifier"); + + // v3: GET /policies/ima + // v2: GET /allowlists + let url = if is_v3(&self.api_version) { + format!( + "{}/v{}/policies/ima", + self.base.base_url, self.api_version + ) + } else { + format!( + "{}/v{}/allowlists/", + self.base.base_url, self.api_version + ) + }; + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send list runtime policies request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Add a measured boot policy + pub async fn add_mb_policy( + &self, + policy_name: &str, + mut policy_data: Value, + ) -> Result { + debug!("Adding measured boot policy {policy_name} to verifier"); + + // v3: POST /refstates/uefi with JSON:API body (name in attributes) + // v2: POST /mbpolicies/:name with plain JSON + let (url, body, content_type) = if is_v3(&self.api_version) { + if let Some(obj) = policy_data.as_object_mut() { + let _ = obj.entry("name").or_insert(json!(policy_name)); + } + ( + format!( + "{}/v{}/refstates/uefi", + self.base.base_url, self.api_version + ), + json_api_resource("mb_policy", None, policy_data), + Some(JSON_API_CONTENT_TYPE.to_string()), + ) + } else { + ( + format!( + "{}/v{}/mbpolicies/{}", + self.base.base_url, self.api_version, policy_name + ), + policy_data, + None, + ) + }; + + let response = self + .base + .client + .get_json_request_from_struct( + Method::POST, + &url, + &body, + content_type, + ) + .map_err(KeylimectlError::Json)? + .send() + .await + .with_context(|| { + "Failed to send add measured boot policy request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Get a measured boot policy + pub async fn get_mb_policy( + &self, + policy_name: &str, + ) -> Result, KeylimectlError> { + debug!("Getting measured boot policy {policy_name} from verifier"); + + // v3: GET /refstates/uefi/:name + // v2: GET /mbpolicies/:name + let url = if is_v3(&self.api_version) { + format!( + "{}/v{}/refstates/uefi/{}", + self.base.base_url, self.api_version, policy_name + ) + } else { + format!( + "{}/v{}/mbpolicies/{}", + self.base.base_url, self.api_version, policy_name + ) + }; + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send get measured boot policy request to verifier" + .to_string() + })?; + + match response.status() { + StatusCode::OK => { + let json_response: Value = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from)?; + Ok(Some(json_response)) + } + StatusCode::NOT_FOUND => Ok(None), + _ => { + let error_response: Result = self + .base + .handle_response(response) + .await + .map_err(KeylimectlError::from); + match error_response { + Ok(_) => Ok(None), + Err(e) => Err(e), + } + } + } + } + + /// Update a measured boot policy + pub async fn update_mb_policy( + &self, + policy_name: &str, + policy_data: Value, + ) -> Result { + debug!("Updating measured boot policy {policy_name} on verifier"); + + // v3: PATCH /refstates/uefi/:name with JSON:API body + // v2: PUT /mbpolicies/:name with plain JSON + let (method, url, body, content_type) = if is_v3(&self.api_version) { + ( + Method::PATCH, + format!( + "{}/v{}/refstates/uefi/{}", + self.base.base_url, self.api_version, policy_name + ), + json_api_resource("mb_policy", None, policy_data), + Some(JSON_API_CONTENT_TYPE.to_string()), + ) + } else { + ( + Method::PUT, + format!( + "{}/v{}/mbpolicies/{}", + self.base.base_url, self.api_version, policy_name + ), + policy_data, + None, + ) + }; + + let response = self + .base + .client + .get_json_request_from_struct(method, &url, &body, content_type) + .map_err(KeylimectlError::Json)? + .send() + .await + .with_context(|| { + "Failed to send update measured boot policy request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Delete a measured boot policy + pub async fn delete_mb_policy( + &self, + policy_name: &str, + ) -> Result { + debug!("Deleting measured boot policy {policy_name} from verifier"); + + // v3: DELETE /refstates/uefi/:name + // v2: DELETE /mbpolicies/:name + let url = if is_v3(&self.api_version) { + format!( + "{}/v{}/refstates/uefi/{}", + self.base.base_url, self.api_version, policy_name + ) + } else { + format!( + "{}/v{}/mbpolicies/{}", + self.base.base_url, self.api_version, policy_name + ) + }; + + let response = self + .base + .client + .get_request(Method::DELETE, &url) + .send() + .await + .with_context(|| { + "Failed to send delete measured boot policy request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// List measured boot policies + pub async fn list_mb_policies(&self) -> Result { + debug!("Listing measured boot policies on verifier"); + + // v3: GET /refstates/uefi + // v2: GET /mbpolicies + let url = if is_v3(&self.api_version) { + format!( + "{}/v{}/refstates/uefi", + self.base.base_url, self.api_version + ) + } else { + format!( + "{}/v{}/mbpolicies/", + self.base.base_url, self.api_version + ) + }; + + let response = self + .base + .client + .get_request(Method::GET, &url) + .send() + .await + .with_context(|| { + "Failed to send list measured boot policies request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Verify attestation evidence via the verifier's one-shot endpoint. + /// + /// Posts evidence data to `POST /v{version}/verify/evidence` and + /// returns the verification result. + pub async fn verify_evidence( + &self, + evidence_data: Value, + ) -> Result { + if !is_v3(&self.api_version) { + return Err(KeylimectlError::Client( + crate::client::error::ClientError::Configuration { + message: format!( + "Evidence verification requires API v3.0+, but verifier is running v{}", + self.api_version + ), + }, + )); + } + + debug!("Verifying evidence via verifier"); + + let url = format!( + "{}/v{}/verify/evidence", + self.base.base_url, self.api_version + ); + + let (body, content_type) = ( + json_api_resource("evidence", None, evidence_data), + Some(JSON_API_CONTENT_TYPE.to_string()), + ); + + let response = self + .base + .client + .get_json_request_from_struct( + Method::POST, + &url, + &body, + content_type, + ) + .map_err(KeylimectlError::Json)? + .send() + .await + .with_context(|| { + "Failed to send verify evidence request to verifier" + .to_string() + })?; + + self.base + .handle_response(response) + .await + .map_err(KeylimectlError::from) + } + + /// Get the detected API version + pub fn api_version(&self) -> &str { + &self.api_version + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::base::BaseClient; + use crate::config::{ + AgentConfig, ClientConfig, TlsConfig, VerifierConfig, + }; + + /// Create a test configuration + fn create_test_config() -> Config { + Config { + loaded_from: None, + cli_overrides: crate::config::CliOverrides::default(), + verifier: VerifierConfig { + ip: "127.0.0.1".to_string(), + port: 8881, + id: Some("test-verifier".to_string()), + }, + registrar: crate::config::RegistrarConfig::default(), + tls: TlsConfig { + client_cert: None, + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: false, // Disable for testing + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }, + client: ClientConfig { + timeout: 30, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + }, + agent: AgentConfig::default(), + } + } + + #[test] + fn test_verifier_client_new() { + let config = create_test_config(); + let result = VerifierClient::new_without_version_detection(&config); + + assert!(result.is_ok()); + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://127.0.0.1:8881"); + assert_eq!( + client.api_version, + crate::api_versions::DEFAULT_API_VERSION + ); + } + + #[test] + fn test_verifier_client_new_with_ipv6() { + let mut config = create_test_config(); + config.verifier.ip = "::1".to_string(); + + let result = VerifierClient::new_without_version_detection(&config); + assert!(result.is_ok()); + + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://[::1]:8881"); + } + + #[test] + fn test_verifier_client_new_with_bracketed_ipv6() { + let mut config = create_test_config(); + config.verifier.ip = "[2001:db8::1]".to_string(); + + let result = VerifierClient::new_without_version_detection(&config); + assert!(result.is_ok()); + + let client = result.unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://[2001:db8::1]:8881"); + } + + #[test] + fn test_create_http_client_with_cert_files_nonexistent() { + let mut config = create_test_config(); + config.tls.client_cert = Some("/nonexistent/cert.pem".to_string()); + config.tls.client_key = Some("/nonexistent/key.pem".to_string()); + + let result = BaseClient::create_http_client(&config); + // Should fail because cert files don't exist + assert!(result.is_err()); + + let error = result.unwrap_err(); + assert!(error.to_string().contains("Certificate file error")); + } + + #[test] + fn test_config_validation() { + let config = create_test_config(); + + // Test that our test config is valid + assert!(config.validate().is_ok()); + + // Test base URL generation + assert_eq!(config.verifier_base_url(), "https://127.0.0.1:8881"); + } + + #[test] + fn test_base_url_construction() { + // Test IPv4 + let mut config = create_test_config(); + config.verifier.ip = "192.168.1.100".to_string(); + config.verifier.port = 9001; + + let client = + VerifierClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://192.168.1.100:9001"); + + // Test IPv6 + config.verifier.ip = "2001:db8::1".to_string(); + config.verifier.port = 8881; + + let client = + VerifierClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + assert_eq!(client.base.base_url, "https://[2001:db8::1]:8881"); + } + + // Mock response handler tests + mod response_tests { + use super::*; + use serde_json::json; + + // Note: Testing handle_response requires mocking HTTP responses + // which is complex with reqwest. In a real implementation, we would + // use a mocking library like wiremock or mockito. + + #[test] + fn test_error_codes() { + // Test error code constants and behavior + let api_error = KeylimectlError::api_error( + 404, + "Agent not found".to_string(), + Some(json!({"error": "Agent does not exist"})), + ); + + assert_eq!(api_error.error_code(), "API_ERROR"); + + let json_output = api_error.to_json(); + assert_eq!(json_output["error"]["code"], "API_ERROR"); + assert_eq!(json_output["error"]["details"]["http_status"], 404); + } + + #[test] + fn test_api_error_creation() { + let error = KeylimectlError::api_error( + 500, + "Internal server error".to_string(), + None, + ); + + assert_eq!(error.error_code(), "API_ERROR"); + assert!(error.is_retryable()); // 5xx errors should be retryable + + let error_400 = KeylimectlError::api_error( + 400, + "Bad request".to_string(), + None, + ); + assert!(!error_400.is_retryable()); // 4xx errors should not be retryable + } + } + + // Integration-style tests that would require a running verifier + // These are commented out as they require actual network connectivity + /* + #[tokio::test] + async fn test_add_agent_integration() { + let config = create_test_config(); + let client = VerifierClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + + let agent_data = json!({ + "ip": "192.168.1.100", + "port": 9002, + "tpm_policy": "{}", + "ima_policy": "{}" + }); + + // This would require a running verifier service + // let result = client.add_agent("test-agent-uuid", agent_data).await; + // assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_get_agent_integration() { + let config = create_test_config(); + let client = VerifierClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + + // This would require a running verifier service + // let result = client.get_agent("test-agent-uuid").await; + // Should handle both Some(agent) and None cases + } + + #[tokio::test] + async fn test_list_agents_integration() { + let config = create_test_config(); + let client = VerifierClient::new_without_version_detection(&config).unwrap(); //#[allow_ci] + + // This would require a running verifier service + // let result = client.list_agents(None).await; + // assert!(result.is_ok()); + // + // let agents = result.unwrap(); //#[allow_ci] + // assert!(agents.get("results").is_some()); + } + */ + + // API Version Detection Tests + mod api_version_tests { + use super::*; + use keylime::version::KeylimeRegistrarVersion; + + #[test] + fn test_supported_api_versions_constant() { + // Test that the constant contains expected versions based on enabled features + assert!(!SUPPORTED_API_VERSIONS.is_empty()); + + #[cfg(all(feature = "api-v2", feature = "api-v3"))] + assert_eq!( + SUPPORTED_API_VERSIONS, + &["2.0", "2.1", "2.2", "2.3", "3.0"] + ); + + #[cfg(all(feature = "api-v2", not(feature = "api-v3")))] + assert_eq!(SUPPORTED_API_VERSIONS, &["2.0", "2.1", "2.2", "2.3"]); + + #[cfg(all(not(feature = "api-v2"), feature = "api-v3"))] + assert_eq!(SUPPORTED_API_VERSIONS, &["3.0"]); + + // Verify versions are in ascending order (oldest to newest) + for i in 1..SUPPORTED_API_VERSIONS.len() { + let prev: f32 = + SUPPORTED_API_VERSIONS[i - 1].parse().unwrap(); //#[allow_ci] + let curr: f32 = SUPPORTED_API_VERSIONS[i].parse().unwrap(); //#[allow_ci] + assert!( + prev < curr, + "API versions should be in ascending order" + ); + } + } + + #[test] + fn test_response_structure_deserialization() { + let json_str = r#"{ + "code": 200, + "status": "OK", + "results": { + "current_version": "2.1", + "supported_versions": ["2.0", "2.1", "2.2", "3.0"] + } + }"#; + + let response: Result, _> = + serde_json::from_str(json_str); + + assert!(response.is_ok()); + let response = response.unwrap(); //#[allow_ci] + assert_eq!(response.results.current_version, "2.1"); + assert_eq!( + response.results.supported_versions, + vec!["2.0", "2.1", "2.2", "3.0"] + ); + } + + #[test] + fn test_api_version_iteration_order() { + // Test that iter().rev() gives us newest to oldest as expected + let versions: Vec<&str> = + SUPPORTED_API_VERSIONS.iter().rev().copied().collect(); + + // Should be newest first (last element of ascending array) + assert_eq!( + versions[0], + *SUPPORTED_API_VERSIONS.last().unwrap() //#[allow_ci] + ); + + // Verify it's actually newest to oldest + for i in 1..versions.len() { + let prev: f32 = versions[i - 1].parse().unwrap(); //#[allow_ci] + let curr: f32 = versions[i].parse().unwrap(); //#[allow_ci] + assert!( + prev > curr, + "Reversed iteration should give newest to oldest" + ); + } + } + + #[test] + fn test_version_string_parsing() { + // Test that our version strings can be parsed as valid version numbers + for version in SUPPORTED_API_VERSIONS { + let parsed: Result = version.parse(); + assert!( + parsed.is_ok(), + "Version string '{version}' should parse as number" + ); + + let num = parsed.unwrap(); //#[allow_ci] + assert!(num >= 1.0, "Version should be >= 1.0"); + assert!(num < 10.0, "Version should be reasonable"); + } + } + + #[test] + fn test_base_url_construction_with_different_versions() { + let config = create_test_config(); + let mut client = + VerifierClient::new_without_version_detection(&config) + .unwrap(); //#[allow_ci] + + // Test URL construction with different API versions + for version in SUPPORTED_API_VERSIONS { + client.api_version = version.to_string(); + + // Simulate how URLs would be constructed in actual methods + let expected_pattern = format!("/v{version}/agents/"); + let test_url = format!( + "{}/v{}/agents/test-uuid", + client.base.base_url, client.api_version + ); + + assert!(test_url.contains(&expected_pattern)); + assert!(test_url.contains(&client.base.base_url)); + assert!(test_url.contains("test-uuid")); + } + } + + #[test] + #[allow(clippy::const_is_empty)] + fn test_version_constants_consistency() { + // Ensure our constants are consistent with expected patterns + assert!(!SUPPORTED_API_VERSIONS.is_empty()); // Known constant value + + // All supported versions should be valid version strings + for version in SUPPORTED_API_VERSIONS { + assert!(!version.is_empty()); + assert!(version + .chars() + .all(|c| c.is_ascii_digit() || c == '.')); + assert!(version.contains('.')); + } + } + + #[test] + fn test_add_agent_url_construction() { + // Test that add_agent URLs are constructed correctly for different API versions + let config = create_test_config(); + let mut client = + VerifierClient::new_without_version_detection(&config) + .unwrap(); //#[allow_ci] + let base_url = &client.base.base_url; + let agent_uuid = "test-agent-uuid"; + + // Test API v2.x (includes agent UUID in URL) + client.api_version = "2.1".to_string(); + let api_version_f32 = + client.api_version.parse::().unwrap_or(2.1); + let url_v2 = if api_version_f32 >= 3.0 { + format!("{base_url}/v{}/agents/", client.api_version) + } else { + format!( + "{base_url}/v{}/agents/{agent_uuid}", + client.api_version + ) + }; + assert_eq!( + url_v2, + format!("{base_url}/v2.1/agents/{agent_uuid}") + ); + assert!(url_v2.contains(agent_uuid)); + + // Test API v3.0 (excludes agent UUID from URL) + client.api_version = "3.0".to_string(); + let api_version_f32 = + client.api_version.parse::().unwrap_or(2.1); + let url_v3 = if api_version_f32 >= 3.0 { + format!("{base_url}/v{}/agents/", client.api_version) + } else { + format!( + "{base_url}/v{}/agents/{agent_uuid}", + client.api_version + ) + }; + assert_eq!(url_v3, format!("{base_url}/v3.0/agents/")); + assert!(!url_v3.contains(agent_uuid)); + assert!(url_v3.ends_with("/agents/")); + } + } +} diff --git a/keylimectl/src/commands/agent/add.rs b/keylimectl/src/commands/agent/add.rs new file mode 100644 index 000000000..8f147ab58 --- /dev/null +++ b/keylimectl/src/commands/agent/add.rs @@ -0,0 +1,981 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Agent add (enrollment) command +//! +//! Handles both pull model (API 2.x) and push model (API 3.0+) enrollment. + +#[cfg(feature = "api-v2")] +use super::attestation::{ + perform_agent_attestation, perform_key_delivery, verify_key_derivation, + AttestationData, +}; +use super::helpers::{ + load_payload_file, load_policy_file, resolve_tpm_policy_enhanced, +}; +use super::types::AddAgentParams; +use super::types::AddAgentRequest; +#[cfg(feature = "api-v2")] +use crate::client::agent::AgentClient; +use crate::client::factory; +use crate::client::verifier::VerifierClient; +use crate::commands::error::CommandError; +use crate::config::singleton::get_config; +use crate::output::OutputHandler; +use base64::{engine::general_purpose::STANDARD, Engine}; +use log::debug; +use serde_json::{json, Value}; + +/// Add (enroll) an agent to the verifier for continuous attestation monitoring +/// +/// This function implements the correct Keylime enrollment workflow: +/// +/// 1. **Check Registration**: Verify agent is registered with registrar +/// 2. **Enroll with Verifier**: Add agent to verifier with attestation policy +/// +/// The flow differs based on API version: +/// - **API 2.x (Pull Model)**: Includes TPM quote verification and key exchange +/// - **API 3.0+ (Push Model)**: Simplified enrollment, agent pushes attestations +pub(super) async fn add_agent( + params: AddAgentParams<'_>, + output: &OutputHandler, +) -> Result { + // Validate agent ID + if params.agent_id.is_empty() { + return Err(CommandError::invalid_parameter( + "agent_id", + "Agent ID cannot be empty".to_string(), + )); + } + + if params.agent_id.len() > 255 { + return Err(CommandError::invalid_parameter( + "agent_id", + "Agent ID cannot exceed 255 characters".to_string(), + )); + } + + // Check for control characters that might cause issues + if params.agent_id.chars().any(|c| c.is_control()) { + return Err(CommandError::invalid_parameter( + "agent_id", + "Agent ID cannot contain control characters".to_string(), + )); + } + + output.info(format!("Adding agent {} to verifier", params.agent_id)); + + // Step 1: Get agent data from registrar + output.step(1, 4, "Retrieving agent data from registrar"); + + let registrar_client = factory::get_registrar().await.map_err(|e| { + CommandError::connection_error("registrar", e.to_string()) + })?; + let agent_data = registrar_client + .get_agent(params.agent_id) + .await + .map_err(|e| { + CommandError::resource_error( + "registrar", + format!("Failed to retrieve agent data: {e}"), + ) + })?; + + let agent_data = match agent_data { + Some(data) => data, + None => { + return Err(CommandError::agent_operation_failed( + params.agent_id, + "enrollment", + format!( + "Agent not found in registrar. \ + Ensure the agent is running and has completed TPM registration. \ + Check with: keylimectl agent status --registrar {}", + params.agent_id + ), + )); + } + }; + + // Step 2: Determine API version and enrollment approach + output.step(2, 4, "Detecting verifier API version"); + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + + let api_version_str = verifier_client.api_version().to_string(); + let (api_major, _api_minor) = + crate::api_versions::parse_version(&api_version_str); + + // Determine enrollment model based on flags and API version: + // 1. Explicit --push-model flag: always push + // 2. Explicit --pull-model flag: always pull (with deprecation warning for v3.x) + // 3. Auto-detect: push for API >= 3.0, pull for API < 3.0 + let is_push_model = if params.push_model { + true + } else if params.pull_model { + if api_major >= 3 { + log::warn!( + "Pull model is deprecated for API v{api_version_str} verifiers. \ + Consider using push model (default) instead." + ); + } + false + } else { + // Auto-detect based on API version + #[cfg(feature = "api-v3")] + { + api_major >= 3 + } + #[cfg(not(feature = "api-v3"))] + { + false + } + }; + + debug!( + "Detected API version: {api_version_str}, push model: {is_push_model}" + ); + + // Determine agent connection details + let agent_ip = params.ip.map(|s| s.to_string()).or_else(|| { + agent_data + .get("ip") + .and_then(|v| v.as_str().map(|s| s.to_string())) + }); + + let agent_port = match params.port { + Some(p) => Some(p), + None => match agent_data.get("port").and_then(|v| v.as_u64()) { + Some(n) => Some(u16::try_from(n).map_err(|_| { + CommandError::invalid_parameter( + "port", + format!( + "Agent port {n} from registrar is out of range (0-65535)" + ), + ) + })?), + None => None, + }, + }; + + // Extract agent mTLS certificate from registrar data. + // Used as trusted root CA when connecting directly to the agent. + #[cfg(feature = "api-v2")] + let agent_mtls_cert = + agent_data.get("mtls_cert").and_then(|v| v.as_str()); + + // Pull model requires IP and port for direct agent communication + if !is_push_model { + if agent_ip.is_none() { + return Err(CommandError::invalid_parameter( + "ip", + "Agent IP address is required for pull model".to_string(), + )); + } + if agent_port.is_none() { + return Err(CommandError::invalid_parameter( + "port", + "Agent port is required for pull model".to_string(), + )); + } + } + + // Use defaults for push model if not available from registrar + let agent_ip = agent_ip.unwrap_or_else(|| "0.0.0.0".to_string()); + let agent_port = agent_port.unwrap_or(0); + + // Step 3: Perform attestation for pull model + #[allow(unused_assignments, unused_variables)] + let attestation_result = if !is_push_model { + #[cfg(feature = "api-v2")] + { + output.step(3, 4, "Performing TPM attestation (pull model)"); + + // Create agent client for direct communication + let agent_client = AgentClient::builder() + .agent_ip(&agent_ip) + .agent_port(agent_port) + .config(get_config()) + .agent_cert(agent_mtls_cert) + .build() + .await + .map_err(|e| { + CommandError::resource_error("agent", e.to_string()) + })?; + + // Perform TPM quote verification + perform_agent_attestation( + &agent_client, + &agent_data, + params.agent_id, + params.allow_unverified_quote, + output, + ) + .await? + } + #[cfg(not(feature = "api-v2"))] + { + return Err(CommandError::invalid_parameter( + "push_model", + "Pull model is not available (api-v2 feature not enabled). \ + Use --push-model for API v3.0+ enrollment." + .to_string(), + )); + } + } else { + output.step(3, 4, "Skipping agent attestation (push model)"); + #[cfg(feature = "api-v2")] + { + None:: + } + #[cfg(not(feature = "api-v2"))] + { + None::<()> + } + }; + + // Step 4: Enroll agent with verifier + output.step(4, 4, "Enrolling agent with verifier"); + + // Build the request payload based on API version + #[cfg(feature = "api-v2")] + let cv_agent_ip = params.verifier_ip.unwrap_or(&agent_ip); + + if !has_attestation_policy(¶ms) { + return Err(CommandError::invalid_parameter( + "policy", + "At least one attestation policy must be provided: \ + --runtime-policy, --runtime-policy-name, --mb-policy, \ + or --tpm-policy" + .to_string(), + )); + } + + // Resolve TPM policy with enhanced precedence handling. + // Auto-enables PCRs in the mask based on which policies are attached + // (matching the Python tenant's process_policy() behavior). + let tpm_policy = resolve_tpm_policy_enhanced( + params.tpm_policy, + params.mb_policy, + params.runtime_policy.is_some(), + params.mb_policy.is_some(), + )?; + + // Build enrollment request with version-appropriate fields. + // File loading (policies, payload) happens before serialization for + // early input validation — file errors surface before any network calls. + let request = if is_push_model { + // API 3.0+: Push model enrollment. The verifier expects all policy + // fields to be present; defaults are set in AddAgentRequest::new(). + debug!( + "Building push model enrollment request for agent {}", + params.agent_id + ); + let mut req = AddAgentRequest::new( + Some(agent_ip.clone()), + Some(agent_port), + None, // push model: agent connects to verifier, no verifier fields + None, + tpm_policy, + ) + .with_v_key(agent_data.get("v").cloned()) + .with_ak_tpm(agent_data.get("aik_tpm").cloned()) + .with_mtls_cert(agent_data.get("mtls_cert").cloned()) + .with_ima_sign_verification_keys(Some( + agent_data + .get("ima_sign_verification_keys") + .and_then(|v| v.as_str()) + .unwrap_or("[]") + .to_string(), + )) + .with_metadata(Some( + agent_data + .get("metadata") + .and_then(|v| v.as_str()) + .unwrap_or("{}") + .to_string(), + )) + .with_revocation_key(Some( + agent_data + .get("revocation_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + )) + .with_accept_tpm_hash_algs(Some( + get_config().agent.accept_tpm_hash_algs.clone(), + )) + .with_accept_tpm_encryption_algs(Some( + get_config().agent.accept_tpm_encryption_algs.clone(), + )) + .with_accept_tpm_signing_algs(Some( + get_config().agent.accept_tpm_signing_algs.clone(), + )) + .with_supported_version(Some( + agent_data + .get("supported_version") + .and_then(|v| v.as_str()) + .unwrap_or("2.0") + .to_string(), + )) + .with_mb_policy(Some(String::new())) + .with_mb_policy_name(Some( + agent_data + .get("mb_policy_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + )) + .with_mb_refstate(Some("null".to_string())); + + req = apply_file_policies( + req, + params.runtime_policy, + params.runtime_policy_name, + params.runtime_policy_sig_key, + params.mb_policy, + params.payload, + params.cert_dir, + )?; + serde_json::to_value(req)? + } else { + #[cfg(feature = "api-v2")] + { + // API 2.x: Pull model with direct agent communication + let mut req = AddAgentRequest::new( + Some(cv_agent_ip.to_string()), + Some(agent_port), + Some(get_config().verifier.ip.clone()), + Some(get_config().verifier.port), + tpm_policy, + ) + .with_ak_tpm(agent_data.get("aik_tpm").cloned()) + .with_mtls_cert(agent_data.get("mtls_cert").cloned()) + .with_metadata(Some( + agent_data + .get("metadata") + .and_then(|v| v.as_str()) + .unwrap_or("{}") + .to_string(), + )) + .with_ima_sign_verification_keys(Some( + agent_data + .get("ima_sign_verification_keys") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + )) + .with_revocation_key(Some( + agent_data + .get("revocation_key") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + )) + .with_accept_tpm_hash_algs(Some( + get_config().agent.accept_tpm_hash_algs.clone(), + )) + .with_accept_tpm_encryption_algs(Some( + get_config().agent.accept_tpm_encryption_algs.clone(), + )) + .with_accept_tpm_signing_algs(Some( + get_config().agent.accept_tpm_signing_algs.clone(), + )) + .with_supported_version(Some( + agent_data + .get("supported_version") + .and_then(|v| v.as_str()) + .unwrap_or("2.1") + .to_string(), + )) + .with_mb_policy_name(Some( + agent_data + .get("mb_policy_name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + )) + .with_mb_policy(Some( + agent_data + .get("mb_policy") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + )); + + // Add V key from attestation if available + if let Some(attestation) = &attestation_result { + req = req.with_v_key(Some(Value::String( + STANDARD.encode(attestation.v_key.as_slice()), + ))); + } + + req = apply_file_policies( + req, + params.runtime_policy, + params.runtime_policy_name, + params.runtime_policy_sig_key, + params.mb_policy, + params.payload, + params.cert_dir, + )?; + serde_json::to_value(req)? + } + #[cfg(not(feature = "api-v2"))] + { + return Err(CommandError::invalid_parameter( + "push_model", + "Pull model is not available (api-v2 feature not enabled). \ + Use --push-model for API v3.0+ enrollment." + .to_string(), + )); + } + }; + + let response = verifier_client + .add_agent(params.agent_id, request) + .await + .map_err(|e| { + let model = if is_push_model { "push" } else { "pull" }; + CommandError::resource_error( + "verifier", + format!( + "Failed to enroll agent ({model} model): {e}. \ + If the agent already exists on the verifier, use \ + 'keylimectl agent update {agent_id}' to re-enroll, or \ + 'keylimectl agent remove {agent_id}' first to start fresh.", + agent_id = params.agent_id + ), + ) + })?; + + // Step 5: Perform legacy key delivery for API < 3.0 + #[cfg(feature = "api-v2")] + if !is_push_model && attestation_result.is_some() { + let agent_client = AgentClient::builder() + .agent_ip(&agent_ip) + .agent_port(agent_port) + .config(get_config()) + .agent_cert(agent_mtls_cert) + .build() + .await + .map_err(|e| { + CommandError::resource_error( + "agent", + format!( + "Key delivery failed (agent enrolled but key not delivered): {e}. \ + Remove and re-add: keylimectl agent remove {} && keylimectl agent add {}", + params.agent_id, params.agent_id + ), + ) + })?; + + // Deliver U key and payload to agent + if let Some(attestation) = attestation_result { + perform_key_delivery( + &agent_client, + &attestation, + params.payload, + output, + ) + .await?; + + // Verify key derivation if requested + if params.verify { + output.info("Performing key derivation verification"); + verify_key_derivation(&agent_client, &attestation, output) + .await?; + } + } + } + + let enrollment_type = if is_push_model { + "push model" + } else { + "pull model" + }; + output.info(format!( + "Agent {} successfully enrolled with verifier ({})", + params.agent_id, enrollment_type + )); + + // Optional: Wait for first attestation to complete + let attestation_state = if params.wait_for_attestation { + output.info("Waiting for first attestation to complete..."); + let state = poll_attestation_status( + verifier_client, + params.agent_id, + params.attestation_timeout, + output, + ) + .await?; + Some(state) + } else { + None + }; + + let mut result = json!({ + "status": "success", + "message": format!("Agent {} enrolled successfully ({})", params.agent_id, enrollment_type), + "agent_id": params.agent_id, + "api_version": api_version_str, + "push_model": is_push_model, + "results": response + }); + + if let Some(state) = attestation_state { + result["attestation_state"] = json!(state); + } + + Ok(result) +} + +/// Check whether at least one attestation policy is specified. +/// +/// A named runtime policy (`--runtime-policy-name`) that references a +/// policy already stored in the verifier counts as a valid policy +/// specification, alongside inline file-based policies. +fn has_attestation_policy(params: &AddAgentParams) -> bool { + params.runtime_policy.is_some() + || params.runtime_policy_name.is_some() + || params.mb_policy.is_some() + || params.tpm_policy.is_some() +} + +/// Apply file-based policies to a request before serialization. +/// +/// Loads and base64-encodes policy files, reads the payload file, and sets +/// the cert_dir path. Doing this before serialization gives early validation +/// (file not found errors surface before any network calls). +fn apply_file_policies( + mut req: AddAgentRequest, + runtime_policy: Option<&str>, + runtime_policy_name: Option<&str>, + runtime_policy_sig_key: Option<&str>, + mb_policy: Option<&str>, + payload: Option<&str>, + cert_dir: Option<&str>, +) -> Result { + if let Some(policy_path) = runtime_policy { + let content = load_policy_file(policy_path)?; + req = req + .with_runtime_policy(Some(STANDARD.encode(content.as_bytes()))); + } + if let Some(name) = runtime_policy_name { + req = req.with_runtime_policy_name(Some(name.to_string())); + } + if let Some(key_path) = runtime_policy_sig_key { + let key_bytes = std::fs::read(key_path).map_err(|e| { + CommandError::invalid_parameter( + "runtime_policy_sig_key", + format!("Failed to read key file '{key_path}': {e}"), + ) + })?; + req = req.with_runtime_policy_key(Some(Value::String( + STANDARD.encode(&key_bytes), + ))); + } + if let Some(policy_path) = mb_policy { + let content = load_policy_file(policy_path)?; + req = req.with_mb_policy(Some(STANDARD.encode(content.as_bytes()))); + } + if let Some(payload_path) = payload { + let content = load_payload_file(payload_path)?; + req = req.with_payload(Some(content)); + } + if let Some(cert_dir_path) = cert_dir { + req = req.with_cert_dir(Some(cert_dir_path.to_string())); + } + Ok(req) +} + +/// Extract operational state from verifier agent data as a human-readable string. +/// +/// The verifier returns `operational_state` as an integer (0-10) or +/// occasionally as a string. This function handles both and converts +/// integers to their string representation. +fn extract_operational_state(data: &Value) -> Option { + let raw = data + .pointer("/results/operational_state") + .or_else(|| data.get("operational_state")); + + match raw { + Some(Value::String(s)) => Some(s.clone()), + Some(Value::Number(n)) => n.as_u64().map(operational_state_to_str), + _ => None, + } +} + +/// Convert an operational state integer to a human-readable string. +/// +/// State values are defined in keylime/common/states.py. +fn operational_state_to_str(state: u64) -> String { + match state { + 0 => "Registered".to_string(), + 1 => "Start".to_string(), + 2 => "Saved".to_string(), + 3 => "Get Quote".to_string(), + 4 => "Get Quote (retry)".to_string(), + 5 => "Provide V".to_string(), + 6 => "Provide V (retry)".to_string(), + 7 => "Failed".to_string(), + 8 => "Terminated".to_string(), + 9 => "Invalid Quote".to_string(), + 10 => "Tenant Failed".to_string(), + _ => format!("Unknown ({state})"), + } +} + +/// Poll verifier for agent attestation status until it reaches PASS or FAIL. +/// +/// The verifier response includes an `attestation_status` field that is +/// computed from the agent's operational state (for pull mode) or from +/// attestation history (for push mode). The possible values are: +/// - `"PENDING"`: attestation has not yet completed +/// - `"PASS"`: agent has been successfully attested +/// - `"FAIL"`: attestation failed +/// +/// Returns the attestation status string on success ("PASS"). +/// Returns an error if the agent enters a failure state or the timeout expires. +async fn poll_attestation_status( + verifier_client: &VerifierClient, + agent_id: &str, + timeout_secs: u64, + output: &OutputHandler, +) -> Result { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(timeout_secs); + let poll_interval = std::time::Duration::from_secs(2); + + let wait_handle = output.start_wait(format!( + "Waiting for attestation of agent {agent_id}..." + )); + + loop { + if start.elapsed() > timeout { + return Err(CommandError::agent_operation_failed( + agent_id, + "attestation", + format!( + "Timed out waiting for attestation after {timeout_secs}s. \ + The agent may still complete attestation. \ + Check status with: keylimectl agent status {agent_id}" + ), + )); + } + + match verifier_client.get_agent(agent_id).await { + Ok(Some(data)) => { + let attestation_status = extract_attestation_status(&data); + let operational_state = extract_operational_state(&data); + + match attestation_status { + Some("FAIL") => { + // Collect failure details from the response + let severity = data + .pointer("/results/severity_level") + .or_else(|| data.get("severity_level")) + .and_then(|v| v.as_u64()); + let last_event = data + .pointer("/results/last_event_id") + .or_else(|| data.get("last_event_id")) + .and_then(|v| v.as_str()); + + let mut reason = + String::from("Agent attestation failed"); + if let Some(state) = operational_state { + reason.push_str(&format!(" (state: {state})")); + } + if let Some(severity) = severity { + reason + .push_str(&format!(", severity: {severity}")); + } + if let Some(event) = last_event { + reason + .push_str(&format!(", last event: {event}")); + } + + return Err(CommandError::agent_operation_failed( + agent_id, + "attestation", + reason, + )); + } + Some("PASS") => { + drop(wait_handle); + output.info(format!( + "Agent {agent_id} attestation successful" + )); + return Ok("PASS".to_string()); + } + _ => { + // PENDING or missing — update spinner and keep polling + let elapsed = start.elapsed().as_secs(); + let state_str = + operational_state.as_deref().unwrap_or("pending"); + wait_handle.set_message(format!( + "Waiting for attestation of agent {agent_id} \ + ({state_str}, {elapsed}s elapsed)" + )); + debug!( + "Agent attestation status: {:?}, operational_state: {:?}, waiting...", + attestation_status, operational_state + ); + } + } + } + Ok(None) => { + debug!("Agent not yet visible on verifier, waiting..."); + } + Err(e) => { + debug!("Error polling agent status: {e}, retrying..."); + } + } + + tokio::time::sleep(poll_interval).await; + } +} + +/// Extract attestation_status from verifier agent data +/// +/// The verifier computes this field based on operational_state (pull mode) +/// or attestation history (push mode). Values: "PENDING", "PASS", "FAIL". +fn extract_attestation_status(data: &Value) -> Option<&str> { + data.get("results") + .and_then(|r| r.get("attestation_status")) + .and_then(|s| s.as_str()) + .or_else(|| data.get("attestation_status").and_then(|s| s.as_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_operational_state_nested_integer() { + let data = json!({ + "results": { + "operational_state": 3 + } + }); + assert_eq!( + extract_operational_state(&data), + Some("Get Quote".to_string()) + ); + } + + #[test] + fn test_extract_operational_state_nested_string() { + let data = json!({ + "results": { + "operational_state": "Get Quote" + } + }); + assert_eq!( + extract_operational_state(&data), + Some("Get Quote".to_string()) + ); + } + + #[test] + fn test_extract_operational_state_top_level() { + let data = json!({ + "operational_state": 7 + }); + assert_eq!( + extract_operational_state(&data), + Some("Failed".to_string()) + ); + } + + #[test] + fn test_extract_operational_state_missing() { + let data = json!({ + "other_field": "value" + }); + assert_eq!(extract_operational_state(&data), None); + } + + #[test] + fn test_extract_operational_state_empty() { + let data = json!({}); + assert_eq!(extract_operational_state(&data), None); + } + + #[test] + fn test_extract_operational_state_prefers_nested() { + let data = json!({ + "operational_state": 1, + "results": { + "operational_state": 3 + } + }); + assert_eq!( + extract_operational_state(&data), + Some("Get Quote".to_string()) + ); + } + + #[test] + fn test_operational_state_to_str() { + assert_eq!(operational_state_to_str(0), "Registered"); + assert_eq!(operational_state_to_str(1), "Start"); + assert_eq!(operational_state_to_str(2), "Saved"); + assert_eq!(operational_state_to_str(3), "Get Quote"); + assert_eq!(operational_state_to_str(4), "Get Quote (retry)"); + assert_eq!(operational_state_to_str(5), "Provide V"); + assert_eq!(operational_state_to_str(6), "Provide V (retry)"); + assert_eq!(operational_state_to_str(7), "Failed"); + assert_eq!(operational_state_to_str(8), "Terminated"); + assert_eq!(operational_state_to_str(9), "Invalid Quote"); + assert_eq!(operational_state_to_str(10), "Tenant Failed"); + assert_eq!(operational_state_to_str(99), "Unknown (99)"); + } + + #[test] + fn test_extract_attestation_status() { + let data = json!({ + "results": { + "attestation_status": "PASS" + } + }); + assert_eq!(extract_attestation_status(&data), Some("PASS")); + + let data = json!({ + "attestation_status": "FAIL" + }); + assert_eq!(extract_attestation_status(&data), Some("FAIL")); + + let data = json!({}); + assert_eq!(extract_attestation_status(&data), None); + } + + #[test] + fn test_model_auto_detection_logic() { + // Test the auto-detection logic that determines push vs pull model + // Uses (major, minor) tuples to mirror the production code. + + struct ModelParams { + push_model: bool, + pull_model: bool, + api_major: u32, + } + + fn determine_model(params: &ModelParams) -> bool { + if params.push_model { + true + } else if params.pull_model { + false + } else { + params.api_major >= 3 + } + } + + // Explicit --push-model always wins + assert!(determine_model(&ModelParams { + push_model: true, + pull_model: false, + api_major: 2, + })); + assert!(determine_model(&ModelParams { + push_model: true, + pull_model: false, + api_major: 3, + })); + + // Explicit --pull-model forces pull + assert!(!determine_model(&ModelParams { + push_model: false, + pull_model: true, + api_major: 2, + })); + assert!(!determine_model(&ModelParams { + push_model: false, + pull_model: true, + api_major: 3, + })); + + // Auto-detect: push for v3.x, pull for v2.x + assert!(!determine_model(&ModelParams { + push_model: false, + pull_model: false, + api_major: 2, + })); + assert!(determine_model(&ModelParams { + push_model: false, + pull_model: false, + api_major: 3, + })); + assert!(determine_model(&ModelParams { + push_model: false, + pull_model: false, + api_major: 4, + })); + } + + fn empty_params<'a>() -> AddAgentParams<'a> { + AddAgentParams { + agent_id: "test-agent", + ip: None, + port: None, + verifier_ip: None, + runtime_policy: None, + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: None, + payload: None, + cert_dir: None, + verify: false, + push_model: false, + pull_model: false, + tpm_policy: None, + allow_unverified_quote: false, + wait_for_attestation: false, + attestation_timeout: 60, + } + } + + #[test] + fn test_has_attestation_policy_none() { + let params = empty_params(); + assert!(!has_attestation_policy(¶ms)); + } + + #[test] + fn test_has_attestation_policy_runtime_policy_only() { + let mut params = empty_params(); + params.runtime_policy = Some("policy.json"); + assert!(has_attestation_policy(¶ms)); + } + + #[test] + fn test_has_attestation_policy_runtime_policy_name_only() { + let mut params = empty_params(); + params.runtime_policy_name = Some("my-named-policy"); + assert!(has_attestation_policy(¶ms)); + } + + #[test] + fn test_has_attestation_policy_mb_policy_only() { + let mut params = empty_params(); + params.mb_policy = Some("mb-policy.json"); + assert!(has_attestation_policy(¶ms)); + } + + #[test] + fn test_has_attestation_policy_tpm_policy_only() { + let mut params = empty_params(); + params.tpm_policy = Some("{}"); + assert!(has_attestation_policy(¶ms)); + } + + #[test] + fn test_has_attestation_policy_multiple() { + let mut params = empty_params(); + params.runtime_policy = Some("policy.json"); + params.mb_policy = Some("mb-policy.json"); + assert!(has_attestation_policy(¶ms)); + } +} diff --git a/keylimectl/src/commands/agent/attestation.rs b/keylimectl/src/commands/agent/attestation.rs new file mode 100644 index 000000000..e7ba06739 --- /dev/null +++ b/keylimectl/src/commands/agent/attestation.rs @@ -0,0 +1,1400 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! TPM attestation workflow for pull model (API < 3.0) +//! +//! This module contains the attestation functions used in the pull model +//! where the tenant communicates directly with the agent for TPM quote +//! verification and key exchange. + +use super::helpers::load_payload_bytes; +use crate::client::agent::AgentClient; +use crate::commands::error::CommandError; +use crate::output::OutputHandler; +use base64::{engine::general_purpose::STANDARD, Engine}; +use keylime::crypto; +use log::{debug, warn}; +use openssl::rand; +use openssl::symm::{self, Cipher}; +use serde_json::Value; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +/// Validation result for TPM quote verification +#[derive(Debug)] +struct TpmQuoteValidation { + is_valid: bool, + nonce_verified: bool, + aik_verified: bool, + details: String, +} + +/// Key material and attestation data produced by TPM quote verification +/// +/// This struct holds sensitive key material used for provisioning the agent. +/// It implements `Zeroize` and `ZeroizeOnDrop` to ensure all sensitive key +/// material is cleared from memory when no longer needed. +#[derive(Zeroize, ZeroizeOnDrop)] +pub(super) struct AttestationData { + /// Base64-encoded RSA-OAEP ciphertext of the U key for the agent + pub(super) encrypted_u: String, + /// Hex-encoded HMAC-SHA256 authentication tag (K key over agent ID) + pub(super) auth_tag: String, + /// TPM quote string (public, not zeroized) + #[zeroize(skip)] + pub(super) quote: String, + /// Agent RSA public key in PEM format (public, not zeroized) + #[zeroize(skip)] + pub(super) public_key: String, + /// Hex-encoded nonce used for quote freshness (not sensitive) + #[zeroize(skip)] + pub(super) nonce: String, + /// K key bytes (K = U XOR V), zeroed on drop + pub(super) k_key: Zeroizing>, + /// V key bytes for delivery to verifier, zeroed on drop + pub(super) v_key: Zeroizing>, +} + +/// Perform agent attestation for API < 3.0 (pull model) +/// +/// This function implements the TPM quote verification process used in the +/// legacy pull model where the tenant communicates directly with the agent. +/// +/// # Arguments +/// +/// * `agent_client` - Client for communicating with the agent +/// * `agent_data` - Agent registration data from registrar +/// * `config` - Configuration containing cryptographic settings +/// * `output` - Output handler for progress reporting +/// +/// # Returns +/// +/// Returns attestation data including generated keys on success. +pub(super) async fn perform_agent_attestation( + agent_client: &AgentClient, + agent_data: &Value, + agent_id: &str, + allow_unverified_quote: bool, + output: &OutputHandler, +) -> Result, CommandError> { + output.progress("Generating nonce for TPM quote"); + + // Generate random nonce for quote freshness + let nonce = generate_secure_nonce(20)?; + debug!("Generated nonce for TPM quote ({} chars)", nonce.len()); + + output.progress("Requesting TPM quote from agent"); + + // Get TPM quote from agent + let quote_response = + agent_client.get_quote(&nonce).await.map_err(|e| { + CommandError::agent_operation_failed( + agent_id.to_string(), + "get_tpm_quote", + format!("Failed to get TPM quote: {e}"), + ) + })?; + + debug!( + "Received quote response ({} fields)", + quote_response.as_object().map_or(0, |m| m.len()) + ); + + // Extract quote data + let results = quote_response.get("results").ok_or_else(|| { + CommandError::agent_operation_failed( + agent_id.to_string(), + "quote_validation", + "Missing results in quote response", + ) + })?; + + let quote = + results + .get("quote") + .and_then(|q| q.as_str()) + .ok_or_else(|| { + CommandError::agent_operation_failed( + agent_id.to_string(), + "quote_validation", + "Missing quote in response", + ) + })?; + + let public_key = results + .get("pubkey") + .and_then(|pk| pk.as_str()) + .ok_or_else(|| { + CommandError::agent_operation_failed( + agent_id.to_string(), + "quote_validation", + "Missing public key in response", + ) + })?; + + output.progress("Validating TPM quote"); + + // Implement structured TPM quote validation + let validation_result = + validate_tpm_quote(quote, public_key, &nonce, agent_data, agent_id) + .await?; + + if !validation_result.is_valid && !allow_unverified_quote { + return Err(CommandError::agent_operation_failed( + agent_id.to_string(), + "tpm_quote_validation", + format!( + "TPM quote validation failed: {}", + validation_result.details + ), + )); + } + + let nonce_verified = validation_result.nonce_verified; + let aik_verified = validation_result.aik_verified; + + if !nonce_verified || !aik_verified { + if !allow_unverified_quote { + return Err(CommandError::agent_operation_failed( + agent_id.to_string(), + "tpm_quote_validation", + "TPM quote was not cryptographically verified (nonce and/or AIK not verified). \ + Enable the 'tpm-quote-validation' feature for full verification, \ + or pass --allow-unverified-quote to proceed without verification (INSECURE).", + )); + } + warn!( + "Proceeding with unverified TPM quote (--allow-unverified-quote). \ + This is INSECURE and should only be used for development/testing." + ); + } + + output.info(format!( + "TPM quote validation successful: nonce_verified={nonce_verified}, aik_verified={aik_verified}" + )); + + output.progress("Generating cryptographic keys"); + + // Generate U and V keys as random bytes (matching Keylime implementation) + // Wrapped in Zeroizing to clear from memory on drop + let mut u_key_bytes = Zeroizing::new([0u8; 32]); // AES-256 key length + let mut v_key_bytes = Zeroizing::new([0u8; 32]); // AES-256 key length + + // Use OpenSSL's random bytes generator (same as Keylime) + rand::rand_bytes(u_key_bytes.as_mut()).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to generate U key: {e}"), + ) + })?; + rand::rand_bytes(v_key_bytes.as_mut()).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to generate V key: {e}"), + ) + })?; + + // Compute K key as XOR of U and V (as in Keylime) + let mut k_key_bytes = Zeroizing::new([0u8; 32]); + for i in 0..32 { + k_key_bytes[i] = u_key_bytes[i] ^ v_key_bytes[i]; + } + + debug!("Generated U key: {} bytes", u_key_bytes.len()); + debug!("Generated V key: {} bytes", v_key_bytes.len()); + + // Encrypt U key with agent's public key + output.progress("Encrypting U key for agent"); + + // Implement proper RSA encryption using agent's public key + let encrypted_u = + encrypt_u_key_with_agent_pubkey(u_key_bytes.as_ref(), public_key)?; + let auth_tag = + crypto::compute_hmac(k_key_bytes.as_ref(), agent_id.as_bytes()) + .map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to compute auth tag: {e}"), + ) + })?; + + output.info("TPM quote verification completed successfully"); + + Ok(Some(AttestationData { + encrypted_u, + auth_tag: hex::encode(auth_tag), + quote: quote.to_string(), + public_key: public_key.to_string(), + nonce, + k_key: Zeroizing::new(k_key_bytes.to_vec()), + v_key: Zeroizing::new(v_key_bytes.to_vec()), + })) +} + +/// Deliver encrypted U key and payload to agent +/// +/// Sends the encrypted U key and any optional payload to the agent +/// after successful TPM quote verification. +pub(super) async fn perform_key_delivery( + agent_client: &AgentClient, + attestation: &AttestationData, + payload_path: Option<&str>, + output: &OutputHandler, +) -> Result<(), CommandError> { + output.progress("Delivering encrypted U key to agent"); + + // Load and encrypt payload if provided + // The agent expects the payload as base64-encoded AES-256-GCM ciphertext: + // base64(iv || ciphertext || tag) + // where the encryption key is K (= U XOR V). + let encrypted_payload = if let Some(path) = payload_path { + let payload_bytes = load_payload_bytes(path)?; + + output.progress("Encrypting payload for agent"); + Some(encrypt_payload(attestation.k_key.as_ref(), &payload_bytes)?) + } else { + None + }; + + // Deliver key and payload to agent + // Note: encrypted_u is already base64-encoded, auth_tag is hex-encoded + let encrypted_u_bytes = + STANDARD.decode(&attestation.encrypted_u).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to decode encrypted U key: {e}"), + ) + })?; + + let _delivery_result = agent_client + .deliver_key( + &encrypted_u_bytes, + &attestation.auth_tag, + encrypted_payload.as_deref(), + ) + .await + .map_err(|e| { + CommandError::agent_operation_failed( + "agent".to_string(), + "key_delivery", + format!("Failed to deliver key: {e}"), + ) + })?; + + output.info("U key delivered successfully to agent"); + Ok(()) +} + +/// Verify key derivation using HMAC challenge +/// +/// Sends a challenge to the agent to verify that it can correctly +/// derive keys using the delivered U key. Retries with backoff +/// because the agent may not yet have received V from the verifier. +pub(super) async fn verify_key_derivation( + agent_client: &AgentClient, + attestation: &AttestationData, + output: &OutputHandler, +) -> Result<(), CommandError> { + output.progress("Generating verification challenge"); + + let challenge = generate_secure_nonce(20)?; + + // Calculate expected HMAC using K key + let expected_hmac = crypto::compute_hmac( + attestation.k_key.as_ref(), + challenge.as_bytes(), + ) + .map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to compute expected HMAC: {e}"), + ) + })?; + // Agent returns HMAC as hex string (matching Python's do_hmac hexdigest) + let expected_hmac_hex = hex::encode(&expected_hmac); + + // Retry loop: the agent may not have received V from the verifier yet, + // so K = U XOR V is not available until both parts arrive. + let max_retries = 12; + let base_interval = std::time::Duration::from_secs(1); + + let wait_handle = + output.start_wait("Verifying key derivation (attempt 1/12)"); + + for attempt in 0..max_retries { + wait_handle.set_message(format!( + "Verifying key derivation (attempt {}/{})", + attempt + 1, + max_retries + )); + + match agent_client + .verify_key_derivation(&challenge, &expected_hmac_hex) + .await + { + Ok(true) => { + drop(wait_handle); + output.info("Key derivation verification successful"); + return Ok(()); + } + Ok(false) => { + // HMAC mismatch — agent likely hasn't received V yet + if attempt + 1 >= max_retries { + return Err(CommandError::agent_operation_failed( + "agent".to_string(), + "key_derivation_verification", + format!( + "Agent HMAC does not match expected value \ + after {max_retries} attempts" + ), + )); + } + let delay = base_interval + * 2u32.saturating_pow(attempt.min(4) as u32); + debug!( + "Key derivation not yet complete (attempt {}/{}), \ + retrying in {:?}", + attempt + 1, + max_retries, + delay + ); + tokio::time::sleep(delay).await; + } + Err(e) => { + // Network/protocol error — also retry + if attempt + 1 >= max_retries { + return Err(CommandError::agent_operation_failed( + "agent".to_string(), + "key_derivation_verification", + format!("Failed to verify key derivation: {e}"), + )); + } + let delay = base_interval + * 2u32.saturating_pow(attempt.min(4) as u32); + debug!( + "Verification request failed (attempt {}/{}): {e}, \ + retrying in {:?}", + attempt + 1, + max_retries, + delay + ); + tokio::time::sleep(delay).await; + } + } + } + + unreachable!() //#[allow_ci] +} + +/// Generate a cryptographically secure random nonce +/// +/// Uses OpenSSL's CSPRNG (`RAND_bytes`) to generate random bytes, +/// then hex-encodes them to produce a string suitable for use as +/// a nonce or challenge. +/// +/// # Arguments +/// * `num_bytes` - Number of random bytes to generate (output string will be `2 * num_bytes` hex chars) +fn generate_secure_nonce(num_bytes: usize) -> Result { + let mut buf = vec![0u8; num_bytes]; + rand::rand_bytes(&mut buf).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("CSPRNG failed to generate nonce: {e}"), + ) + })?; + Ok(hex::encode(buf)) +} + +/// Encrypt payload using AES-256-GCM and return base64-encoded ciphertext +/// +/// Matches the format produced by Python keylime's `crypto.encrypt()`: +/// base64(iv || ciphertext || tag) +/// +/// where: +/// - iv: 16-byte random initialization vector +/// - ciphertext: AES-256-GCM encrypted data +/// - tag: 16-byte GCM authentication tag +/// +/// The agent decrypts this using `crypto::decrypt_aead()` after +/// base64-decoding. +fn encrypt_payload( + key: &[u8], + plaintext: &[u8], +) -> Result { + const AES_BLOCK_SIZE: usize = 16; + + let mut iv = [0u8; AES_BLOCK_SIZE]; + rand::rand_bytes(&mut iv).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to generate IV: {e}"), + ) + })?; + + let cipher = Cipher::aes_256_gcm(); + let mut tag = vec![0u8; AES_BLOCK_SIZE]; + let ciphertext = + symm::encrypt_aead(cipher, key, Some(&iv), &[], plaintext, &mut tag) + .map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Payload encryption failed: {e}"), + ) + })?; + + let mut result = + Vec::with_capacity(iv.len() + ciphertext.len() + tag.len()); + result.extend_from_slice(&iv); + result.extend_from_slice(&ciphertext); + result.extend_from_slice(&tag); + + Ok(STANDARD.encode(&result)) +} + +/// Validate TPM quote structure (default: structural checks only) +/// +/// # Security Limitations +/// +/// Without the `tpm-quote-validation` feature, this function performs +/// **structural validation only**. It does NOT verify: +/// +/// - The cryptographic signature on the TPM quote against the registered AIK +/// (a full implementation uses `decode_quote_string` to parse the quote, +/// hashes the `AttestBuffer` with SHA-256, and verifies the signature +/// using the AIK public key with OpenSSL) +/// - The nonce via the `TPMS_ATTEST.extraData` field +/// (a full implementation converts the `AttestBuffer` to `Attest` and +/// compares `extra_data().value()` with the expected nonce bytes) +/// - The PCR digest integrity +/// (a full implementation hashes the selected PCR values and compares +/// with `QuoteInfo.pcr_digest()`) +/// +/// Enable the `tpm-quote-validation` cargo feature for full cryptographic +/// verification following the same logic as `tpm2_checkquote`. +#[cfg(not(feature = "tpm-quote-validation"))] +async fn validate_tpm_quote( + quote: &str, + _public_key: &str, + _nonce: &str, + agent_data: &Value, + agent_id: &str, +) -> Result { + // SECURITY: This path performs structural validation only. + // Enable the `tpm-quote-validation` cargo feature for full + // cryptographic verification of signature, nonce, and PCR digest. + warn!( + "TPM quote validation uses structural checks only. \ + Enable the 'tpm-quote-validation' feature for cryptographic verification." + ); + debug!("Starting structural TPM quote validation for agent {agent_id}"); + + let registered_aik = agent_data["aik_tpm"].as_str().ok_or_else(|| { + CommandError::agent_operation_failed( + agent_id.to_string(), + "aik_validation", + "Agent AIK not found in registrar", + ) + })?; + + // Structural check: quote format is r:: + if !quote.starts_with('r') { + return Ok(TpmQuoteValidation { + is_valid: false, + nonce_verified: false, + aik_verified: false, + details: "Quote does not start with expected 'r' prefix" + .to_string(), + }); + } + + let quote_parts: Vec<&str> = quote[1..].split(':').collect(); + if quote_parts.len() < 3 { + return Ok(TpmQuoteValidation { + is_valid: false, + nonce_verified: false, + aik_verified: false, + details: format!( + "Quote has {} colon-separated parts, expected at least 3", + quote_parts.len() + ), + }); + } + + // Structural check: base64 components decode successfully + let labels = ["attestation", "signature", "PCR blob"]; + for (i, part) in quote_parts.iter().take(3).enumerate() { + if STANDARD.decode(part).is_err() { + return Ok(TpmQuoteValidation { + is_valid: false, + nonce_verified: false, + aik_verified: false, + details: format!( + "Quote {} component is not valid base64", + labels[i] + ), + }); + } + } + + // Structural check: attestation data has reasonable length + let att_bytes = STANDARD.decode(quote_parts[0]).map_err(|e| { + CommandError::agent_operation_failed( + agent_id.to_string(), + "quote_validation", + format!("Failed to decode attestation data: {e}"), + ) + })?; + + if att_bytes.len() < 32 { + return Ok(TpmQuoteValidation { + is_valid: false, + nonce_verified: false, + aik_verified: false, + details: "Attestation data too short to be a valid TPM quote" + .to_string(), + }); + } + + let aik_available = !registered_aik.is_empty(); + let att_len = att_bytes.len(); + let details = format!( + "Structural validation only: {} quote parts, \ + {att_len} bytes attestation data, \ + registered AIK available: {aik_available}", + quote_parts.len() + ); + + debug!("TPM quote structural validation result: {details}"); + + // SECURITY: is_valid, nonce_verified, and aik_verified are false because + // structural validation cannot verify cryptographic properties. The caller + // must check nonce_verified and aik_verified and reject unverified quotes + // unless --allow-unverified-quote is explicitly passed. + Ok(TpmQuoteValidation { + is_valid: false, + nonce_verified: false, + aik_verified: false, + details, + }) +} + +/// Validate TPM quote with full cryptographic verification +/// +/// This function performs proper TPM quote validation following the same +/// logic as `tpm2_checkquote`: +/// 1. Parses the quote using `decode_quote_string` +/// 2. Verifies the quote signature against the registered AIK using OpenSSL +/// 3. Verifies the nonce from the `TPMS_ATTEST.extraData` field +/// 4. Verifies the PCR digest matches the quoted PCR values +#[cfg(feature = "tpm-quote-validation")] +async fn validate_tpm_quote( + quote: &str, + _public_key: &str, + nonce: &str, + agent_data: &Value, + agent_id: &str, +) -> Result { + debug!( + "Starting cryptographic TPM quote validation for agent {agent_id}" + ); + + let registered_aik = agent_data["aik_tpm"].as_str().ok_or_else(|| { + CommandError::agent_operation_failed( + agent_id.to_string(), + "aik_validation", + "Agent AIK not found in registrar", + ) + })?; + + // Step 2: Parse quote using keylime's decode_quote_string + let (att, sig, pcrsel, pcrdata) = + keylime::tpm::testing::decode_quote_string(quote).map_err(|e| { + CommandError::agent_operation_failed( + agent_id.to_string(), + "quote_validation", + format!("Failed to parse TPM quote: {e}"), + ) + })?; + + // Step 3: Convert registered AIK (base64-encoded TPM2B_PUBLIC) to OpenSSL PKey + let aik_bytes = STANDARD.decode(registered_aik).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to decode AIK base64: {e}"), + ) + })?; + + let aik_pubkey = pubkey_from_tpm2b_public(&aik_bytes)?; + + let aik_verified = + verify_quote_signature(&aik_pubkey, att.value(), &sig)?; + + // Step 4: Verify nonce from TPMS_ATTEST.extraData + let attestation: tss_esapi::structures::Attest = + att.try_into().map_err(|e: tss_esapi::Error| { + CommandError::agent_operation_failed( + agent_id.to_string(), + "quote_validation", + format!("Failed to parse attestation structure: {e}"), + ) + })?; + + let nonce_verified = attestation.extra_data().value() == nonce.as_bytes(); + + // Step 5: Verify PCR digest + let pcr_digest_ok = verify_pcr_digest(&attestation, &pcrsel, &pcrdata)?; + + let details = format!( + "Cryptographic validation: signature={aik_verified}, \ + nonce={nonce_verified}, pcr_digest={pcr_digest_ok}" + ); + + debug!("TPM quote validation result: {details}"); + + Ok(TpmQuoteValidation { + is_valid: aik_verified && nonce_verified && pcr_digest_ok, + nonce_verified, + aik_verified, + details, + }) +} + +/// Verify the TPM quote signature using OpenSSL +/// +/// Supports RSA-SSA (PKCS#1 v1.5), RSA-PSS, and ECDSA signature +/// schemes. EC-Schnorr is not supported for software-based +/// verification as OpenSSL lacks EC-Schnorr support. +#[cfg(feature = "tpm-quote-validation")] +fn verify_quote_signature( + aik_pubkey: &openssl::pkey::PKey, + att_data: &[u8], + sig: &tss_esapi::structures::Signature, +) -> Result { + use openssl::{rsa::Padding, sign::Verifier}; + use tss_esapi::structures::Signature as TpmSignature; + + match sig { + TpmSignature::RsaSsa(rsa_sig) => { + let raw_sig = rsa_sig.signature().value(); + let md = hash_alg_to_message_digest(rsa_sig.hashing_algorithm())?; + let mut verifier = + Verifier::new(md, aik_pubkey).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to create verifier: {e}"), + ) + })?; + verifier.set_rsa_padding(Padding::PKCS1).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to set PKCS1 padding: {e}"), + ) + })?; + verifier.update(att_data).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to update verifier: {e}"), + ) + })?; + verifier.verify(raw_sig).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Signature verification error: {e}"), + ) + }) + } + TpmSignature::RsaPss(rsa_sig) => { + let raw_sig = rsa_sig.signature().value(); + let md = hash_alg_to_message_digest(rsa_sig.hashing_algorithm())?; + let mut verifier = + Verifier::new(md, aik_pubkey).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to create verifier: {e}"), + ) + })?; + verifier.set_rsa_padding(Padding::PKCS1_PSS).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to set PSS padding: {e}"), + ) + })?; + verifier.update(att_data).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to update verifier: {e}"), + ) + })?; + verifier.verify(raw_sig).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("PSS signature verification error: {e}"), + ) + }) + } + TpmSignature::EcDsa(ecc_sig) => { + let md = hash_alg_to_message_digest(ecc_sig.hashing_algorithm())?; + + let r_bn = openssl::bn::BigNum::from_slice( + ecc_sig.signature_r().value(), + ) + .map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to create ECDSA r component: {e}"), + ) + })?; + let s_bn = openssl::bn::BigNum::from_slice( + ecc_sig.signature_s().value(), + ) + .map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to create ECDSA s component: {e}"), + ) + })?; + + let ecdsa_sig = + openssl::ecdsa::EcdsaSig::from_private_components(r_bn, s_bn) + .map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to create ECDSA signature: {e}"), + ) + })?; + let der_sig = ecdsa_sig.to_der().map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to DER-encode ECDSA signature: {e}"), + ) + })?; + + let mut verifier = + Verifier::new(md, aik_pubkey).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to create ECDSA verifier: {e}"), + ) + })?; + verifier.update(att_data).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to update ECDSA verifier: {e}"), + ) + })?; + verifier.verify(&der_sig).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("ECDSA signature verification error: {e}"), + ) + }) + } + TpmSignature::EcSchnorr(_) => Err(CommandError::resource_error( + "tpm", + "EC-Schnorr signature verification is not supported \ + for software-based quote validation. \ + EC-Schnorr requires TPM-based verification.", + )), + _ => Err(CommandError::resource_error( + "tpm", + format!( + "Unsupported TPM signature algorithm: {:?}", + sig.algorithm() + ), + )), + } +} + +/// Verify PCR digest matches the quoted PCR values +#[cfg(feature = "tpm-quote-validation")] +fn verify_pcr_digest( + attestation: &tss_esapi::structures::Attest, + pcrsel: &tss_esapi::structures::PcrSelectionList, + pcrdata: &tss_esapi::abstraction::pcr::PcrData, +) -> Result { + use openssl::hash::{Hasher, MessageDigest}; + use tss_esapi::{ + interface_types::algorithm::HashingAlgorithm, structures::AttestInfo, + }; + + // Get SHA-256 PCR bank + let pcrbank = + pcrdata.pcr_bank(HashingAlgorithm::Sha256).ok_or_else(|| { + CommandError::resource_error( + "tpm", + "No SHA-256 PCR bank in quote data", + ) + })?; + + // Hash selected PCR values in order + let mut hasher = Hasher::new(MessageDigest::sha256()).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to create hasher: {e}"), + ) + })?; + + for &sel in pcrsel.get_selections() { + for i in &sel.selected() { + if let Some(digest) = pcrbank.get_digest(*i) { + hasher.update(digest.value()).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to hash PCR value: {e}"), + ) + })?; + } + } + } + + let computed_digest = hasher.finish().map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to finalize PCR hash: {e}"), + ) + })?; + + // Extract quote info and compare PCR digest + let quote_info = match attestation.attested() { + AttestInfo::Quote { info } => info, + _ => { + return Err(CommandError::resource_error( + "tpm", + format!( + "Expected attestation type Quote, got {:?}", + attestation.attestation_type() + ), + )) + } + }; + + Ok(quote_info.pcr_digest().value() == computed_digest.as_ref()) +} + +/// Convert TSS hashing algorithm to OpenSSL message digest +#[cfg(feature = "tpm-quote-validation")] +fn hash_alg_to_message_digest( + alg: tss_esapi::interface_types::algorithm::HashingAlgorithm, +) -> Result { + use keylime::algorithms::HashAlgorithm; + use openssl::hash::MessageDigest; + + let hash_alg = HashAlgorithm::try_from(alg).map_err(|e| { + CommandError::resource_error( + "tpm", + format!("Unsupported hash algorithm in TPM signature: {e}"), + ) + })?; + MessageDigest::try_from(hash_alg).map_err(|e| { + CommandError::resource_error( + "tpm", + format!("Unsupported message digest: {e}"), + ) + }) +} + +/// Parse a TPM2B_PUBLIC structure and extract the public key. +/// +/// The TPM2B_PUBLIC format is a 2-byte big-endian size prefix followed +/// by a TPMT_PUBLIC structure, which is deserialized using tss-esapi +/// and converted to an OpenSSL PKey via PEM. +#[cfg(feature = "tpm-quote-validation")] +fn pubkey_from_tpm2b_public( + data: &[u8], +) -> Result, CommandError> { + use tss_esapi::traits::UnMarshall; + + if data.len() < 2 { + return Err(CommandError::resource_error( + "crypto", + "TPM2B_PUBLIC data too short", + )); + } + + let tpmt_size = u16::from_be_bytes([data[0], data[1]]) as usize; + if data.len() < 2 + tpmt_size { + return Err(CommandError::resource_error( + "crypto", + format!( + "TPM2B_PUBLIC buffer too short: {} < {}", + data.len(), + 2 + tpmt_size + ), + )); + } + + let public = + tss_esapi::structures::Public::unmarshall(&data[2..2 + tpmt_size]) + .map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to unmarshal TPMT_PUBLIC: {e}"), + ) + })?; + + let pem_bytes = crypto::tss_pubkey_to_pem(public).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to convert TPM public key to PEM: {e}"), + ) + })?; + + let pem_str = std::str::from_utf8(&pem_bytes).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("PEM is not valid UTF-8: {e}"), + ) + })?; + + crypto::pkey_pub_from_pem(pem_str).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to parse public key PEM: {e}"), + ) + }) +} + +/// Encrypt U key using agent's RSA public key with OAEP padding +/// +/// This function performs proper RSA-OAEP encryption of the U key using the agent's +/// public key. This ensures that only the agent with the corresponding private key +/// can decrypt and use the delivered key. +/// +/// # Arguments +/// * `u_key` - The U key to encrypt (typically 32 bytes) +/// * `agent_public_key` - Agent's RSA public key in base64 format +/// +/// # Returns +/// Returns base64-encoded encrypted U key +/// +/// # Security +/// - Uses RSA-OAEP padding for semantic security +/// - Validates public key format before encryption +/// - Provides cryptographic confidentiality for key delivery +#[must_use = "encrypted key must be sent to the agent"] +fn encrypt_u_key_with_agent_pubkey( + u_key_bytes: &[u8], + agent_public_key: &str, +) -> Result { + debug!("Encrypting U key with agent's RSA public key"); + + // Step 1: Agent public keys are provided in PEM format by Keylime agents + // Based on quotes_handler.rs:95 - agents use crypto::pkey_pub_to_pem() to format keys + debug!("Using public key in PEM format from agent response"); + let pubkey_pem = agent_public_key; + + // Step 2: Import the public key as OpenSSL PKey + let pubkey = crypto::pkey_pub_from_pem(pubkey_pem).map_err(|e| { + CommandError::resource_error( + "crypto", + format!("Failed to parse public key PEM: {e}"), + ) + })?; + + // Step 3: Perform RSA-OAEP encryption using keylime crypto module + let encrypted_bytes = crypto::rsa_oaep_encrypt(&pubkey, u_key_bytes) + .map_err(|e| { + CommandError::resource_error( + "crypto", + format!("RSA encryption failed: {e}"), + ) + })?; + + // Step 4: Encode result as base64 for transmission + let encrypted_b64 = STANDARD.encode(&encrypted_bytes); + + let input_len = u_key_bytes.len(); + let output_len = encrypted_bytes.len(); + debug!( + "Successfully encrypted U key: {input_len} bytes -> {output_len} bytes" + ); + + Ok(encrypted_b64) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_generate_secure_nonce_length() { + // Each byte becomes 2 hex chars + for num_bytes in [1, 10, 16, 20, 32] { + let nonce = + generate_secure_nonce(num_bytes).expect("nonce generation"); //#[allow_ci] + assert_eq!( + nonce.len(), + num_bytes * 2, + "Expected {} hex chars for {} bytes", + num_bytes * 2, + num_bytes + ); + } + } + + #[test] + fn test_generate_secure_nonce_hex_chars() { + let nonce = generate_secure_nonce(32).expect("nonce generation"); //#[allow_ci] + assert!( + nonce.chars().all(|c| c.is_ascii_hexdigit()), + "Nonce contains non-hex characters: {nonce}" + ); + } + + #[test] + fn test_generate_secure_nonce_uniqueness() { + let mut nonces = HashSet::new(); + for _ in 0..100 { + let nonce = generate_secure_nonce(20).expect("nonce generation"); //#[allow_ci] + assert!(nonces.insert(nonce), "Duplicate nonce generated"); + } + } + + // Negative security tests: attacker-controlled public keys + + #[test] + fn test_encrypt_u_key_empty_pem() { + let result = encrypt_u_key_with_agent_pubkey(&[0u8; 32], ""); + assert!(result.is_err()); + } + + #[test] + fn test_encrypt_u_key_garbage_pem() { + let result = + encrypt_u_key_with_agent_pubkey(&[0u8; 32], "not-a-pem-key"); + assert!(result.is_err()); + } + + #[test] + fn test_encrypt_u_key_truncated_pem() { + let result = encrypt_u_key_with_agent_pubkey( + &[0u8; 32], + "-----BEGIN PUBLIC KEY-----\nMIIB\n-----END PUBLIC KEY-----", + ); + assert!(result.is_err()); + } + + #[test] + fn test_encrypt_u_key_ec_key_rejected() { + // RSA-OAEP encryption must reject non-RSA keys + use openssl::ec::{EcGroup, EcKey}; + use openssl::nid::Nid; + use openssl::pkey::PKey; + + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1) + .expect("EC group"); //#[allow_ci] + let ec = EcKey::generate(&group).expect("EC key generation"); //#[allow_ci] + let pkey = PKey::from_ec_key(ec).expect("PKey from EC"); //#[allow_ci] + let pem = String::from_utf8( + pkey.public_key_to_pem().expect("PEM encoding"), //#[allow_ci] + ) + .expect("UTF-8"); //#[allow_ci] + + let result = encrypt_u_key_with_agent_pubkey(&[0u8; 32], &pem); + assert!(result.is_err()); + } + + #[test] + fn test_encrypt_u_key_valid_rsa_key() { + use openssl::pkey::PKey; + use openssl::rsa::Rsa; + + let rsa = Rsa::generate(2048).expect("RSA key generation"); //#[allow_ci] + let pkey = PKey::from_rsa(rsa).expect("PKey from RSA"); //#[allow_ci] + let pem = String::from_utf8( + pkey.public_key_to_pem().expect("PEM encoding"), //#[allow_ci] + ) + .expect("UTF-8"); //#[allow_ci] + + let result = encrypt_u_key_with_agent_pubkey(&[0u8; 32], &pem); + assert!(result.is_ok()); + } + + #[test] + fn test_encrypt_u_key_empty_plaintext_no_panic() { + use openssl::pkey::PKey; + use openssl::rsa::Rsa; + + let rsa = Rsa::generate(2048).expect("RSA key generation"); //#[allow_ci] + let pkey = PKey::from_rsa(rsa).expect("PKey from RSA"); //#[allow_ci] + let pem = String::from_utf8( + pkey.public_key_to_pem().expect("PEM encoding"), //#[allow_ci] + ) + .expect("UTF-8"); //#[allow_ci] + + // Empty plaintext — verifies no panic regardless of result + let _ = encrypt_u_key_with_agent_pubkey(&[], &pem); + } + + // Payload encryption tests + + #[test] + fn test_encrypt_payload_roundtrip() { + // Verify encrypt_payload produces output compatible with + // keylime::crypto::decrypt_aead + let key = [0x42u8; 32]; // AES-256 key + let plaintext = b"test payload data for the agent"; + + let b64_ciphertext = + encrypt_payload(&key, plaintext).expect("encryption"); //#[allow_ci] + + // Base64 decode + let raw = STANDARD.decode(&b64_ciphertext).expect("base64 decode"); //#[allow_ci] + + // Decrypt using the same function the agent uses + let decrypted = crypto::decrypt_aead(&key, &raw).expect("decryption"); //#[allow_ci] + + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_encrypt_payload_empty() { + let key = [0xAAu8; 32]; + let plaintext = b""; + + let b64_ciphertext = + encrypt_payload(&key, plaintext).expect("encryption"); //#[allow_ci] + let raw = STANDARD.decode(&b64_ciphertext).expect("base64 decode"); //#[allow_ci] + let decrypted = crypto::decrypt_aead(&key, &raw).expect("decryption"); //#[allow_ci] + + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_encrypt_payload_produces_valid_base64() { + let key = [0xBBu8; 32]; + let plaintext = b"hello world"; + + let b64_ciphertext = + encrypt_payload(&key, plaintext).expect("encryption"); //#[allow_ci] + + // Must be valid base64 + assert!(STANDARD.decode(&b64_ciphertext).is_ok()); + + // Must not contain whitespace (which caused the original bug) + assert!( + !b64_ciphertext.contains(' '), + "Encrypted payload contains spaces" + ); + } + + // Negative security tests: malformed TPM quote parsing + + #[test] + fn test_malformed_quote_base64_decode() { + // Verify base64 decode returns Err for garbage, not panic + assert!(STANDARD.decode("!!!invalid!!!").is_err()); + assert!(STANDARD.decode("").is_ok()); // empty decodes to empty + } + + #[test] + fn test_quote_format_parsing_edge_cases() { + // Verify quote string splitting handles edge cases without panic + let empty = ""; + assert!(!empty.starts_with('r')); + + let no_parts = "r"; + let parts: Vec<&str> = no_parts[1..].split(':').collect(); + assert_eq!(parts.len(), 1); + assert!(parts[0].is_empty()); + + let one_part = "rYWJj"; + let parts: Vec<&str> = one_part[1..].split(':').collect(); + assert_eq!(parts.len(), 1); + + let two_parts = "rYWJj:ZGVm"; + let parts: Vec<&str> = two_parts[1..].split(':').collect(); + assert_eq!(parts.len(), 2); + } + + // Zeroization verification tests + + #[test] + fn test_zeroizing_wraps_and_clears_on_explicit_zeroize() { + use zeroize::Zeroize; + + // Verify Zeroizing wraps correctly + let mut key = Zeroizing::new([0xFFu8; 32]); + assert!(key.iter().all(|&b| b == 0xFF)); + + // Verify explicit zeroize clears the value + key.zeroize(); + assert!(key.iter().all(|&b| b == 0x00)); + } + + #[test] + fn test_zeroizing_key_material_operations() { + // Verify key material operations work correctly with Zeroizing wrapper + let mut u_key = Zeroizing::new([0u8; 32]); + let mut v_key = Zeroizing::new([0u8; 32]); + + // Fill with test data (simulating rand::rand_bytes) + for (i, b) in u_key.iter_mut().enumerate() { + *b = i as u8; + } + for (i, b) in v_key.iter_mut().enumerate() { + *b = (255 - i) as u8; + } + + // XOR operation (K = U ^ V) should work through Zeroizing + let mut k_key = Zeroizing::new([0u8; 32]); + for i in 0..32 { + k_key[i] = u_key[i] ^ v_key[i]; + } + + // Verify XOR result + for i in 0..32 { + assert_eq!(k_key[i], (i as u8) ^ (255 - i) as u8); + } + } + + #[cfg(feature = "tpm-quote-validation")] + mod quote_validation_tests { + use super::super::*; + + fn generate_ec_key_and_sign( + data: &[u8], + ) -> ( + openssl::pkey::PKey, + openssl::bn::BigNum, + openssl::bn::BigNum, + ) { + use openssl::ec::{EcGroup, EcKey}; + use openssl::nid::Nid; + use openssl::pkey::PKey; + + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1) + .expect("P-256 group"); //#[allow_ci] + let ec_key = EcKey::generate(&group).expect("EC key generation"); //#[allow_ci] + let pkey = + PKey::from_ec_key(ec_key.clone()).expect("PKey from EC"); //#[allow_ci] + + let sig = openssl::ecdsa::EcdsaSig::sign(data, &ec_key) + .expect("ECDSA sign"); //#[allow_ci] + let r = sig.r().to_owned().expect("r component"); //#[allow_ci] + let s = sig.s().to_owned().expect("s component"); //#[allow_ci] + + let pub_key_pem = + pkey.public_key_to_pem().expect("public key PEM"); //#[allow_ci] + let pub_pkey = PKey::public_key_from_pem(&pub_key_pem) + .expect("parse public PEM"); //#[allow_ci] + + (pub_pkey, r, s) + } + + #[test] + fn test_verify_ecdsa_signature_valid() { + use tss_esapi::interface_types::algorithm::HashingAlgorithm; + use tss_esapi::structures::{ + EccParameter, EccSignature, Signature, + }; + + let att_data = b"test attestation data"; + let digest = openssl::hash::hash( + openssl::hash::MessageDigest::sha256(), + att_data, + ) + .expect("hash"); //#[allow_ci] + + let (pub_pkey, r, s) = generate_ec_key_and_sign(&digest); + + let r_bytes = r.to_vec(); + let s_bytes = s.to_vec(); + + let ecc_sig = EccSignature::create( + HashingAlgorithm::Sha256, + EccParameter::try_from(r_bytes.as_slice()).expect("r param"), //#[allow_ci] + EccParameter::try_from(s_bytes.as_slice()).expect("s param"), //#[allow_ci] + ) + .expect("EccSignature"); //#[allow_ci] + + let sig = Signature::EcDsa(ecc_sig); + + let result = verify_quote_signature(&pub_pkey, att_data, &sig); + assert!( + result.is_ok(), + "verify_quote_signature failed: {result:?}" + ); + assert!(result.unwrap(), "ECDSA signature should be valid"); //#[allow_ci] + } + + #[test] + fn test_verify_ecdsa_signature_wrong_data() { + use tss_esapi::interface_types::algorithm::HashingAlgorithm; + use tss_esapi::structures::{ + EccParameter, EccSignature, Signature, + }; + + let att_data = b"test attestation data"; + let digest = openssl::hash::hash( + openssl::hash::MessageDigest::sha256(), + att_data, + ) + .expect("hash"); //#[allow_ci] + + let (pub_pkey, r, s) = generate_ec_key_and_sign(&digest); + + let r_bytes = r.to_vec(); + let s_bytes = s.to_vec(); + + let ecc_sig = EccSignature::create( + HashingAlgorithm::Sha256, + EccParameter::try_from(r_bytes.as_slice()).expect("r param"), //#[allow_ci] + EccParameter::try_from(s_bytes.as_slice()).expect("s param"), //#[allow_ci] + ) + .expect("EccSignature"); //#[allow_ci] + + let sig = Signature::EcDsa(ecc_sig); + + let result = + verify_quote_signature(&pub_pkey, b"wrong data", &sig); + assert!(result.is_ok()); + assert!( + !result.unwrap(), //#[allow_ci] + "ECDSA signature should be invalid for wrong data" + ); + } + + #[test] + fn test_verify_ecschnorr_returns_error() { + use tss_esapi::interface_types::algorithm::HashingAlgorithm; + use tss_esapi::structures::{ + EccParameter, EccSignature, Signature, + }; + + let ec_key = openssl::ec::EcKey::generate( + &openssl::ec::EcGroup::from_curve_name( + openssl::nid::Nid::X9_62_PRIME256V1, + ) + .expect("group"), //#[allow_ci] + ) + .expect("keygen"); //#[allow_ci] + let pub_pkey = openssl::pkey::PKey::from_ec_key( + openssl::ec::EcKey::from_public_key( + ec_key.group(), + ec_key.public_key(), + ) + .expect("pub EC"), //#[allow_ci] + ) + .expect("pub PKey"); //#[allow_ci] + + let dummy = vec![0u8; 32]; + let ecc_sig = EccSignature::create( + HashingAlgorithm::Sha256, + EccParameter::try_from(dummy.as_slice()).expect("r"), //#[allow_ci] + EccParameter::try_from(dummy.as_slice()).expect("s"), //#[allow_ci] + ) + .expect("EccSignature"); //#[allow_ci] + + let sig = Signature::EcSchnorr(ecc_sig); + + let result = verify_quote_signature(&pub_pkey, b"data", &sig); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); //#[allow_ci] + assert!( + err_msg.contains("EC-Schnorr"), + "Error should mention EC-Schnorr: {err_msg}" + ); + } + } +} diff --git a/keylimectl/src/commands/agent/helpers.rs b/keylimectl/src/commands/agent/helpers.rs new file mode 100644 index 000000000..63b85035c --- /dev/null +++ b/keylimectl/src/commands/agent/helpers.rs @@ -0,0 +1,524 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Helper utilities for agent commands +//! +//! Policy file loading, TPM policy resolution, and measured boot +//! policy extraction. + +use crate::commands::error::CommandError; +use log::{debug, warn}; +use serde_json::Value; +use std::fs; + +/// Load policy file contents +#[must_use = "policy content must be used after loading"] +pub(super) fn load_policy_file(path: &str) -> Result { + fs::read_to_string(path).map_err(|e| { + CommandError::policy_file_error( + path, + format!("Failed to read policy file: {e}"), + ) + }) +} + +/// Load payload file contents as string +#[must_use = "payload content must be used after loading"] +pub(super) fn load_payload_file(path: &str) -> Result { + fs::read_to_string(path).map_err(|e| { + CommandError::policy_file_error( + path, + format!("Failed to read payload file: {e}"), + ) + }) +} + +/// Load payload file contents as raw bytes +/// +/// Used for payload encryption where the file content needs to be +/// encrypted before being sent to the agent. Reads as bytes to +/// support both text and binary payloads. +#[cfg(feature = "api-v2")] +#[must_use = "payload bytes must be used after loading"] +pub(super) fn load_payload_bytes( + path: &str, +) -> Result, CommandError> { + fs::read(path).map_err(|e| { + CommandError::policy_file_error( + path, + format!("Failed to read payload file: {e}"), + ) + }) +} + +/// IMA PCR index (matches keylime config.IMA_PCR) +const IMA_PCR: u32 = 10; + +/// Measured boot PCR indices (matches keylime config.MEASUREDBOOT_PCRS) +const MEASUREDBOOT_PCRS: &[u32] = + &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15]; + +/// Enhanced TPM policy resolution with measured boot policy extraction +/// +/// This function implements the full precedence chain for TPM policy resolution, +/// matching the behavior of the Python keylime_tenant implementation. +/// +/// After resolving the base policy, it auto-enables PCRs based on which +/// attestation policies are provided (matching `process_policy()` in the +/// Python tenant): +/// - runtime policy → enables IMA PCR (10) +/// - measured boot policy → enables measured boot PCRs (0-9, 11-15) +/// +/// # Precedence Order: +/// 1. Explicit CLI --tpm_policy argument (highest priority) +/// 2. TPM policy extracted from measured boot policy file +/// 3. Default empty policy "{}" (lowest priority) +/// +/// # Arguments +/// * `explicit_policy` - Policy provided via CLI --tpm_policy argument +/// * `mb_policy_path` - Path to measured boot policy file (for extraction) +/// * `has_runtime_policy` - Whether a runtime (IMA) policy is being provided +/// * `has_mb_policy` - Whether a measured boot policy is being provided +/// +/// # Returns +/// Returns the resolved TPM policy as a JSON string +#[must_use = "resolved policy must be used in the request"] +pub(super) fn resolve_tpm_policy_enhanced( + explicit_policy: Option<&str>, + mb_policy_path: Option<&str>, + has_runtime_policy: bool, + has_mb_policy: bool, +) -> Result { + // Priority 1: Explicit CLI argument + let mut tpm_policy: Value = if let Some(policy) = explicit_policy { + debug!("Using explicit TPM policy from CLI: {policy}"); + serde_json::from_str(policy).map_err(|e| { + CommandError::invalid_parameter( + "tpm_policy", + format!("Invalid JSON in TPM policy: {e}"), + ) + })? + } else { + // Priority 2: Extract from measured boot policy + let mut resolved = None; + if let Some(mb_path) = mb_policy_path { + debug!("Attempting to extract TPM policy from measured boot policy: {mb_path}"); + match extract_tpm_policy_from_mb_policy(mb_path) { + Ok(Some(extracted_policy)) => { + debug!("Extracted TPM policy from measured boot policy: {extracted_policy}"); + let parsed = serde_json::from_str(&extracted_policy) + .map_err(|e| { + CommandError::invalid_parameter( + "mb_policy", + format!( + "Corrupt TPM policy in measured boot policy file {mb_path}: {e}" + ), + ) + })?; + resolved = Some(parsed); + } + Ok(None) => { + debug!("No TPM policy found in measured boot policy, using default"); + } + Err(e) => { + warn!("Failed to extract TPM policy from measured boot policy: {e}"); + debug!( + "Continuing with default policy due to extraction error" + ); + } + } + } + + // Priority 3: Default empty policy with zeroed mask (no PCRs) + resolved.unwrap_or_else(|| { + debug!("Using default empty TPM policy with zeroed mask"); + serde_json::json!({"mask": "0x0"}) + }) + }; + + // Auto-enable PCRs based on provided policies (matching Python tenant) + let obj = tpm_policy.as_object_mut().ok_or_else(|| { + CommandError::invalid_parameter( + "tpm_policy", + "TPM policy must be a JSON object".to_string(), + ) + })?; + + let mut mask: u32 = obj + .get("mask") + .and_then(|v| v.as_str()) + .and_then(|s| { + u32::from_str_radix(s.trim_start_matches("0x"), 16).ok() + }) + .unwrap_or(0); + + if has_runtime_policy { + mask |= 1 << IMA_PCR; + debug!("Auto-enabled IMA PCR {IMA_PCR} in TPM policy mask"); + } + + if has_mb_policy { + for &pcr in MEASUREDBOOT_PCRS { + mask |= 1 << pcr; + } + debug!("Auto-enabled measured boot PCRs in TPM policy mask"); + } + + let _ = obj + .insert("mask".to_string(), serde_json::json!(format!("0x{mask:x}"))); + + let policy_str = serde_json::to_string(&tpm_policy).map_err(|e| { + CommandError::invalid_parameter( + "tpm_policy", + format!("Failed to serialize TPM policy: {e}"), + ) + })?; + + debug!("Resolved TPM policy: {policy_str}"); + Ok(policy_str) +} + +/// Extract TPM policy from a measured boot policy file +/// +/// Measured boot policies in Keylime can contain TPM policy sections that should +/// be extracted and used for agent attestation. This function parses the measured +/// boot policy file and extracts any TPM-related policy information. +/// +/// # Arguments +/// * `mb_policy_path` - Path to the measured boot policy JSON file +/// +/// # Returns +/// * `Ok(Some(policy))` - Successfully extracted TPM policy +/// * `Ok(None)` - No TPM policy found in the file +/// * `Err(error)` - File reading or parsing error +/// +/// # Expected Format +/// The measured boot policy file should be a JSON file that may contain: +/// ```json +/// { +/// "tpm_policy": { +/// "pcr": [15], +/// "hash": "sha256" +/// }, +/// "other_mb_fields": "..." +/// } +/// ``` +#[must_use = "extracted policy must be checked and used"] +fn extract_tpm_policy_from_mb_policy( + mb_policy_path: &str, +) -> Result, CommandError> { + debug!("Reading measured boot policy file: {mb_policy_path}"); + + // Read the measured boot policy file + let policy_content = fs::read_to_string(mb_policy_path).map_err(|e| { + CommandError::policy_file_error( + mb_policy_path, + format!("Failed to read measured boot policy file: {e}"), + ) + })?; + + // Parse as JSON + let mb_policy: Value = + serde_json::from_str(&policy_content).map_err(|e| { + CommandError::policy_file_error( + mb_policy_path, + format!("Invalid JSON in measured boot policy file: {e}"), + ) + })?; + + // Look for TPM policy in various expected locations + let tpm_policy_value = mb_policy + .get("tpm_policy") // Primary location + .or_else(|| mb_policy.get("tpm")); // Alternative location + + match tpm_policy_value { + Some(policy_obj) => { + // Convert the TPM policy object to a JSON string + let policy_str = + serde_json::to_string(policy_obj).map_err(|e| { + CommandError::policy_file_error( + mb_policy_path, + format!( + "Failed to serialize extracted TPM policy: {e}" + ), + ) + })?; + debug!("Successfully extracted TPM policy: {policy_str}"); + Ok(Some(policy_str)) + } + None => { + debug!("No TPM policy section found in measured boot policy"); + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_resolve_tpm_policy_explicit_priority() { + // Explicit policy should have highest priority. + // The mask is updated by auto-enable logic even for explicit policies. + let result = resolve_tpm_policy_enhanced( + Some("{\"pcr\": [15], \"mask\": \"0x0\"}"), + Some("/path/to/mb.json"), + true, + false, + ) + .unwrap(); //#[allow_ci] + let parsed: Value = serde_json::from_str(&result).unwrap(); //#[allow_ci] + assert_eq!(parsed["pcr"], json!([15])); + // IMA PCR 10 should be auto-enabled (has_runtime_policy=true) + assert_eq!(parsed["mask"], "0x400"); + } + + #[test] + fn test_resolve_tpm_policy_default_fallback() { + // Should fallback to default when no policies provided (empty policy with no PCRs) + let result = + resolve_tpm_policy_enhanced(None, None, false, false).unwrap(); //#[allow_ci] + assert_eq!(result, r#"{"mask":"0x0"}"#); + } + + #[test] + fn test_extract_tpm_policy_from_mb_policy_success() { + let temp_dir = tempdir().unwrap(); //#[allow_ci] + let policy_file = temp_dir.path().join("mb_policy.json"); + + // Create test measured boot policy with TPM policy + let mb_policy_content = json!({ + "tpm_policy": { + "pcr": [15], + "hash": "sha256" + }, + "other_field": "value" + }); + + fs::write(&policy_file, mb_policy_content.to_string()).unwrap(); //#[allow_ci] + + let result = + extract_tpm_policy_from_mb_policy(policy_file.to_str().unwrap()) //#[allow_ci] + .unwrap(); //#[allow_ci] + + assert!(result.is_some()); + let extracted = result.unwrap(); //#[allow_ci] + let parsed: Value = serde_json::from_str(&extracted).unwrap(); //#[allow_ci] + assert_eq!(parsed["pcr"], json!([15])); + assert_eq!(parsed["hash"], "sha256"); + } + + #[test] + fn test_extract_tpm_policy_alternative_locations() { + let temp_dir = tempdir().unwrap(); //#[allow_ci] + + // Test "tpm" location + let policy_file_tpm = temp_dir.path().join("mb_policy_tpm.json"); + let mb_policy_tpm = json!({ + "tpm": {"pcr": [16]}, + "other_field": "value" + }); + fs::write(&policy_file_tpm, mb_policy_tpm.to_string()).unwrap(); //#[allow_ci] + + let result = extract_tpm_policy_from_mb_policy( + policy_file_tpm.to_str().unwrap(), //#[allow_ci] + ) + .unwrap(); //#[allow_ci] + assert!(result.is_some()); + + // Test "tpm_policy" location + let policy_file_full = temp_dir.path().join("mb_policy_full.json"); + let mb_policy_full = json!({ + "tpm_policy": {"pcr": [17]}, + "other_field": "value" + }); + fs::write(&policy_file_full, mb_policy_full.to_string()).unwrap(); //#[allow_ci] + + let result = extract_tpm_policy_from_mb_policy( + policy_file_full.to_str().unwrap(), //#[allow_ci] + ) + .unwrap(); //#[allow_ci] + assert!(result.is_some()); + } + + #[test] + fn test_extract_tpm_policy_no_policy_found() { + let temp_dir = tempdir().unwrap(); //#[allow_ci] + let policy_file = temp_dir.path().join("mb_policy_no_tpm.json"); + + // Create measured boot policy without TPM policy + let mb_policy_content = json!({ + "other_field": "value", + "more_fields": "data" + }); + + fs::write(&policy_file, mb_policy_content.to_string()).unwrap(); //#[allow_ci] + + let result = + extract_tpm_policy_from_mb_policy(policy_file.to_str().unwrap()) //#[allow_ci] + .unwrap(); //#[allow_ci] + + assert!(result.is_none()); + } + + #[test] + fn test_extract_tpm_policy_invalid_json() { + let temp_dir = tempdir().unwrap(); //#[allow_ci] + let policy_file = temp_dir.path().join("invalid.json"); + + // Write invalid JSON + fs::write(&policy_file, "{ invalid json }").unwrap(); //#[allow_ci] + + let result = + extract_tpm_policy_from_mb_policy(policy_file.to_str().unwrap()); //#[allow_ci] + + assert!(result.is_err()); + } + + #[test] + fn test_extract_tpm_policy_file_not_found() { + let result = + extract_tpm_policy_from_mb_policy("/nonexistent/file.json"); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_tpm_policy_enhanced_with_mb_extraction() { + let temp_dir = tempdir().unwrap(); //#[allow_ci] + let policy_file = temp_dir.path().join("mb_with_tmp.json"); + + // Create measured boot policy with TPM policy + let mb_policy_content = json!({ + "tpm_policy": { + "pcr": [14, 15], + "hash": "sha1" + } + }); + + fs::write(&policy_file, mb_policy_content.to_string()).unwrap(); //#[allow_ci] + + // Should extract from measured boot policy when no explicit policy + let result = resolve_tpm_policy_enhanced( + None, + Some(policy_file.to_str().unwrap()), //#[allow_ci] + false, + false, + ) + .unwrap(); //#[allow_ci] + + let parsed: Value = serde_json::from_str(&result).unwrap(); //#[allow_ci] + assert_eq!(parsed["pcr"], json!([14, 15])); + assert_eq!(parsed["hash"], "sha1"); + } + + #[test] + fn test_resolve_tpm_policy_enhanced_extraction_error_fallback() { + // When extraction fails, should fallback to default (empty policy with no PCRs) + let result = resolve_tpm_policy_enhanced( + None, + Some("/nonexistent/file.json"), + false, + false, + ) + .unwrap(); //#[allow_ci] + + assert_eq!(result, r#"{"mask":"0x0"}"#); + } + + #[test] + fn test_resolve_tpm_policy_precedence_order() { + let temp_dir = tempdir().unwrap(); //#[allow_ci] + let policy_file = temp_dir.path().join("mb_policy.json"); + + // Create measured boot policy + let mb_policy_content = json!({ + "tpm_policy": {"pcr": [16]} + }); + fs::write(&policy_file, mb_policy_content.to_string()).unwrap(); //#[allow_ci] + + // Explicit policy should override extracted policy + let result = resolve_tpm_policy_enhanced( + Some("{\"pcr\": [15], \"mask\": \"0x0\"}"), + Some(policy_file.to_str().unwrap()), //#[allow_ci] + false, + false, + ) + .unwrap(); //#[allow_ci] + + // Should use explicit policy, not extracted one + let parsed: Value = serde_json::from_str(&result).unwrap(); //#[allow_ci] + assert_eq!(parsed["pcr"], json!([15])); + } + + #[test] + fn test_resolve_tpm_policy_auto_enable_ima_pcr() { + // When has_runtime_policy=true, IMA PCR 10 should be auto-enabled + let has_runtime_policy = true; + let has_mb_policy = false; + let result = resolve_tpm_policy_enhanced( + None, + None, + has_runtime_policy, + has_mb_policy, + ) + .unwrap(); //#[allow_ci] + + let parsed: Value = serde_json::from_str(&result).unwrap(); //#[allow_ci] + assert_eq!(parsed["mask"], "0x400"); // IMA PCR 10: 1 << 10 = 0x400 + } + + #[test] + fn test_resolve_tpm_policy_auto_enable_mb_pcrs() { + // When has_mb_policy=true, measured boot PCRs (0-9,11-15) should be auto-enabled + let has_runtime_policy = false; + let has_mb_policy = true; + let result = resolve_tpm_policy_enhanced( + None, + None, + has_runtime_policy, + has_mb_policy, + ) + .unwrap(); //#[allow_ci] + + let parsed: Value = serde_json::from_str(&result).unwrap(); //#[allow_ci] + assert_eq!(parsed["mask"], "0xfbff"); // MB PCRs 0-9,11-15: 0xfbff + } + + #[test] + fn test_resolve_tpm_policy_auto_enable_both() { + // When both policies are provided, both IMA and MB PCRs should be enabled + let has_runtime_policy = true; + let has_mb_policy = true; + let result = resolve_tpm_policy_enhanced( + None, + None, + has_runtime_policy, + has_mb_policy, + ) + .unwrap(); //#[allow_ci] + + let parsed: Value = serde_json::from_str(&result).unwrap(); //#[allow_ci] + assert_eq!(parsed["mask"], "0xffff"); // IMA + MB PCRs: 0xffff + } + + #[test] + fn test_resolve_tpm_policy_auto_enable_preserves_existing_mask() { + // Existing mask bits should be preserved when auto-enabling + let has_runtime_policy = true; + let has_mb_policy = false; + let result = resolve_tpm_policy_enhanced( + Some("{\"mask\": \"0x800000\"}"), // PCR 23 + None, + has_runtime_policy, + has_mb_policy, + ) + .unwrap(); //#[allow_ci] + + let parsed: Value = serde_json::from_str(&result).unwrap(); //#[allow_ci] + assert_eq!(parsed["mask"], "0x800400"); // PCR 23 | PCR 10 + } +} diff --git a/keylimectl/src/commands/agent/mod.rs b/keylimectl/src/commands/agent/mod.rs new file mode 100644 index 000000000..3e775cbf5 --- /dev/null +++ b/keylimectl/src/commands/agent/mod.rs @@ -0,0 +1,743 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Agent management commands for keylimectl +//! +//! This module provides comprehensive agent lifecycle management for the Keylime attestation system. +//! It handles all agent-related operations including registration, monitoring, and decommissioning. +//! +//! # Agent Lifecycle +//! +//! The typical agent lifecycle involves these stages: +//! +//! 1. **Registration**: Agent registers with the registrar, providing TPM keys +//! 2. **Addition**: Agent is added to verifier for continuous monitoring +//! 3. **Monitoring**: Verifier continuously attests agent integrity +//! 4. **Management**: Agent can be updated, reactivated, or removed +//! 5. **Decommissioning**: Agent is removed from both verifier and registrar +//! +//! # Command Types +//! +//! - [`AgentAction::Add`]: Add agent to verifier for attestation monitoring +//! - [`AgentAction::Remove`]: Remove agent from verifier and optionally registrar +//! - [`AgentAction::Update`]: Update agent configuration (runtime/measured boot policies) +//! - [`AgentAction::Reactivate`]: Reactivate a failed or stopped agent +//! +//! # Security Considerations +//! +//! - All operations validate agent UUIDs for proper format +//! - TPM-based attestation ensures agent authenticity +//! - Secure communication using mutual TLS +//! - Policy validation before deployment +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::commands::agent; +//! use keylimectl::config::Config; +//! use keylimectl::output::OutputHandler; +//! use keylimectl::AgentAction; +//! +//! # async fn example() -> Result<(), Box> { +//! let config = Config::default(); +//! let output = OutputHandler::new(crate::OutputFormat::Json, false); +//! +//! let action = AgentAction::Add { +//! uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), +//! ip: Some("192.168.1.100".to_string()), +//! port: Some(9002), +//! verifier_ip: None, +//! runtime_policy: None, +//! runtime_policy_name: None, +//! runtime_policy_sig_key: None, +//! mb_policy: None, +//! payload: None, +//! cert_dir: None, +//! verify: true, +//! push_model: false, +//! allow_unverified_quote: false, +//! pull_model: false, +//! tpm_policy: None, +//! wait_for_attestation: false, +//! attestation_timeout: 60, +//! }; +//! +//! let result = agent::execute(&action, &config, &output).await?; +//! println!("Agent operation result: {:?}", result); +//! # Ok(()) +//! # } +//! ``` + +mod add; +#[cfg(feature = "api-v2")] +mod attestation; +mod helpers; +mod reactivate; +mod remove; +mod status; +pub mod types; +mod update; + +#[allow(unused_imports)] // Re-export for downstream use +pub use types::AddAgentRequest; + +use add::add_agent; +use reactivate::reactivate_agent; +use remove::remove_agent; +use status::get_agent_status; +use types::AddAgentParams; +use update::update_agent; + +use crate::client::factory; +use crate::error::{ErrorContext, KeylimectlError}; +use crate::output::OutputHandler; +use crate::AgentAction; +use serde_json::{json, Value}; + +/// Execute an agent management command +/// +/// This is the main entry point for all agent-related operations. It dispatches +/// to the appropriate handler based on the action type and manages the complete +/// operation lifecycle including progress reporting and error handling. +/// +/// # Arguments +/// +/// * `action` - The specific agent action to perform (Add, Remove, Update, or Reactivate) +/// * `config` - Configuration containing service endpoints and authentication settings +/// * `output` - Output handler for progress reporting and result formatting +/// +/// # Returns +/// +/// Returns a JSON value containing the operation results, which typically includes: +/// - `status`: Success/failure indicator +/// - `message`: Human-readable status message +/// - `results`: Detailed operation results from the services +/// - `agent_uuid`: The UUID of the affected agent +/// +/// # Error Handling +/// +/// This function handles various error conditions: +/// - Invalid UUIDs are rejected with validation errors +/// - Network failures are retried according to client configuration +/// - Service errors are propagated with detailed context +/// - Missing agents result in appropriate not-found errors +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::commands::agent; +/// use keylimectl::config::Config; +/// use keylimectl::output::OutputHandler; +/// use keylimectl::AgentAction; +/// +/// # async fn example() -> Result<(), Box> { +/// let config = Config::default(); +/// let output = OutputHandler::new(crate::OutputFormat::Json, false); +/// +/// // Add an agent +/// let add_action = AgentAction::Add { +/// uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), +/// ip: Some("192.168.1.100".to_string()), +/// port: Some(9002), +/// verifier_ip: None, +/// runtime_policy: None, +/// runtime_policy_name: None, +/// runtime_policy_sig_key: None, +/// mb_policy: None, +/// payload: None, +/// cert_dir: None, +/// verify: true, +/// push_model: false, +/// allow_unverified_quote: false, +/// pull_model: false, +/// tpm_policy: None, +/// wait_for_attestation: false, +/// attestation_timeout: 60, +/// }; +/// +/// let result = agent::execute(&add_action, &config, &output).await?; +/// assert_eq!(result["status"], "success"); +/// +/// // Remove the same agent +/// let remove_action = AgentAction::Remove { +/// uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), +/// registrar: false, +/// force: false, +/// }; +/// +/// let result = agent::execute(&remove_action, &config, &output).await?; +/// assert_eq!(result["status"], "success"); +/// # Ok(()) +/// # } +/// ``` +pub async fn execute( + action: &AgentAction, + output: &OutputHandler, +) -> Result { + match action { + AgentAction::Add { + uuid, + ip, + port, + verifier_ip, + runtime_policy, + runtime_policy_name, + runtime_policy_sig_key, + mb_policy, + payload, + cert_dir, + verify, + push_model, + pull_model, + tpm_policy, + allow_unverified_quote, + wait_for_attestation, + attestation_timeout, + } => add_agent( + AddAgentParams { + agent_id: uuid, + ip: ip.as_deref(), + port: *port, + verifier_ip: verifier_ip.as_deref(), + runtime_policy: runtime_policy.as_deref(), + runtime_policy_name: runtime_policy_name.as_deref(), + runtime_policy_sig_key: runtime_policy_sig_key.as_deref(), + mb_policy: mb_policy.as_deref(), + payload: payload.as_deref(), + cert_dir: cert_dir.as_deref(), + verify: *verify, + push_model: *push_model, + pull_model: *pull_model, + tpm_policy: tpm_policy.as_deref(), + allow_unverified_quote: *allow_unverified_quote, + wait_for_attestation: *wait_for_attestation, + attestation_timeout: *attestation_timeout, + }, + output, + ) + .await + .map_err(KeylimectlError::from), + AgentAction::Remove { + uuid, + registrar, + force, + } => remove_agent(uuid, *registrar, *force, output) + .await + .map_err(KeylimectlError::from), + AgentAction::Update { + uuid, + runtime_policy, + runtime_policy_name, + runtime_policy_sig_key, + mb_policy, + } => update_agent( + uuid, + runtime_policy.as_deref(), + runtime_policy_name.as_deref(), + runtime_policy_sig_key.as_deref(), + mb_policy.as_deref(), + output, + ) + .await + .map_err(KeylimectlError::from), + AgentAction::Status { + uuid, + verifier, + registrar_only, + } => get_agent_status(uuid, *verifier, *registrar_only, output).await, + AgentAction::Reactivate { uuid } => reactivate_agent(uuid, output) + .await + .map_err(KeylimectlError::from), + AgentAction::List { + detailed, + registrar_only, + } => list_agents(*detailed, *registrar_only, output).await, + } +} + +/// List all agents +async fn list_agents( + detailed: bool, + registrar_only: bool, + output: &OutputHandler, +) -> Result { + if registrar_only { + output.info("Listing agents from registrar only"); + + let registrar_client = factory::get_registrar().await?; + let registrar_data = + registrar_client.list_agents().await.with_context(|| { + "Failed to list agents from registrar".to_string() + })?; + + Ok(registrar_data) + } else if detailed { + output.info("Retrieving detailed agent information from both verifier and registrar"); + + let verifier_client = factory::get_verifier().await?; + + // Get detailed info from verifier + let verifier_data = verifier_client + .get_bulk_info( + crate::config::singleton::get_config() + .verifier + .id + .as_deref(), + ) + .await + .with_context(|| { + "Failed to get bulk agent info from verifier".to_string() + })?; + + // Also get registrar data for complete picture + let registrar_client = factory::get_registrar().await?; + let registrar_data = + registrar_client.list_agents().await.with_context(|| { + "Failed to list agents from registrar".to_string() + })?; + + Ok(json!({ + "detailed": true, + "verifier": verifier_data, + "registrar": registrar_data + })) + } else { + output.info("Listing agents from verifier"); + + let verifier_client = factory::get_verifier().await?; + + // Just get basic list from verifier + let verifier_data = verifier_client + .list_agents( + crate::config::singleton::get_config() + .verifier + .id + .as_deref(), + ) + .await + .with_context(|| { + "Failed to list agents from verifier".to_string() + })?; + + Ok(verifier_data) + } +} + +#[cfg(test)] +mod tests { + use crate::commands::error::CommandError; + use crate::config::{ + AgentConfig, CliOverrides, ClientConfig, Config, RegistrarConfig, + TlsConfig, VerifierConfig, + }; + use crate::output::OutputHandler; + use crate::AgentAction; + use serde_json::json; + + /// Create a test configuration for agent operations + fn create_test_config() -> Config { + Config { + loaded_from: None, + cli_overrides: CliOverrides::default(), + verifier: VerifierConfig { + ip: "127.0.0.1".to_string(), + port: 8881, + id: Some("test-verifier".to_string()), + }, + registrar: RegistrarConfig { + ip: "127.0.0.1".to_string(), + port: 8891, + }, + tls: TlsConfig { + client_cert: None, + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: false, // Disable for testing + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }, + client: ClientConfig { + timeout: 30, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + }, + agent: AgentConfig::default(), + } + } + + /// Create a test output handler + fn _create_test_output() -> OutputHandler { + OutputHandler::new( + crate::OutputFormat::Json, + true, + crate::ColorMode::Never, + ) // Quiet mode for tests + } + + #[test] + fn test_config_creation() { + let config = create_test_config(); + + assert_eq!(config.verifier.ip, "127.0.0.1"); + assert_eq!(config.verifier.port, 8881); + assert_eq!(config.registrar.ip, "127.0.0.1"); + assert_eq!(config.registrar.port, 8891); + assert!(!config.tls.verify_server_cert); + assert_eq!(config.client.max_retries, 3); + } + + #[test] + fn test_output_handler_creation() { + let _output = _create_test_output(); + // OutputHandler doesn't expose its internal fields, but we can verify it was created + // by ensuring no panic occurred during creation + } + + // Test agent ID validation behavior + mod agent_id_validation { + + #[test] + fn test_valid_agent_id_formats() { + let valid_ids = [ + "550e8400-e29b-41d4-a716-446655440000", // UUID format + "agent-001", // Simple identifier + "AAA", // Simple uppercase + "aaa", // Simple lowercase + "my-agent", // Hyphenated + "agent_123", // Underscore + "Agent123", // Mixed case + "1234567890", // Numeric + "a", // Single character + "test-agent-with-long-name-but-under-255-chars", // Long but valid + ]; + + for agent_id in &valid_ids { + // Test that ID is not empty + assert!( + !agent_id.is_empty(), + "Agent ID {agent_id} should not be empty" + ); + + // Test that ID is under 255 characters + assert!( + agent_id.len() <= 255, + "Agent ID {agent_id} should be <= 255 chars" + ); + + // Test that ID has no control characters + assert!( + !agent_id.chars().any(|c| c.is_control()), + "Agent ID {agent_id} should have no control characters" + ); + } + } + + #[test] + fn test_invalid_agent_id_formats() { + let invalid_ids = [ + "", // Empty string + &"a".repeat(256), // Too long (>255 chars) + "agent\x00id", // Contains null character (control character) + "agent\nid", // Contains newline (control character) + "agent\tid", // Contains tab (control character) + ]; + + for agent_id in &invalid_ids { + // Check various validation conditions + let is_empty = agent_id.is_empty(); + let is_too_long = agent_id.len() > 255; + let has_control_chars = + agent_id.chars().any(|c| c.is_control()); + + assert!(is_empty || is_too_long || has_control_chars, + "Agent ID {agent_id:?} should fail at least one validation"); + } + } + } + + // Test error handling and validation + mod error_handling { + use super::*; + + #[test] + fn test_agent_action_variants() { + // Test that all AgentAction variants can be created + let add_action = AgentAction::Add { + uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), + ip: Some("192.168.1.100".to_string()), + port: Some(9002), + verifier_ip: None, + runtime_policy: None, + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: None, + payload: None, + cert_dir: None, + verify: true, + push_model: false, + pull_model: false, + tpm_policy: None, + allow_unverified_quote: false, + wait_for_attestation: false, + attestation_timeout: 60, + }; + + let remove_action = AgentAction::Remove { + uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), + registrar: false, + force: false, + }; + + let update_action = AgentAction::Update { + uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), + runtime_policy: Some("/path/to/policy.json".to_string()), + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: None, + }; + + let status_action = AgentAction::Status { + uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), + verifier: false, + registrar_only: false, + }; + + let reactivate_action = AgentAction::Reactivate { + uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), + }; + + // Verify actions were created without panicking + match add_action { + AgentAction::Add { uuid, .. } => { + assert_eq!(uuid, "550e8400-e29b-41d4-a716-446655440000"); + } + _ => panic!("Expected Add action"), //#[allow_ci] + } + + match remove_action { + AgentAction::Remove { + uuid, + registrar, + force, + } => { + assert_eq!(uuid, "550e8400-e29b-41d4-a716-446655440000"); + assert!(!registrar); + assert!(!force); + } + _ => panic!("Expected Remove action"), //#[allow_ci] + } + + match update_action { + AgentAction::Update { + uuid, + runtime_policy, + mb_policy, + .. + } => { + assert_eq!(uuid, "550e8400-e29b-41d4-a716-446655440000"); + assert!(runtime_policy.is_some()); + assert!(mb_policy.is_none()); + } + _ => panic!("Expected Update action"), //#[allow_ci] + } + + match status_action { + AgentAction::Status { + uuid, + verifier, + registrar_only, + } => { + assert_eq!(uuid, "550e8400-e29b-41d4-a716-446655440000"); + assert!(!verifier); + assert!(!registrar_only); + } + _ => panic!("Expected Status action"), //#[allow_ci] + } + + match reactivate_action { + AgentAction::Reactivate { uuid } => { + assert_eq!(uuid, "550e8400-e29b-41d4-a716-446655440000"); + } + _ => panic!("Expected Reactivate action"), //#[allow_ci] + } + } + + #[test] + fn test_error_context_trait() { + use crate::error::ErrorContext; + + // Test that we can add context to errors + let io_error: Result<(), std::io::Error> = + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "file not found", + )); + + let contextual_error = io_error.with_context(|| { + "Failed to process agent configuration".to_string() + }); + + assert!(contextual_error.is_err()); + let error = contextual_error.unwrap_err(); + assert_eq!(error.error_code(), "GENERIC_ERROR"); + } + + #[test] + fn test_command_error_types() { + // Test agent not found error + let _agent_error = + CommandError::agent_not_found("test-uuid", "verifier"); + // Note: category() method removed as unused + + // Test validation error + let _validation_error = CommandError::invalid_parameter( + "uuid", + "Invalid UUID format", + ); + // Note: category() method removed as unused + + // Test resource error + let _resource_error = CommandError::resource_error( + "verifier", + "Failed to connect to service", + ); + // Note: category() method removed as unused + } + } + + // Test JSON response structures + mod json_responses { + use super::*; + + #[test] + fn test_success_response_structure() { + let response = json!({ + "status": "success", + "message": "Agent operation completed successfully", + "agent_uuid": "550e8400-e29b-41d4-a716-446655440000", + "results": { + "verifier_response": "OK" + } + }); + + assert_eq!(response["status"], "success"); + assert_eq!( + response["agent_uuid"], + "550e8400-e29b-41d4-a716-446655440000" + ); + assert!(response["results"].is_object()); + } + + #[test] + fn test_error_response_structure() { + let error = + CommandError::agent_not_found("test-uuid", "verifier"); + let error_string = error.to_string(); + + assert!(error_string.contains("Agent error")); + assert!(error_string.contains("test-uuid")); + assert!(error_string.contains("verifier")); + assert!(error_string.contains("not found")); + } + } + + // Test configuration validation + mod config_validation { + use super::*; + + #[test] + fn test_config_validation_success() { + let config = create_test_config(); + let result = config.validate(); + assert!(result.is_ok(), "Test config should be valid"); + } + + #[test] + fn test_config_urls() { + let config = create_test_config(); + + assert_eq!(config.verifier_base_url(), "https://127.0.0.1:8881"); + assert_eq!(config.registrar_base_url(), "https://127.0.0.1:8891"); + } + + #[test] + fn test_config_with_ipv6() { + let mut config = create_test_config(); + config.verifier.ip = "::1".to_string(); + config.registrar.ip = "[2001:db8::1]".to_string(); + + assert_eq!(config.verifier_base_url(), "https://[::1]:8881"); + assert_eq!( + config.registrar_base_url(), + "https://[2001:db8::1]:8891" + ); + } + } + + // Test integration patterns (would require running services in real integration tests) + mod integration_patterns { + use super::*; + + #[test] + fn test_agent_action_serialization() { + // Test that AgentAction can be serialized/deserialized if needed for IPC + let add_action = AgentAction::Add { + uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), + ip: Some("192.168.1.100".to_string()), + port: Some(9002), + verifier_ip: None, + runtime_policy: None, + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: None, + payload: None, + cert_dir: None, + verify: true, + push_model: false, + pull_model: false, + tpm_policy: None, + allow_unverified_quote: false, + wait_for_attestation: false, + attestation_timeout: 60, + }; + + // Verify the action was created properly + match add_action { + AgentAction::Add { + uuid, + ip, + port, + verify, + push_model, + .. + } => { + assert_eq!(uuid, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(ip, Some("192.168.1.100".to_string())); + assert_eq!(port, Some(9002)); + assert!(verify); + assert!(!push_model); + } + _ => panic!("Expected Add action"), //#[allow_ci] + } + } + + #[test] + fn test_configuration_loading_patterns() { + // Test different configuration patterns + let default_config = Config::default(); + assert_eq!(default_config.verifier.ip, "127.0.0.1"); + assert_eq!(default_config.verifier.port, 8881); + assert_eq!(default_config.registrar.port, 8891); + + // Test configuration modification + let mut custom_config = default_config; + custom_config.verifier.ip = "10.0.0.1".to_string(); + custom_config.verifier.port = 9001; + + assert_eq!(custom_config.verifier.ip, "10.0.0.1"); + assert_eq!(custom_config.verifier.port, 9001); + } + } +} diff --git a/keylimectl/src/commands/agent/reactivate.rs b/keylimectl/src/commands/agent/reactivate.rs new file mode 100644 index 000000000..a1a5140e4 --- /dev/null +++ b/keylimectl/src/commands/agent/reactivate.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Agent reactivation command + +use crate::client::factory; +use crate::commands::error::CommandError; +use crate::output::OutputHandler; +use serde_json::{json, Value}; + +/// Reactivate a failed agent +pub(super) async fn reactivate_agent( + agent_id: &str, + output: &OutputHandler, +) -> Result { + // Validate agent ID + if agent_id.is_empty() { + return Err(CommandError::invalid_parameter( + "agent_id", + "Agent ID cannot be empty".to_string(), + )); + } + + output.info(format!("Reactivating agent {agent_id}")); + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let response = + verifier_client + .reactivate_agent(agent_id) + .await + .map_err(|e| { + CommandError::resource_error( + "verifier", + format!("Failed to reactivate agent: {e}"), + ) + })?; + + output.info(format!("Agent {agent_id} successfully reactivated")); + + Ok(json!({ + "status": "success", + "message": format!("Agent {agent_id} reactivated successfully"), + "agent_id": agent_id, + "results": response + })) +} diff --git a/keylimectl/src/commands/agent/remove.rs b/keylimectl/src/commands/agent/remove.rs new file mode 100644 index 000000000..7f506d5a8 --- /dev/null +++ b/keylimectl/src/commands/agent/remove.rs @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Agent removal command + +use crate::client::factory; +use crate::client::verifier::VerifierClient; +use crate::commands::error::CommandError; +use crate::config::singleton::get_config; +use crate::output::OutputHandler; +use log::{debug, warn}; +use serde_json::{json, Value}; + +/// Remove an agent from the verifier (and optionally registrar) +/// +/// When the verifier responds to DELETE with 202 Accepted (meaning an +/// in-flight attestation cycle is still completing), this function polls +/// until the agent is fully gone before returning. Callers can therefore +/// assume the agent is no longer present on the verifier when this +/// function succeeds. +pub(super) async fn remove_agent( + agent_id: &str, + registrar: bool, + force: bool, + output: &OutputHandler, +) -> Result { + // Validate agent ID + if agent_id.is_empty() { + return Err(CommandError::invalid_parameter( + "agent_id", + "Agent ID cannot be empty".to_string(), + )); + } + + output.info(format!("Removing agent {agent_id} from verifier")); + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + + // Check if agent exists on verifier (unless force is used) + if !force { + output.step( + 1, + if registrar { 3 } else { 2 }, + "Checking agent status on verifier", + ); + + match verifier_client.get_agent(agent_id).await { + Ok(Some(_)) => { + debug!("Agent found on verifier"); + } + Ok(None) => { + warn!("Agent not found on verifier, but continuing with removal"); + } + Err(e) => { + if !force { + return Err(CommandError::resource_error( + "verifier", + e.to_string(), + )); + } + warn!("Failed to check agent status, but continuing due to force flag: {e}"); + } + } + } + + // Remove from verifier + let step_num = if force { 1 } else { 2 }; + let total_steps = if registrar { + if force { + 2 + } else { + 3 + } + } else if force { + 1 + } else { + 2 + }; + + output.step(step_num, total_steps, "Removing agent from verifier"); + + let verifier_response = + verifier_client.delete_agent(agent_id).await.map_err(|e| { + CommandError::resource_error( + "verifier", + format!("Failed to remove agent: {e}"), + ) + })?; + + // If the verifier returned 202 Accepted, deletion is asynchronous: + // an in-flight attestation cycle is still running. Poll until the + // agent is fully gone so the caller gets a clean post-condition. + let http_status = verifier_response + .get("http_status") + .and_then(|v| v.as_u64()) + .unwrap_or(200); + + if http_status == 202 { + poll_agent_removal(verifier_client, agent_id, output).await?; + } + + let mut results = json!({ + "verifier": verifier_response + }); + + // Remove from registrar if requested + if registrar { + output.step( + total_steps, + total_steps, + "Removing agent from registrar", + ); + + let registrar_client = + factory::get_registrar().await.map_err(|e| { + CommandError::connection_error("registrar", e.to_string()) + })?; + let registrar_response = + registrar_client.delete_agent(agent_id).await.map_err(|e| { + CommandError::resource_error( + "registrar", + format!("Failed to remove agent: {e}"), + ) + })?; + + results["registrar"] = registrar_response; + } + + output.info(format!("Agent {agent_id} successfully removed")); + + Ok(json!({ + "status": "success", + "message": format!("Agent {agent_id} removed successfully"), + "agent_id": agent_id, + "results": results + })) +} + +/// Poll the verifier until the agent is fully removed (returns 404). +/// +/// After a DELETE returns 202, the verifier processes the removal +/// asynchronously while the current attestation cycle completes. This +/// function waits with exponential backoff until the agent is gone. +async fn poll_agent_removal( + verifier_client: &VerifierClient, + agent_id: &str, + output: &OutputHandler, +) -> Result<(), CommandError> { + let config = get_config(); + let max_retries = config.client.max_retries as usize; + let retry_interval = config.client.retry_interval; + let exponential_backoff = config.client.exponential_backoff; + + let wait_handle = output.start_wait(format!( + "Waiting for verifier to complete deletion of agent {agent_id}..." + )); + + for attempt in 0..=max_retries { + match verifier_client.get_agent(agent_id).await { + Ok(None) => { + drop(wait_handle); + debug!( + "Agent {agent_id} fully removed after {attempt} poll(s)" + ); + return Ok(()); + } + Ok(Some(_)) => { + if attempt >= max_retries { + drop(wait_handle); + return Err(CommandError::agent_operation_failed( + agent_id, + "remove", + format!( + "Verifier did not finish removing the agent after \ + {max_retries} retries. Try again with: \ + keylimectl agent remove {agent_id}" + ), + )); + } + + let delay = compute_backoff( + retry_interval, + exponential_backoff, + attempt, + ); + wait_handle.set_message(format!( + "Agent {agent_id} still present on verifier, \ + retrying in {delay:.1}s (attempt {}/{max_retries})", + attempt + 1 + )); + debug!( + "Agent {agent_id} still present, sleeping {delay:.1}s \ + (attempt {attempt}/{max_retries})" + ); + tokio::time::sleep(std::time::Duration::from_secs_f64(delay)) + .await; + } + Err(e) => { + drop(wait_handle); + return Err(CommandError::resource_error( + "verifier", + format!("Failed to poll agent deletion status: {e}"), + )); + } + } + } + + // Unreachable: the loop returns in all branches when attempt == max_retries + Ok(()) +} + +/// Compute the delay before the next retry attempt. +/// +/// With exponential backoff: delay = interval * 2^attempt, capped at 60s. +/// Without: delay = interval (constant). +fn compute_backoff( + retry_interval: f64, + exponential_backoff: bool, + attempt: usize, +) -> f64 { + let delay = if exponential_backoff { + retry_interval * 2.0_f64.powi(attempt as i32) + } else { + retry_interval + }; + delay.min(60.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_backoff_linear() { + let delay = compute_backoff(1.0, false, 0); + assert!((delay - 1.0).abs() < f64::EPSILON); + let delay = compute_backoff(1.0, false, 5); + assert!((delay - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn test_compute_backoff_exponential() { + assert!((compute_backoff(1.0, true, 0) - 1.0).abs() < f64::EPSILON); + assert!((compute_backoff(1.0, true, 1) - 2.0).abs() < f64::EPSILON); + assert!((compute_backoff(1.0, true, 2) - 4.0).abs() < f64::EPSILON); + assert!((compute_backoff(1.0, true, 3) - 8.0).abs() < f64::EPSILON); + } + + #[test] + fn test_compute_backoff_cap() { + // Should be capped at 60 seconds + let delay = compute_backoff(1.0, true, 10); // 2^10 = 1024 + assert!((delay - 60.0).abs() < f64::EPSILON); + } + + #[test] + fn test_compute_backoff_custom_interval() { + assert!((compute_backoff(2.0, true, 2) - 8.0).abs() < f64::EPSILON); + assert!((compute_backoff(0.5, true, 3) - 4.0).abs() < f64::EPSILON); + } +} diff --git a/keylimectl/src/commands/agent/status.rs b/keylimectl/src/commands/agent/status.rs new file mode 100644 index 000000000..2803b78f8 --- /dev/null +++ b/keylimectl/src/commands/agent/status.rs @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Agent status query command + +#[cfg(feature = "api-v2")] +use crate::client::agent::AgentClient; +use crate::client::factory; +use crate::commands::error::CommandError; +#[cfg(feature = "api-v2")] +use crate::config::singleton::get_config; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use serde_json::{json, Value}; + +/// Get agent status from verifier and/or registrar +pub(super) async fn get_agent_status( + agent_id: &str, + verifier: bool, + registrar_only: bool, + output: &OutputHandler, +) -> Result { + // Validate agent ID + if agent_id.is_empty() { + return Err(KeylimectlError::validation("Agent ID cannot be empty")); + } + + output.info(format!("Getting status for agent {agent_id}")); + + let mut results = json!({}); + + // Get status from registrar (unless verifier is set) + if !verifier { + output.progress("Checking registrar status"); + + let registrar_client = + factory::get_registrar().await.map_err(|e| { + CommandError::connection_error("registrar", e.to_string()) + })?; + match registrar_client.get_agent(agent_id).await { + Ok(Some(agent_data)) => { + results["registrar"] = json!({ + "status": "found", + "data": agent_data + }); + } + Ok(None) => { + results["registrar"] = json!({ + "status": "not_found" + }); + } + Err(e) => { + results["registrar"] = json!({ + "status": "error", + "error": e.to_string() + }); + } + } + } + + // Get status from verifier (unless registrar_only is set) + if !registrar_only { + output.progress("Checking verifier status"); + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + match verifier_client.get_agent(agent_id).await { + Ok(Some(agent_data)) => { + results["verifier"] = json!({ + "status": "found", + "data": agent_data + }); + } + Ok(None) => { + results["verifier"] = json!({ + "status": "not_found" + }); + } + Err(e) => { + results["verifier"] = json!({ + "status": "error", + "error": e.to_string() + }); + } + } + } + + // Check agent directly if API < 3.0 and we have connection details + // This is only applicable for pull model (api-v2) + #[cfg(feature = "api-v2")] + if !registrar_only { + // Extract IP and port from results (clone to avoid borrow conflicts) + let agent_connection = { + let registrar_data = + results.get("registrar").and_then(|r| r.get("data")); + let verifier_data = + results.get("verifier").and_then(|v| v.get("data")); + match (registrar_data, verifier_data) { + (Some(reg), Some(ver)) => { + let ip = ver + .get("ip") + .or_else(|| reg.get("ip")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let port = ver + .get("port") + .or_else(|| reg.get("port")) + .and_then(|v| v.as_u64().map(|p| p as u16)); + ip.zip(port) + } + _ => None, + } + }; + + if let Some((ip, port)) = agent_connection { + let verifier_client = + factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let api_version = + verifier_client.api_version().parse::().unwrap_or(2.1); + + if api_version < 3.0 { + results["model"] = json!("pull"); + output.progress("Checking agent status directly"); + + match AgentClient::builder() + .agent_ip(&ip) + .agent_port(port) + .config(get_config()) + .build() + .await + { + Ok(agent_client) => { + match agent_client + .get_quote("test_connectivity") + .await + { + Ok(_) => { + results["agent"] = json!({ + "status": "responsive", + "connection": format!("{ip}:{port}") + }); + } + Err(e) => { + if e.to_string().contains("400") + || e.to_string().contains("Bad Request") + { + results["agent"] = json!({ + "status": "responsive", + "connection": format!("{ip}:{port}"), + "note": "Agent rejected test nonce (expected)" + }); + } else { + results["agent"] = json!({ + "status": "unreachable", + "connection": format!("{ip}:{port}"), + "error": e.to_string() + }); + } + } + } + } + Err(e) => { + results["agent"] = json!({ + "status": "connection_failed", + "connection": format!("{ip}:{port}"), + "error": e.to_string() + }); + } + } + } else { + results["agent"] = json!({ + "status": "not_applicable", + "note": "Direct agent communication is not used with push model (API >= 3.0). \ + Agent attestation status is managed by the verifier." + }); + results["model"] = json!("push"); + } + } + } + + let result_map = results.as_object().expect("results is an object"); + let failed_statuses = + ["error", "connection_failed", "not_found", "unreachable"]; + let any_failed = result_map.values().any(|v| { + v.get("status") + .and_then(|s| s.as_str()) + .is_some_and(|s| failed_statuses.contains(&s)) + }); + + if any_failed { + return Err(KeylimectlError::validation_failed( + format!("Agent {agent_id} status check found issues"), + json!({ + "agent_id": agent_id, + "results": results + }), + )); + } + + Ok(json!({ + "agent_id": agent_id, + "results": results + })) +} diff --git a/keylimectl/src/commands/agent/types.rs b/keylimectl/src/commands/agent/types.rs new file mode 100644 index 000000000..ff8678a0d --- /dev/null +++ b/keylimectl/src/commands/agent/types.rs @@ -0,0 +1,762 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Types and validation helpers for agent commands + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Parameters for adding an agent to the verifier +/// +/// This struct groups all the parameters needed for agent addition to improve +/// function signature readability and maintainability. +/// +/// # Fields +/// +/// * `agent_id` - Agent identifier (can be any string, not necessarily a UUID) +/// * `ip` - Optional agent IP address (overrides registrar data) +/// * `port` - Optional agent port (overrides registrar data) +/// * `verifier_ip` - Optional verifier IP for agent communication +/// * `runtime_policy` - Optional path to runtime policy file +/// * `mb_policy` - Optional path to measured boot policy file +/// * `payload` - Optional path to payload file for agent +/// * `cert_dir` - Optional path to certificate directory +/// * `verify` - Whether to perform key derivation verification +/// * `push_model` - Whether to use push model (agent connects to verifier) +pub(super) struct AddAgentParams<'a> { + /// Agent identifier - can be any string + pub agent_id: &'a str, + /// Optional agent IP address (overrides registrar data) + pub ip: Option<&'a str>, + /// Optional agent port (overrides registrar data) + pub port: Option, + /// Optional verifier IP for agent communication + #[cfg_attr(not(feature = "api-v2"), allow(dead_code))] + pub verifier_ip: Option<&'a str>, + /// Optional path to runtime policy file + pub runtime_policy: Option<&'a str>, + /// Optional name for the runtime policy in the verifier database + pub runtime_policy_name: Option<&'a str>, + /// Optional path to public key file for DSSE signature verification + pub runtime_policy_sig_key: Option<&'a str>, + /// Optional path to measured boot policy file + pub mb_policy: Option<&'a str>, + /// Optional path to payload file for agent + pub payload: Option<&'a str>, + /// Optional path to certificate directory + pub cert_dir: Option<&'a str>, + /// Whether to perform key derivation verification (pull model only) + #[cfg_attr(not(feature = "api-v2"), allow(dead_code))] + pub verify: bool, + /// Whether to use push model (agent connects to verifier) + pub push_model: bool, + /// Whether to force pull model (legacy, overrides auto-detection) + pub pull_model: bool, + /// Optional TPM policy in JSON format + pub tpm_policy: Option<&'a str>, + /// Allow proceeding with unverified TPM quotes (INSECURE: for development only) + #[cfg_attr(not(feature = "api-v2"), allow(dead_code))] + pub allow_unverified_quote: bool, + /// Whether to wait for attestation after enrollment + pub wait_for_attestation: bool, + /// Timeout for waiting for attestation (seconds) + pub attestation_timeout: u64, +} + +/// Request structure for adding an agent to the verifier +/// +/// This struct represents the complete request payload sent to the verifier +/// when adding an agent for attestation monitoring. It uses serde for +/// automatic JSON serialization and ensures type safety. +/// +/// # Core Required Fields +/// +/// * `cloudagent_ip` - IP address where the agent can be reached +/// * `cloudagent_port` - Port where the agent is listening +/// * `verifier_ip` - IP address of the verifier +/// * `verifier_port` - Port of the verifier +/// * `ak_tpm` - Agent's attestation key from TPM +/// * `mtls_cert` - Mutual TLS certificate for agent communication +/// * `tpm_policy` - TPM policy in JSON format +/// +/// # Legacy Compatibility Fields +/// +/// * `v` - Optional V key from attestation (for API < 3.0) +/// +/// # Policy Fields +/// +/// * `runtime_policy` - Runtime policy content +/// * `runtime_policy_name` - Name of the runtime policy +/// * `runtime_policy_key` - Runtime policy signature key +/// * `mb_policy` - Measured boot policy content +/// * `mb_policy_name` - Name of the measured boot policy +/// +/// # Security & Verification Fields +/// +/// * `ima_sign_verification_keys` - IMA signature verification keys +/// * `revocation_key` - Revocation key for certificates +/// * `accept_tpm_hash_algs` - Accepted TPM hash algorithms +/// * `accept_tpm_encryption_algs` - Accepted TPM encryption algorithms +/// * `accept_tpm_signing_algs` - Accepted TPM signing algorithms +/// +/// # Additional Fields +/// +/// * `metadata` - Metadata in JSON format +/// * `payload` - Optional payload content +/// * `cert_dir` - Optional certificate directory path +/// * `supported_version` - API version supported by the agent +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddAgentRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub cloudagent_ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cloudagent_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub verifier_ip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub verifier_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ak_tpm: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtls_cert: Option, + pub tpm_policy: String, + + // Legacy compatibility (API < 3.0) + #[serde(skip_serializing_if = "Option::is_none")] + pub v: Option, + + // Runtime policy fields + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_policy_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_policy_key: Option, + // Measured boot policy fields + #[serde(skip_serializing_if = "Option::is_none")] + pub mb_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mb_policy_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mb_refstate: Option, + + // IMA and verification keys + #[serde(skip_serializing_if = "Option::is_none")] + pub ima_sign_verification_keys: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revocation_key: Option, + + // TPM algorithm support + #[serde(skip_serializing_if = "Option::is_none")] + pub accept_tpm_hash_algs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub accept_tpm_encryption_algs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub accept_tpm_signing_algs: Option>, + + // Metadata and additional fields + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub payload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cert_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub supported_version: Option, +} + +impl AddAgentRequest { + /// Create a new agent request with the required fields. + /// + /// Policy-related fields (`runtime_policy`, `runtime_policy_name`, + /// `runtime_policy_key`, `mb_policy_name`) are + /// initialized to empty strings so the verifier always receives them. + /// Use builder methods to override these defaults. + #[must_use] + pub fn new( + cloudagent_ip: Option, + cloudagent_port: Option, + verifier_ip: Option, + verifier_port: Option, + tpm_policy: String, + ) -> Self { + Self { + cloudagent_ip, + cloudagent_port, + verifier_ip, + verifier_port, + ak_tpm: None, + mtls_cert: None, + tpm_policy, + v: None, + runtime_policy: Some(String::new()), + runtime_policy_name: Some(String::new()), + runtime_policy_key: Some(Value::String(String::new())), + mb_policy: None, + mb_policy_name: Some(String::new()), + mb_refstate: None, + ima_sign_verification_keys: None, + revocation_key: None, + accept_tpm_hash_algs: None, + accept_tpm_encryption_algs: None, + accept_tpm_signing_algs: None, + metadata: None, + payload: None, + cert_dir: None, + supported_version: None, + } + } + + /// Set the TPM attestation key + #[must_use] + pub fn with_ak_tpm(mut self, ak_tpm: Option) -> Self { + self.ak_tpm = ak_tpm; + self + } + + /// Set the mutual TLS certificate + #[must_use] + pub fn with_mtls_cert(mut self, mtls_cert: Option) -> Self { + self.mtls_cert = mtls_cert; + self + } + + /// Set the V key from attestation + #[must_use] + pub fn with_v_key(mut self, v_key: Option) -> Self { + self.v = v_key; + self + } + + /// Set the runtime policy + #[must_use] + pub fn with_runtime_policy(mut self, policy: Option) -> Self { + self.runtime_policy = policy; + self + } + + /// Set the measured boot policy + #[must_use] + pub fn with_mb_policy(mut self, policy: Option) -> Self { + self.mb_policy = policy; + self + } + + /// Set the payload + #[must_use] + pub fn with_payload(mut self, payload: Option) -> Self { + self.payload = payload; + self + } + + /// Set the certificate directory + #[must_use] + pub fn with_cert_dir(mut self, cert_dir: Option) -> Self { + self.cert_dir = cert_dir; + self + } + + /// Set the runtime policy name + #[must_use] + #[allow(dead_code)] // Used when --runtime-policy-name CLI arg is wired up + pub fn with_runtime_policy_name( + mut self, + policy_name: Option, + ) -> Self { + self.runtime_policy_name = policy_name; + self + } + + /// Set the runtime policy signature key + #[must_use] + #[allow(dead_code)] // Used when --runtime-policy-key CLI arg is wired up + pub fn with_runtime_policy_key( + mut self, + policy_key: Option, + ) -> Self { + self.runtime_policy_key = policy_key; + self + } + + /// Set the measured boot policy name + #[must_use] + pub fn with_mb_policy_name( + mut self, + policy_name: Option, + ) -> Self { + self.mb_policy_name = policy_name; + self + } + + /// Set the IMA signature verification keys + #[must_use] + pub fn with_ima_sign_verification_keys( + mut self, + keys: Option, + ) -> Self { + self.ima_sign_verification_keys = keys; + self + } + + /// Set the revocation key + #[must_use] + pub fn with_revocation_key(mut self, key: Option) -> Self { + self.revocation_key = key; + self + } + + /// Set the accepted TPM hash algorithms + #[must_use] + pub fn with_accept_tpm_hash_algs( + mut self, + algs: Option>, + ) -> Self { + self.accept_tpm_hash_algs = algs; + self + } + + /// Set the accepted TPM encryption algorithms + #[must_use] + pub fn with_accept_tpm_encryption_algs( + mut self, + algs: Option>, + ) -> Self { + self.accept_tpm_encryption_algs = algs; + self + } + + /// Set the accepted TPM signing algorithms + #[must_use] + pub fn with_accept_tpm_signing_algs( + mut self, + algs: Option>, + ) -> Self { + self.accept_tpm_signing_algs = algs; + self + } + + /// Set the metadata + #[must_use] + pub fn with_metadata(mut self, metadata: Option) -> Self { + self.metadata = metadata; + self + } + + /// Set the supported API version + #[must_use] + pub fn with_supported_version(mut self, version: Option) -> Self { + self.supported_version = version; + self + } + + /// Set the measured boot refstate + #[must_use] + pub fn with_mb_refstate(mut self, refstate: Option) -> Self { + self.mb_refstate = refstate; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_agent_params_creation() { + let params = AddAgentParams { + agent_id: "550e8400-e29b-41d4-a716-446655440000", + ip: Some("192.168.1.100"), + port: Some(9002), + verifier_ip: None, + runtime_policy: None, + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: None, + payload: None, + cert_dir: None, + verify: true, + push_model: false, + pull_model: false, + tpm_policy: None, + allow_unverified_quote: false, + wait_for_attestation: false, + attestation_timeout: 60, + }; + + assert_eq!(params.agent_id, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(params.ip, Some("192.168.1.100")); + assert_eq!(params.port, Some(9002)); + assert!(params.verify); + assert!(!params.push_model); + } + + #[test] + fn test_add_agent_params_with_policies() { + let params = AddAgentParams { + agent_id: "550e8400-e29b-41d4-a716-446655440000", + ip: None, + port: None, + verifier_ip: Some("10.0.0.1"), + runtime_policy: Some("/path/to/runtime.json"), + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: Some("/path/to/measured_boot.json"), + payload: Some("/path/to/payload.txt"), + cert_dir: Some("/path/to/certs"), + verify: false, + push_model: true, + pull_model: false, + tpm_policy: Some("{\"test\": \"policy\"}"), + allow_unverified_quote: false, + wait_for_attestation: false, + attestation_timeout: 60, + }; + + assert_eq!(params.runtime_policy, Some("/path/to/runtime.json")); + assert_eq!(params.mb_policy, Some("/path/to/measured_boot.json")); + assert_eq!(params.payload, Some("/path/to/payload.txt")); + assert_eq!(params.cert_dir, Some("/path/to/certs")); + assert!(!params.verify); + assert!(params.push_model); + } + + // Test various agent parameter combinations + mod parameter_combinations { + use super::*; + + #[test] + fn test_minimal_add_params() { + let params = AddAgentParams { + agent_id: "550e8400-e29b-41d4-a716-446655440000", + ip: None, + port: None, + verifier_ip: None, + runtime_policy: None, + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: None, + payload: None, + cert_dir: None, + verify: false, + push_model: false, + pull_model: false, + tpm_policy: None, + allow_unverified_quote: false, + wait_for_attestation: false, + attestation_timeout: 60, + }; + + assert_eq!( + params.agent_id, + "550e8400-e29b-41d4-a716-446655440000" + ); + assert!(params.ip.is_none()); + assert!(params.port.is_none()); + assert!(!params.verify); + assert!(!params.push_model); + } + + #[test] + fn test_maximal_add_params() { + let params = AddAgentParams { + agent_id: "550e8400-e29b-41d4-a716-446655440000", + ip: Some("192.168.1.100"), + port: Some(9002), + verifier_ip: Some("10.0.0.1"), + runtime_policy: Some("/etc/keylime/runtime.json"), + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: Some("/etc/keylime/measured_boot.json"), + payload: Some("/etc/keylime/payload.txt"), + cert_dir: Some("/etc/keylime/certs"), + verify: true, + push_model: true, + pull_model: false, + tpm_policy: Some("{\"pcr\": [\"15\"]}"), + allow_unverified_quote: false, + wait_for_attestation: false, + attestation_timeout: 60, + }; + + assert!(params.ip.is_some()); + assert!(params.port.is_some()); + assert!(params.verifier_ip.is_some()); + assert!(params.runtime_policy.is_some()); + assert!(params.mb_policy.is_some()); + assert!(params.payload.is_some()); + assert!(params.cert_dir.is_some()); + assert!(params.verify); + assert!(params.push_model); + } + + #[test] + fn test_push_model_params() { + let params = AddAgentParams { + agent_id: "550e8400-e29b-41d4-a716-446655440000", + ip: None, // IP not needed in push model + port: None, // Port not needed in push model + verifier_ip: None, + runtime_policy: None, + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: None, + payload: None, + cert_dir: None, + verify: false, // Verification different in push model + push_model: true, + pull_model: false, + tpm_policy: None, + allow_unverified_quote: false, + wait_for_attestation: false, + attestation_timeout: 60, + }; + + assert!(params.push_model); + assert!(!params.verify); + assert!(params.ip.is_none()); + assert!(params.port.is_none()); + } + } + + // Test comprehensive field support and validation + mod comprehensive_field_tests { + use super::*; + use serde_json::json; + + #[test] + fn test_add_agent_request_with_all_fields() { + // Create a request with all possible fields + let request = AddAgentRequest::new( + Some("192.168.1.100".to_string()), + Some(9002), + Some("127.0.0.1".to_string()), + Some(8881), + "{}".to_string(), + ) + .with_ak_tpm(Some(json!({"aik": "test_key"}))) + .with_mtls_cert(Some(json!({"cert": "test_cert"}))) + .with_v_key(Some(json!({"v": "test_v_key"}))) + .with_runtime_policy(Some("runtime policy content".to_string())) + .with_runtime_policy_name(Some("runtime_policy_1".to_string())) + .with_runtime_policy_key(Some(json!({"key": "policy_key"}))) + .with_mb_policy(Some("measured boot policy content".to_string())) + .with_mb_policy_name(Some("mb_policy_1".to_string())) + .with_ima_sign_verification_keys(Some("ima_keys".to_string())) + .with_revocation_key(Some("revocation_key".to_string())) + .with_accept_tpm_hash_algs(Some(vec![ + "sha256".to_string(), + "sha1".to_string(), + ])) + .with_accept_tpm_encryption_algs(Some(vec![ + "rsa".to_string(), + "ecc".to_string(), + ])) + .with_accept_tpm_signing_algs(Some(vec![ + "rsa".to_string(), + "ecdsa".to_string(), + ])) + .with_metadata(Some("{}".to_string())) + .with_payload(Some("test payload".to_string())) + .with_cert_dir(Some("/path/to/certs".to_string())) + .with_supported_version(Some("2.1".to_string())); + + // Validate that all fields are set correctly + assert_eq!( + request.cloudagent_ip, + Some("192.168.1.100".to_string()) + ); + assert_eq!(request.cloudagent_port, Some(9002)); + assert_eq!(request.verifier_ip, Some("127.0.0.1".to_string())); + assert_eq!(request.verifier_port, Some(8881)); + assert_eq!(request.tpm_policy, "{}"); + + assert!(request.ak_tpm.is_some()); + assert!(request.mtls_cert.is_some()); + assert!(request.v.is_some()); + + assert_eq!( + request.runtime_policy, + Some("runtime policy content".to_string()) + ); + assert_eq!( + request.runtime_policy_name, + Some("runtime_policy_1".to_string()) + ); + assert!(request.runtime_policy_key.is_some()); + + assert_eq!( + request.mb_policy, + Some("measured boot policy content".to_string()) + ); + assert_eq!( + request.mb_policy_name, + Some("mb_policy_1".to_string()) + ); + + assert_eq!( + request.ima_sign_verification_keys, + Some("ima_keys".to_string()) + ); + assert_eq!( + request.revocation_key, + Some("revocation_key".to_string()) + ); + + assert!(request.accept_tpm_hash_algs.is_some()); + assert!(request.accept_tpm_encryption_algs.is_some()); + assert!(request.accept_tpm_signing_algs.is_some()); + + assert_eq!(request.metadata, Some("{}".to_string())); + assert_eq!(request.payload, Some("test payload".to_string())); + assert_eq!(request.cert_dir, Some("/path/to/certs".to_string())); + assert_eq!(request.supported_version, Some("2.1".to_string())); + } + + #[test] + fn test_serialization_all_fields() { + let request = AddAgentRequest::new( + Some("192.168.1.100".to_string()), + Some(9002), + Some("127.0.0.1".to_string()), + Some(8881), + "{}".to_string(), + ) + .with_runtime_policy_name(Some("test_policy".to_string())) + .with_accept_tpm_hash_algs(Some(vec!["sha256".to_string()])) + .with_metadata(Some("{}".to_string())); + + let serialized = serde_json::to_string(&request).unwrap(); //#[allow_ci] + let json_value: Value = + serde_json::from_str(&serialized).unwrap(); //#[allow_ci] + + // Check that optional agent fields are present when set + assert_eq!(json_value["cloudagent_ip"], "192.168.1.100"); + assert_eq!(json_value["cloudagent_port"], 9002); + assert_eq!(json_value["verifier_ip"], "127.0.0.1"); + assert_eq!(json_value["verifier_port"], 8881); + assert_eq!(json_value["tpm_policy"], "{}"); + + // Check that optional fields are present when set + assert_eq!(json_value["runtime_policy_name"], "test_policy"); + assert_eq!(json_value["accept_tpm_hash_algs"], json!(["sha256"])); + assert_eq!(json_value["metadata"], "{}"); + + // Policy fields serialize as empty strings by default + assert_eq!(json_value["runtime_policy"], ""); + // mb_policy is absent when not set + assert!(json_value.get("mb_policy").is_none()); + } + } + + // Test Optional cloudagent_ip/cloudagent_port/verifier_ip/verifier_port + mod optional_agent_fields { + use super::*; + + #[test] + fn test_add_agent_request_with_none_ip_port() { + let request = AddAgentRequest::new( + None, + None, + Some("127.0.0.1".to_string()), + Some(8881), + "{}".to_string(), + ); + + assert_eq!(request.cloudagent_ip, None); + assert_eq!(request.cloudagent_port, None); + } + + #[test] + fn test_add_agent_request_none_fields_not_serialized() { + let request = AddAgentRequest::new( + None, + None, + None, + None, + "{}".to_string(), + ); + + let serialized = serde_json::to_string(&request).unwrap(); //#[allow_ci] + let json_value: Value = + serde_json::from_str(&serialized).unwrap(); //#[allow_ci] + + // Optional fields are absent when None + assert!(json_value.get("cloudagent_ip").is_none()); + assert!(json_value.get("cloudagent_port").is_none()); + assert!(json_value.get("verifier_ip").is_none()); + assert!(json_value.get("verifier_port").is_none()); + } + + #[test] + fn test_add_agent_request_some_fields_serialized() { + let request = AddAgentRequest::new( + Some("192.168.1.100".to_string()), + Some(9002), + Some("127.0.0.1".to_string()), + Some(8881), + "{}".to_string(), + ); + + let serialized = serde_json::to_string(&request).unwrap(); //#[allow_ci] + let json_value: Value = + serde_json::from_str(&serialized).unwrap(); //#[allow_ci] + + assert_eq!(json_value["cloudagent_ip"], "192.168.1.100"); + assert_eq!(json_value["cloudagent_port"], 9002); + assert_eq!(json_value["verifier_ip"], "127.0.0.1"); + assert_eq!(json_value["verifier_port"], 8881); + } + + #[test] + fn test_add_agent_params_with_pull_model() { + let params = AddAgentParams { + agent_id: "test-agent", + ip: None, + port: None, + verifier_ip: None, + runtime_policy: None, + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: None, + payload: None, + cert_dir: None, + verify: false, + push_model: false, + pull_model: true, + tpm_policy: None, + wait_for_attestation: false, + attestation_timeout: 60, + allow_unverified_quote: false, + }; + + assert!(!params.push_model); + assert!(params.pull_model); + } + + #[test] + fn test_add_agent_params_with_wait_for_attestation() { + let params = AddAgentParams { + agent_id: "test-agent", + ip: None, + port: None, + verifier_ip: None, + runtime_policy: None, + runtime_policy_name: None, + runtime_policy_sig_key: None, + mb_policy: None, + payload: None, + cert_dir: None, + verify: false, + push_model: true, + pull_model: false, + tpm_policy: None, + wait_for_attestation: true, + attestation_timeout: 120, + allow_unverified_quote: false, + }; + + assert!(params.wait_for_attestation); + assert_eq!(params.attestation_timeout, 120); + } + } +} diff --git a/keylimectl/src/commands/agent/update.rs b/keylimectl/src/commands/agent/update.rs new file mode 100644 index 000000000..68209f200 --- /dev/null +++ b/keylimectl/src/commands/agent/update.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Agent update command + +use super::add::add_agent; +use super::remove::remove_agent; +use super::types::AddAgentParams; +use crate::client::factory; +use crate::commands::error::CommandError; +use crate::output::OutputHandler; +use serde_json::{json, Value}; + +/// Update an existing agent +/// +/// This function implements a proper update that preserves existing configuration +/// and only modifies the specified fields. Since Keylime doesn't provide a direct +/// update API, we implement this as: get existing config -> remove -> add with +/// merged config. +/// +/// The remove step blocks until the agent is fully gone from the verifier, +/// handling the case where DELETE returns 202 (async deletion while an in-flight +/// attestation cycle is still completing). Only then is the agent re-added to +/// avoid a 409 Conflict. +pub(super) async fn update_agent( + agent_id: &str, + runtime_policy: Option<&str>, + runtime_policy_name: Option<&str>, + runtime_policy_sig_key: Option<&str>, + mb_policy: Option<&str>, + output: &OutputHandler, +) -> Result { + // Validate agent ID + if agent_id.is_empty() { + return Err(CommandError::invalid_parameter( + "agent_id", + "Agent ID cannot be empty".to_string(), + )); + } + + output.info(format!("Updating agent {agent_id}")); + + // Step 1: Get existing configuration from both registrar and verifier + output.step(1, 3, "Retrieving existing agent configuration"); + + let registrar_client = factory::get_registrar().await.map_err(|e| { + CommandError::connection_error("registrar", e.to_string()) + })?; + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + + // Get agent info from registrar (contains IP, port, etc.) + let registrar_agent = registrar_client + .get_agent(agent_id) + .await + .map_err(|e| { + CommandError::resource_error( + "registrar", + format!("Failed to get agent: {e}"), + ) + })? + .ok_or_else(|| { + CommandError::agent_not_found(agent_id.to_string(), "registrar") + })?; + + // Get agent info from verifier (contains policies, etc.) + let _verifier_agent = verifier_client + .get_agent(agent_id) + .await + .map_err(|e| { + CommandError::resource_error( + "verifier", + format!("Failed to get agent: {e}"), + ) + })? + .ok_or_else(|| { + CommandError::agent_not_found(agent_id.to_string(), "verifier") + })?; + + // Extract existing configuration + let existing_ip = registrar_agent["ip"].as_str().ok_or_else(|| { + CommandError::invalid_parameter( + "ip", + "Agent IP not found in registrar data".to_string(), + ) + })?; + let existing_port = + registrar_agent["port"].as_u64().ok_or_else(|| { + CommandError::invalid_parameter( + "port", + "Agent port not found in registrar data".to_string(), + ) + })?; + + // Determine if agent is using push model based on API version and port + let existing_push_model = { + #[cfg(feature = "api-v3")] + { + let (api_major, _) = crate::api_versions::parse_version( + verifier_client.api_version(), + ); + existing_port == 0 || api_major >= 3 + } + #[cfg(not(feature = "api-v3"))] + { + existing_port == 0 + } + }; + + // Step 2: Remove existing agent; blocks until fully gone (handles 202) + output.step(2, 3, "Removing existing agent configuration"); + let _remove_result = remove_agent(agent_id, false, false, output).await?; + + // Step 3: Add agent with merged configuration (existing + updates) + output.step(3, 3, "Adding agent with updated configuration"); + let add_result = add_agent( + AddAgentParams { + agent_id, + ip: Some(existing_ip), // Preserve existing IP + port: Some(existing_port as u16), // Preserve existing port + verifier_ip: None, // Use default from config + runtime_policy, // Use new policy if provided, otherwise will use default + runtime_policy_name, + runtime_policy_sig_key, + mb_policy, // Use new policy if provided, otherwise will use default + payload: None, // Payload updates not supported in update operation + cert_dir: None, // Use default cert handling + verify: false, // Skip verification during update + push_model: existing_push_model, // Preserve existing model + pull_model: false, // Let auto-detection handle it + tpm_policy: None, // Use default policy during update + allow_unverified_quote: false, // Do not bypass quote verification during update + wait_for_attestation: false, // Don't wait during update + attestation_timeout: 60, + }, + output, + ) + .await?; + + output.info(format!("Agent {agent_id} successfully updated")); + + Ok(json!({ + "status": "success", + "message": format!("Agent {agent_id} updated successfully"), + "agent_id": agent_id, + "existing_config": { + "ip": existing_ip, + "port": existing_port, + "push_model": existing_push_model + }, + "updated_fields": { + "runtime_policy": runtime_policy.map(|p| p.to_string()), + "mb_policy": mb_policy.map(|p| p.to_string()) + }, + "results": add_result + })) +} diff --git a/keylimectl/src/commands/configure.rs b/keylimectl/src/commands/configure.rs new file mode 100644 index 000000000..db724a834 --- /dev/null +++ b/keylimectl/src/commands/configure.rs @@ -0,0 +1,564 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Configuration wizard for keylimectl +//! +//! Provides both interactive and non-interactive modes for creating +//! or updating keylimectl configuration files. + +use log::{debug, info}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::{CliOverrides, Config, RegistrarConfig, VerifierConfig}; +#[cfg(feature = "wizard")] +use crate::config::{ClientConfig, TlsConfig}; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use crate::ConfigScope; + +/// Parameters for the configure command, extracted from CLI args. +#[derive(Debug)] +pub struct ConfigureParams<'a> { + /// Run without interactive prompts + pub non_interactive: bool, + /// Configuration scope + pub scope: &'a ConfigScope, + /// Verifier IP for non-interactive mode + pub verifier_ip: Option<&'a str>, + /// Verifier port for non-interactive mode + pub verifier_port: Option, + /// Registrar IP for non-interactive mode + pub registrar_ip: Option<&'a str>, + /// Registrar port for non-interactive mode + pub registrar_port: Option, + /// Test connectivity after configuration + pub test_connectivity: bool, +} + +/// Execute the configure command. +pub async fn execute( + params: &ConfigureParams<'_>, + output: &OutputHandler, +) -> Result { + let config_path = resolve_config_path(params.scope)?; + + let config = if params.non_interactive { + build_non_interactive_config( + params.verifier_ip, + params.verifier_port, + params.registrar_ip, + params.registrar_port, + ) + } else { + #[cfg(feature = "wizard")] + { + run_interactive_wizard( + params.scope, + &config_path, + params.verifier_ip, + params.verifier_port, + params.registrar_ip, + params.registrar_port, + )? + } + #[cfg(not(feature = "wizard"))] + { + output.info( + "Interactive mode requires the 'wizard' feature. \ + Use --non-interactive or rebuild with --features wizard.", + ); + return Err(KeylimectlError::Validation( + "Interactive mode requires the 'wizard' feature".into(), + )); + } + }; + + if params.test_connectivity { + info!("Connectivity testing is not yet implemented"); + } + + write_config_file(&config_path, &config)?; + + let result = json!({ + "status": "success", + "config_path": config_path.display().to_string(), + "verifier": { + "ip": config.verifier.ip, + "port": config.verifier.port, + }, + "registrar": { + "ip": config.registrar.ip, + "port": config.registrar.port, + }, + }); + + output.info(format!( + "Configuration written to {}", + config_path.display() + )); + + Ok(result) +} + +/// Build a configuration from CLI-provided values, using defaults for +/// anything not specified. +fn build_non_interactive_config( + verifier_ip: Option<&str>, + verifier_port: Option, + registrar_ip: Option<&str>, + registrar_port: Option, +) -> Config { + let defaults = Config::default(); + + Config { + loaded_from: None, + cli_overrides: CliOverrides::default(), + verifier: VerifierConfig { + ip: verifier_ip.unwrap_or(&defaults.verifier.ip).to_string(), + port: verifier_port.unwrap_or(defaults.verifier.port), + id: defaults.verifier.id, + }, + registrar: RegistrarConfig { + ip: registrar_ip.unwrap_or(&defaults.registrar.ip).to_string(), + port: registrar_port.unwrap_or(defaults.registrar.port), + }, + tls: defaults.tls, + client: defaults.client, + agent: defaults.agent, + } +} + +/// Resolve the configuration file path based on the scope. +fn resolve_config_path( + scope: &ConfigScope, +) -> Result { + match scope { + ConfigScope::Local => Ok(PathBuf::from(".keylimectl/config.toml")), + ConfigScope::User => { + let home = dirs_path_home()?; + Ok(home.join(".config").join("keylimectl").join("config.toml")) + } + ConfigScope::System => { + Ok(PathBuf::from("/etc/keylime/keylimectl.conf")) + } + } +} + +/// Get the user's home directory. +fn dirs_path_home() -> Result { + std::env::var("HOME").map(PathBuf::from).map_err(|_| { + KeylimectlError::Validation( + "Could not determine home directory".into(), + ) + }) +} + +/// Write a configuration to a TOML file, creating parent directories +/// as needed. +fn write_config_file( + path: &Path, + config: &Config, +) -> Result<(), KeylimectlError> { + // Create parent directories + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to create directory {}: {e}", + parent.display() + )) + })?; + } + } + + let toml_str = toml::to_string_pretty(config).map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to serialize configuration: {e}" + )) + })?; + + debug!("Writing configuration to {}", path.display()); + crate::policy_tools::privilege::write_sensitive_file( + path, + toml_str.as_bytes(), + ) + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to write configuration to {}: {e}", + path.display() + )) + })?; + + Ok(()) +} + +/// Run the interactive configuration wizard (requires `wizard` feature). +#[cfg(feature = "wizard")] +fn run_interactive_wizard( + scope: &ConfigScope, + config_path: &Path, + default_verifier_ip: Option<&str>, + default_verifier_port: Option, + default_registrar_ip: Option<&str>, + default_registrar_port: Option, +) -> Result { + use dialoguer::{Confirm, Input}; + + eprintln!("keylimectl Configuration Wizard"); + eprintln!("==============================="); + eprintln!(); + + // Show where config will be written + eprintln!("Scope: {:?} ({})", scope, config_path.display()); + eprintln!(); + + // Check for existing file + if config_path.exists() { + eprintln!( + "A configuration file already exists at {}", + config_path.display() + ); + let overwrite = Confirm::new() + .with_prompt("Overwrite existing configuration?") + .default(false) + .interact() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + if !overwrite { + return Err(KeylimectlError::Validation( + "Configuration cancelled by user".into(), + )); + } + eprintln!(); + } + + let defaults = Config::default(); + + // Step 1: Verifier configuration + eprintln!("Step 1: Verifier Configuration"); + eprintln!("------------------------------"); + + let verifier_ip: String = Input::new() + .with_prompt("Verifier IP address") + .default( + default_verifier_ip + .unwrap_or(&defaults.verifier.ip) + .to_string(), + ) + .interact_text() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + let verifier_port: u16 = Input::new() + .with_prompt("Verifier port") + .default(default_verifier_port.unwrap_or(defaults.verifier.port)) + .interact_text() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + eprintln!(); + + // Step 2: Registrar configuration + eprintln!("Step 2: Registrar Configuration"); + eprintln!("-------------------------------"); + + let registrar_ip: String = Input::new() + .with_prompt("Registrar IP address") + .default( + default_registrar_ip + .unwrap_or(&defaults.registrar.ip) + .to_string(), + ) + .interact_text() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + let registrar_port: u16 = Input::new() + .with_prompt("Registrar port") + .default(default_registrar_port.unwrap_or(defaults.registrar.port)) + .interact_text() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + eprintln!(); + + // Step 3: TLS configuration + eprintln!("Step 3: TLS Configuration"); + eprintln!("-------------------------"); + + let verify_server_cert = Confirm::new() + .with_prompt("Verify server certificates?") + .default(defaults.tls.verify_server_cert) + .interact() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + let enable_mtls = Confirm::new() + .with_prompt("Enable mutual TLS (mTLS)?") + .default(defaults.tls.client_cert.is_some()) + .interact() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + let (client_cert, client_key) = if enable_mtls { + let cert: String = Input::new() + .with_prompt("Client certificate path") + .default(defaults.tls.client_cert.unwrap_or_default()) + .interact_text() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + let key: String = Input::new() + .with_prompt("Client key path") + .default(defaults.tls.client_key.unwrap_or_default()) + .interact_text() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + (Some(cert), Some(key)) + } else { + (None, None) + }; + + eprintln!(); + + // Step 4: Client settings + eprintln!("Step 4: Client Settings"); + eprintln!("-----------------------"); + + let timeout: u64 = Input::new() + .with_prompt("Request timeout (seconds)") + .default(defaults.client.timeout) + .interact_text() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + eprintln!(); + + let config = Config { + loaded_from: None, + cli_overrides: CliOverrides::default(), + verifier: VerifierConfig { + ip: verifier_ip, + port: verifier_port, + id: None, + }, + registrar: RegistrarConfig { + ip: registrar_ip, + port: registrar_port, + }, + tls: TlsConfig { + client_cert, + client_key, + verify_server_cert, + ..defaults.tls + }, + client: ClientConfig { + timeout, + ..defaults.client + }, + agent: defaults.agent, + }; + + // Show summary + eprintln!("Configuration Summary"); + eprintln!("====================="); + eprintln!("Verifier: {}:{}", config.verifier.ip, config.verifier.port); + eprintln!( + "Registrar: {}:{}", + config.registrar.ip, config.registrar.port + ); + eprintln!( + "TLS: verify_server_cert={}, mTLS={}", + config.tls.verify_server_cert, + config.tls.client_cert.is_some() + ); + eprintln!("Timeout: {}s", config.client.timeout); + eprintln!(); + + let confirm = Confirm::new() + .with_prompt("Write this configuration?") + .default(true) + .interact() + .map_err(|e| { + KeylimectlError::Validation(format!( + "Failed to read user input: {e}" + )) + })?; + + if !confirm { + return Err(KeylimectlError::Validation( + "Configuration cancelled by user".into(), + )); + } + + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_non_interactive_config_defaults() { + let config = build_non_interactive_config(None, None, None, None); + + assert_eq!(config.verifier.ip, "127.0.0.1"); + assert_eq!(config.verifier.port, 8881); + assert_eq!(config.registrar.ip, "127.0.0.1"); + assert_eq!(config.registrar.port, 8891); + assert_eq!(config.client.timeout, 60); + } + + #[test] + fn test_build_non_interactive_config_with_overrides() { + let config = build_non_interactive_config( + Some("10.0.0.1"), + Some(9001), + Some("10.0.0.2"), + Some(9002), + ); + + assert_eq!(config.verifier.ip, "10.0.0.1"); + assert_eq!(config.verifier.port, 9001); + assert_eq!(config.registrar.ip, "10.0.0.2"); + assert_eq!(config.registrar.port, 9002); + } + + #[test] + fn test_build_non_interactive_config_partial_overrides() { + let config = build_non_interactive_config( + Some("192.168.1.1"), + None, + None, + Some(9999), + ); + + assert_eq!(config.verifier.ip, "192.168.1.1"); + assert_eq!(config.verifier.port, 8881); // default + assert_eq!(config.registrar.ip, "127.0.0.1"); // default + assert_eq!(config.registrar.port, 9999); + } + + #[test] + fn test_resolve_config_path_local() { + let path = resolve_config_path(&ConfigScope::Local).unwrap(); //#[allow_ci] + assert_eq!(path, PathBuf::from(".keylimectl/config.toml")); + } + + #[test] + fn test_resolve_config_path_user() { + let path = resolve_config_path(&ConfigScope::User).unwrap(); //#[allow_ci] + let expected = PathBuf::from(std::env::var("HOME").unwrap()) //#[allow_ci] + .join(".config") + .join("keylimectl") + .join("config.toml"); + assert_eq!(path, expected); + } + + #[test] + fn test_resolve_config_path_system() { + let path = resolve_config_path(&ConfigScope::System).unwrap(); //#[allow_ci] + assert_eq!(path, PathBuf::from("/etc/keylime/keylimectl.conf")); + } + + #[test] + fn test_write_config_file_creates_dirs_and_file() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let config_path = tmpdir + .path() + .join("subdir") + .join("nested") + .join("config.toml"); + + let config = Config::default(); + write_config_file(&config_path, &config).unwrap(); //#[allow_ci] + + assert!(config_path.exists()); + + // Verify the written TOML is valid and round-trips + let contents = fs::read_to_string(&config_path).unwrap(); //#[allow_ci] + let parsed: Config = toml::from_str(&contents).unwrap(); //#[allow_ci] + assert_eq!(parsed.verifier.ip, config.verifier.ip); + assert_eq!(parsed.verifier.port, config.verifier.port); + assert_eq!(parsed.registrar.ip, config.registrar.ip); + assert_eq!(parsed.registrar.port, config.registrar.port); + assert_eq!(parsed.client.timeout, config.client.timeout); + } + + #[test] + fn test_write_config_file_overwrites_existing() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let config_path = tmpdir.path().join("config.toml"); + + // Write first config + let config1 = Config::default(); + write_config_file(&config_path, &config1).unwrap(); //#[allow_ci] + + // Write second config with different values + let config2 = build_non_interactive_config( + Some("10.0.0.1"), + Some(9001), + None, + None, + ); + write_config_file(&config_path, &config2).unwrap(); //#[allow_ci] + + // Verify the second config was written + let contents = fs::read_to_string(&config_path).unwrap(); //#[allow_ci] + let parsed: Config = toml::from_str(&contents).unwrap(); //#[allow_ci] + assert_eq!(parsed.verifier.ip, "10.0.0.1"); + assert_eq!(parsed.verifier.port, 9001); + } + + #[test] + fn test_generated_toml_roundtrips() { + let config = build_non_interactive_config( + Some("::1"), + Some(8881), + Some("192.168.1.100"), + Some(8891), + ); + + let toml_str = toml::to_string_pretty(&config).unwrap(); //#[allow_ci] + let parsed: Config = toml::from_str(&toml_str).unwrap(); //#[allow_ci] + + assert_eq!(parsed.verifier.ip, "::1"); + assert_eq!(parsed.verifier.port, 8881); + assert_eq!(parsed.registrar.ip, "192.168.1.100"); + assert_eq!(parsed.registrar.port, 8891); + } +} diff --git a/keylimectl/src/commands/error.rs b/keylimectl/src/commands/error.rs new file mode 100644 index 000000000..226da3e9d --- /dev/null +++ b/keylimectl/src/commands/error.rs @@ -0,0 +1,424 @@ +//! Command-specific error types for keylimectl +//! +//! This module provides error types specific to CLI command operations, +//! including agent management, policy operations, and resource listing. +//! These errors provide detailed context for command execution failures. +//! +//! # Error Types +//! +//! - [`CommandError`] - Main error type for command operations +//! - [`AgentError`] - Agent management specific errors +//! - [`PolicyError`] - Policy operation specific errors +//! - [`ResourceError`] - Resource listing and management errors +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::commands::error::{CommandError, AgentError}; +//! +//! // Create an agent error +//! let agent_err = CommandError::Agent(AgentError::NotFound { +//! uuid: "12345".to_string(), +//! service: "verifier".to_string(), +//! }); +//! +//! // Create a policy error +//! let policy_err = CommandError::policy_not_found("my_policy"); +//! ``` + +use std::path::PathBuf; +use thiserror::Error; + +pub use crate::policy_tools::dsse::DsseError; + +/// Command execution error types +/// +/// This enum covers all error conditions that can occur during CLI command +/// execution, from agent management failures to policy operations and file I/O. +#[derive(Error, Debug)] +pub enum CommandError { + /// Agent management errors + #[error("Agent error: {0}")] + Agent(#[from] AgentError), + + /// Policy operation errors + #[error("Policy error: {0}")] + Policy(#[from] PolicyError), + + /// Policy generation errors + #[error("Policy generation error: {0}")] + PolicyGeneration(#[from] PolicyGenerationError), + + /// DSSE signing/verification errors + #[error("DSSE error: {0}")] + Dsse(#[from] DsseError), + + /// Resource listing and management errors + #[error("Resource error: {0}")] + Resource(#[from] ResourceError), + + /// File I/O errors + #[error("File operation error: {0}")] + Io(#[from] std::io::Error), + + /// JSON parsing/serialization errors + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// UUID parsing errors + #[error("Invalid UUID: {0}")] + Uuid(#[from] uuid::Error), + + /// Command parameter validation errors + #[error("Invalid parameter: {parameter} - {reason}")] + InvalidParameter { parameter: String, reason: String }, +} + +/// Agent management specific errors +/// +/// These errors represent issues with agent lifecycle operations, +/// including creation, updates, removal, and status queries. +#[derive(Error, Debug)] +pub enum AgentError { + /// Agent not found on specified service + #[error("Agent {uuid} not found on {service}")] + NotFound { uuid: String, service: String }, + + /// Agent operation failed + #[cfg_attr(not(feature = "api-v2"), allow(dead_code))] + #[error("Agent operation failed: {operation} for {uuid} - {reason}")] + OperationFailed { + operation: String, + uuid: String, + reason: String, + }, +} + +/// Policy operation specific errors +/// +/// These errors represent issues with policy management operations, +/// including creation, updates, validation, and file operations. +#[derive(Error, Debug)] +pub enum PolicyError { + /// Policy not found + #[error("Policy '{name}' not found")] + NotFound { name: String }, + + /// Policy file errors + #[error("Policy file error: {path} - {reason}")] + FileError { path: PathBuf, reason: String }, +} + +/// Resource listing and management errors +/// +/// These errors represent issues with resource operations, +/// including listing, filtering, and display formatting. +#[derive(Error, Debug)] +pub enum ResourceError { + /// Resource listing failed + #[error("Failed to list {resource_type}: {reason}")] + ListingFailed { + resource_type: String, + reason: String, + }, + + /// Client connection/initialization failed + #[error("Failed to connect to {resource_type}: {reason}")] + ConnectionFailed { + resource_type: String, + reason: String, + }, +} + +/// Policy generation errors +/// +/// These errors represent issues with local policy generation, +/// including IMA log parsing, filesystem scanning, and digest calculation. +#[derive(Error, Debug)] +pub enum PolicyGenerationError { + /// IMA measurement list parse error + #[error("Failed to parse IMA measurement list {path}: {reason}")] + ImaParse { path: PathBuf, reason: String }, + + /// Allowlist parse error + #[error("Failed to parse allowlist {path}: {reason}")] + AllowlistParse { path: PathBuf, reason: String }, + + /// Filesystem scan error + #[error("Filesystem scan error at {path}: {reason}")] + FilesystemScan { path: PathBuf, reason: String }, + + /// Digest calculation error + #[error("Failed to calculate digest for {path}: {reason}")] + Digest { path: PathBuf, reason: String }, + + /// Unsupported hash algorithm + #[error("Unsupported hash algorithm: {algorithm}")] + UnsupportedAlgorithm { algorithm: String }, + + /// Event log parse error + #[error("Failed to parse event log {path}: {reason}")] + EventLogParse { path: PathBuf, reason: String }, + + /// Output write error + #[error("Failed to write output to {path}: {reason}")] + Output { path: PathBuf, reason: String }, + + /// Insufficient privileges + #[error( + "Insufficient privileges for {operation} on {path}\n Hint: {hint}" + )] + PrivilegeRequired { + operation: String, + path: PathBuf, + hint: String, + }, + + /// RPM parsing error + #[cfg(feature = "rpm-repo")] + #[error("RPM parse error at {path}: {reason}")] + RpmParse { path: PathBuf, reason: String }, + + /// GPG signature verification failed + #[cfg(feature = "rpm-repo")] + #[error("GPG signature verification failed for {path}: {reason}")] + GpgVerification { path: PathBuf, reason: String }, +} + +impl CommandError { + /// Create an invalid parameter error + pub fn invalid_parameter, R: Into>( + parameter: P, + reason: R, + ) -> Self { + Self::InvalidParameter { + parameter: parameter.into(), + reason: reason.into(), + } + } + + /// Create an agent not found error + pub fn agent_not_found, S: Into>( + uuid: U, + service: S, + ) -> Self { + Self::Agent(AgentError::NotFound { + uuid: uuid.into(), + service: service.into(), + }) + } + + /// Create a policy not found error + pub fn policy_not_found>(name: N) -> Self { + Self::Policy(PolicyError::NotFound { name: name.into() }) + } + + /// Create a resource error + pub fn resource_error, R: Into>( + resource_type: T, + reason: R, + ) -> Self { + Self::Resource(ResourceError::ListingFailed { + resource_type: resource_type.into(), + reason: reason.into(), + }) + } + + /// Create a connection error for client initialization failures + pub fn connection_error, R: Into>( + resource_type: T, + reason: R, + ) -> Self { + Self::Resource(ResourceError::ConnectionFailed { + resource_type: resource_type.into(), + reason: reason.into(), + }) + } + + /// Create an agent operation failed error + #[cfg_attr(not(feature = "api-v2"), allow(dead_code))] + pub fn agent_operation_failed< + U: Into, + O: Into, + R: Into, + >( + uuid: U, + operation: O, + reason: R, + ) -> Self { + Self::Agent(AgentError::OperationFailed { + uuid: uuid.into(), + operation: operation.into(), + reason: reason.into(), + }) + } + + /// Create a policy file error + pub fn policy_file_error, R: Into>( + path: P, + reason: R, + ) -> Self { + Self::Policy(PolicyError::FileError { + path: PathBuf::from(path.into()), + reason: reason.into(), + }) + } +} + +impl AgentError {} + +impl PolicyError {} + +impl ResourceError {} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_command_error_creation() { + let _param_err = + CommandError::invalid_parameter("uuid", "Invalid format"); + } + + #[test] + fn test_agent_error_creation() { + let not_found = AgentError::NotFound { + uuid: "12345".to_string(), + service: "verifier".to_string(), + }; + match not_found { + AgentError::NotFound { uuid, service } => { + assert_eq!(uuid, "12345"); + assert_eq!(service, "verifier"); + } + _ => panic!("Expected NotFound error"), //#[allow_ci] + } + + let op_failed = AgentError::OperationFailed { + operation: "add".to_string(), + uuid: "12345".to_string(), + reason: "Network timeout".to_string(), + }; + match op_failed { + AgentError::OperationFailed { + operation, + uuid, + reason, + } => { + assert_eq!(operation, "add"); + assert_eq!(uuid, "12345"); + assert_eq!(reason, "Network timeout"); + } + _ => panic!("Expected OperationFailed error"), //#[allow_ci] + } + } + + #[test] + fn test_policy_error_creation() { + let not_found = PolicyError::NotFound { + name: "my_policy".to_string(), + }; + match not_found { + PolicyError::NotFound { name } => { + assert_eq!(name, "my_policy"); + } + _ => panic!("Expected NotFound error"), //#[allow_ci] + } + + let file_err = PolicyError::FileError { + path: PathBuf::from("/path/policy.json"), + reason: "Permission denied".to_string(), + }; + match file_err { + PolicyError::FileError { path, reason } => { + assert_eq!(path, PathBuf::from("/path/policy.json")); + assert_eq!(reason, "Permission denied"); + } + _ => panic!("Expected FileError error"), //#[allow_ci] + } + } + + #[test] + fn test_resource_error_creation() { + let listing_failed = ResourceError::ListingFailed { + resource_type: "policies".to_string(), + reason: "API unavailable".to_string(), + }; + match listing_failed { + ResourceError::ListingFailed { + resource_type, + reason, + } => { + assert_eq!(resource_type, "policies"); + assert_eq!(reason, "API unavailable"); + } + _ => panic!("Expected ListingFailed error"), //#[allow_ci] + } + + let conn_failed = ResourceError::ConnectionFailed { + resource_type: "verifier".to_string(), + reason: "TLS error".to_string(), + }; + assert!(conn_failed.to_string().contains("connect to verifier")); + assert!(conn_failed.to_string().contains("TLS error")); + } + + #[test] + fn test_error_display() { + let agent_err = AgentError::NotFound { + uuid: "12345".to_string(), + service: "verifier".to_string(), + }; + assert!(agent_err.to_string().contains("12345")); + assert!(agent_err.to_string().contains("verifier")); + assert!(agent_err.to_string().contains("not found")); + + let policy_err = PolicyError::NotFound { + name: "test_policy".to_string(), + }; + assert!(policy_err.to_string().contains("test_policy")); + assert!(policy_err.to_string().contains("not found")); + + let resource_err = ResourceError::ListingFailed { + resource_type: "agents".to_string(), + reason: "Service unavailable".to_string(), + }; + assert!(resource_err.to_string().contains("agents")); + } + + #[test] + fn test_error_classification() { + // Operation failed errors + let _op_failed = CommandError::Agent(AgentError::OperationFailed { + operation: "add".to_string(), + uuid: "12345".to_string(), + reason: "Temporary failure".to_string(), + }); + + let _listing_failed = + CommandError::Resource(ResourceError::ListingFailed { + resource_type: "agents".to_string(), + reason: "Service unavailable".to_string(), + }); + + // Parameter errors + let _invalid_param = + CommandError::invalid_parameter("uuid", "Invalid format"); + } + + #[test] + fn test_user_error_classification() { + // System errors + let _io_err = CommandError::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Permission denied", + )); + // Note: is_user_error() method was removed as unused + + let _agent_not_found = + CommandError::agent_not_found("12345", "verifier"); + // This verifies the constructor still works + } +} diff --git a/keylimectl/src/commands/info/agent_info.rs b/keylimectl/src/commands/info/agent_info.rs new file mode 100644 index 000000000..6cbde0f7a --- /dev/null +++ b/keylimectl/src/commands/info/agent_info.rs @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Per-agent diagnostic information. +//! +//! Combines verifier and registrar data into a unified diagnostic view. +//! Each query is independent — one failing does not prevent others. + +#[cfg(feature = "api-v2")] +use crate::client::agent::AgentClient; +use crate::client::factory; +#[cfg(feature = "api-v2")] +use crate::config::singleton::get_config; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use log::debug; +use serde_json::{json, Value}; + +/// Execute the `info agent ` subcommand. +pub async fn execute( + agent_id: &str, + output: &OutputHandler, +) -> Result { + if agent_id.is_empty() { + return Err(KeylimectlError::Validation( + "Agent ID cannot be empty".into(), + )); + } + + output.progress(format!("Gathering diagnostics for agent {agent_id}")); + + let mut result = json!({ + "agent_id": agent_id, + }); + + // Query registrar + let registrar_data = query_registrar(agent_id, output).await; + result["registrar"] = registrar_data; + + // Query verifier + let verifier_data = query_verifier(agent_id, output).await; + result["verifier"] = verifier_data; + + // Build summary from collected data + result["summary"] = build_summary(&result); + + // Attempt direct agent contact for pull model (api-v2) + result["agent_direct"] = query_agent_direct(&result, output).await; + + Ok(result) +} + +/// Query registrar for agent data. +async fn query_registrar(agent_id: &str, output: &OutputHandler) -> Value { + output.progress("Querying registrar"); + match factory::get_registrar().await { + Ok(client) => match client.get_agent(agent_id).await { + Ok(Some(data)) => json!({ + "status": "found", + "data": data, + }), + Ok(None) => json!({ + "status": "not_found", + }), + Err(e) => { + debug!("Registrar query error: {e}"); + json!({ + "status": "error", + "error": e.to_string(), + }) + } + }, + Err(e) => { + debug!("Failed to connect to registrar: {e}"); + json!({ + "status": "unreachable", + "error": e.to_string(), + }) + } + } +} + +/// Query verifier for agent data. +async fn query_verifier(agent_id: &str, output: &OutputHandler) -> Value { + output.progress("Querying verifier"); + match factory::get_verifier().await { + Ok(client) => match client.get_agent(agent_id).await { + Ok(Some(data)) => json!({ + "status": "found", + "data": data, + }), + Ok(None) => json!({ + "status": "not_found", + }), + Err(e) => { + debug!("Verifier query error: {e}"); + json!({ + "status": "error", + "error": e.to_string(), + }) + } + }, + Err(e) => { + debug!("Failed to connect to verifier: {e}"); + json!({ + "status": "unreachable", + "error": e.to_string(), + }) + } + } +} + +/// Build a summary from collected verifier and registrar data. +fn build_summary(result: &Value) -> Value { + let registered = result["registrar"]["status"].as_str() == Some("found"); + let monitored = result["verifier"]["status"].as_str() == Some("found"); + + let operational_state = result["verifier"]["data"] + .get("operational_state") + .and_then(|v| v.as_str()) + .or_else(|| { + result["verifier"]["data"] + .get("operational_state_description") + .and_then(|v| v.as_str()) + }); + + let mut summary = json!({ + "registered": registered, + "monitored": monitored, + }); + + if let Some(state) = operational_state { + summary["operational_state"] = Value::String(state.to_string()); + } + + summary +} + +/// Attempt direct agent communication (pull model, api-v2 only). +async fn query_agent_direct( + result: &Value, + _output: &OutputHandler, +) -> Value { + #[cfg(feature = "api-v2")] + { + // Check if the verifier is using a pre-v3 API (pull model) + match factory::get_verifier().await { + Ok(client) => { + let api_version = + client.api_version().parse::().unwrap_or(2.1); + + if api_version >= 3.0 { + return json!({ + "status": "not_applicable", + "model": "push", + }); + } + + // Extract agent IP/port from available data + let agent_connection = extract_agent_connection(result); + match agent_connection { + Some((ip, port)) => { + _output.progress(format!( + "Testing direct agent connection {ip}:{port}" + )); + test_agent_connection(&ip, port).await + } + None => json!({ + "status": "unknown", + "model": "pull", + "note": "Agent IP/port not found in registrar or verifier data", + }), + } + } + Err(_) => json!({ + "status": "unknown", + "note": "Cannot determine model — verifier unreachable", + }), + } + } + + #[cfg(not(feature = "api-v2"))] + { + // Suppress unused variable warning + let _ = result; + json!({ + "status": "not_applicable", + "model": "push", + }) + } +} + +/// Extract agent IP and port from registrar/verifier data. +#[cfg(feature = "api-v2")] +fn extract_agent_connection(result: &Value) -> Option<(String, u16)> { + let registrar_data = result.get("registrar").and_then(|r| r.get("data")); + let verifier_data = result.get("verifier").and_then(|v| v.get("data")); + + // Prefer verifier data, fall back to registrar + let ip = verifier_data + .and_then(|d| d.get("ip")) + .or_else(|| registrar_data.and_then(|d| d.get("ip"))) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let port = verifier_data + .and_then(|d| d.get("port")) + .or_else(|| registrar_data.and_then(|d| d.get("port"))) + .and_then(|v| v.as_u64()) + .map(|p| p as u16); + + ip.zip(port) +} + +/// Test direct agent connectivity. +#[cfg(feature = "api-v2")] +async fn test_agent_connection(ip: &str, port: u16) -> Value { + match AgentClient::builder() + .agent_ip(ip) + .agent_port(port) + .config(get_config()) + .build() + .await + { + Ok(agent_client) => { + match agent_client.get_quote("test_connectivity").await { + Ok(_) => json!({ + "status": "responsive", + "model": "pull", + "connection": format!("{ip}:{port}"), + }), + Err(e) => { + // A 400 Bad Request means the agent is reachable + // but rejected our test nonce (expected behavior) + if e.to_string().contains("400") + || e.to_string().contains("Bad Request") + { + json!({ + "status": "responsive", + "model": "pull", + "connection": format!("{ip}:{port}"), + "note": "Agent rejected test nonce (expected)", + }) + } else { + json!({ + "status": "unreachable", + "model": "pull", + "connection": format!("{ip}:{port}"), + "error": e.to_string(), + }) + } + } + } + } + Err(e) => json!({ + "status": "connection_failed", + "model": "pull", + "connection": format!("{ip}:{port}"), + "error": e.to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_summary_both_found() { + let result = json!({ + "registrar": { "status": "found", "data": {} }, + "verifier": { + "status": "found", + "data": { "operational_state": "Get Quote" } + }, + }); + let summary = build_summary(&result); + assert_eq!(summary["registered"], true); + assert_eq!(summary["monitored"], true); + assert_eq!(summary["operational_state"], "Get Quote"); + } + + #[test] + fn test_build_summary_not_found() { + let result = json!({ + "registrar": { "status": "not_found" }, + "verifier": { "status": "not_found" }, + }); + let summary = build_summary(&result); + assert_eq!(summary["registered"], false); + assert_eq!(summary["monitored"], false); + assert!(summary.get("operational_state").is_none()); + } + + #[test] + fn test_build_summary_registrar_only() { + let result = json!({ + "registrar": { "status": "found", "data": {} }, + "verifier": { "status": "error", "error": "connection refused" }, + }); + let summary = build_summary(&result); + assert_eq!(summary["registered"], true); + assert_eq!(summary["monitored"], false); + } + + #[cfg(feature = "api-v2")] + #[test] + fn test_extract_agent_connection_from_verifier() { + let result = json!({ + "registrar": { "status": "found", "data": { "ip": "10.0.0.1", "port": 9002 } }, + "verifier": { "status": "found", "data": { "ip": "10.0.0.2", "port": 9003 } }, + }); + let conn = extract_agent_connection(&result); + // Should prefer verifier data + assert_eq!(conn, Some(("10.0.0.2".to_string(), 9003))); + } + + #[cfg(feature = "api-v2")] + #[test] + fn test_extract_agent_connection_from_registrar_fallback() { + let result = json!({ + "registrar": { "status": "found", "data": { "ip": "10.0.0.1", "port": 9002 } }, + "verifier": { "status": "not_found" }, + }); + let conn = extract_agent_connection(&result); + assert_eq!(conn, Some(("10.0.0.1".to_string(), 9002))); + } + + #[cfg(feature = "api-v2")] + #[test] + fn test_extract_agent_connection_none() { + let result = json!({ + "registrar": { "status": "not_found" }, + "verifier": { "status": "not_found" }, + }); + let conn = extract_agent_connection(&result); + assert_eq!(conn, None); + } +} diff --git a/keylimectl/src/commands/info/mod.rs b/keylimectl/src/commands/info/mod.rs new file mode 100644 index 000000000..9eeb451b2 --- /dev/null +++ b/keylimectl/src/commands/info/mod.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Diagnostic information commands for keylimectl +//! +//! Provides subcommands for inspecting configuration, server status, +//! agents, and TLS certificates. These commands are designed to work +//! even when configuration is incomplete. + +mod agent_info; +mod registrar_info; +mod self_info; +mod tls_info; +mod verifier_info; + +use serde_json::Value; + +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use crate::InfoSubcommand; + +/// Execute the info command dispatcher. +pub async fn execute( + subcommand: &Option, + output: &OutputHandler, +) -> Result { + match subcommand { + None => self_info::execute(output), + Some(InfoSubcommand::Verifier) => { + verifier_info::execute(output).await + } + Some(InfoSubcommand::Registrar) => { + registrar_info::execute(output).await + } + Some(InfoSubcommand::Agent { agent_id }) => { + agent_info::execute(agent_id, output).await + } + Some(InfoSubcommand::Tls) => tls_info::execute(output), + } +} diff --git a/keylimectl/src/commands/info/registrar_info.rs b/keylimectl/src/commands/info/registrar_info.rs new file mode 100644 index 000000000..66923ed53 --- /dev/null +++ b/keylimectl/src/commands/info/registrar_info.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Registrar diagnostic information. +//! +//! Queries the registrar for status, API version, and agent count. + +use log::debug; +use serde_json::{json, Value}; + +use crate::client::factory; +use crate::config; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; + +/// Execute the `info registrar` subcommand. +pub async fn execute( + output: &OutputHandler, +) -> Result { + let cfg = config::singleton::get_config(); + let url = cfg.registrar_base_url(); + + output.progress("Connecting to registrar"); + + match factory::get_registrar().await { + Ok(client) => { + let api_version = client.api_version().to_string(); + debug!("Connected to registrar, API version: {api_version}"); + + // Try to get agent count + let agent_count = match client.list_agents().await { + Ok(response) => extract_agent_count(&response), + Err(e) => { + debug!("Failed to list agents: {e}"); + None + } + }; + + let mut result = json!({ + "registrar": { + "url": url, + "reachable": true, + "api_version": api_version, + } + }); + + if let Some(count) = agent_count { + result["registrar"]["agents"] = json!({ "count": count }); + } + + Ok(result) + } + Err(e) => { + debug!("Failed to connect to registrar: {e}"); + Ok(json!({ + "registrar": { + "url": url, + "reachable": false, + "error": e.to_string(), + } + })) + } + } +} + +/// Extract the agent count from a list_agents response. +fn extract_agent_count(response: &Value) -> Option { + if let Some(results) = response.get("results") { + if let Some(uuids) = results.get("uuids") { + return uuids.as_array().map(|a| a.len()); + } + if let Some(agents) = results.as_array() { + return Some(agents.len()); + } + } + if let Some(agents) = response.get("agents") { + return agents.as_array().map(|a| a.len()); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_agent_count_uuids() { + let response = json!({ + "results": { + "uuids": ["uuid-1", "uuid-2"] + } + }); + assert_eq!(extract_agent_count(&response), Some(2)); + } + + #[test] + fn test_extract_agent_count_empty() { + let response = json!({}); + assert_eq!(extract_agent_count(&response), None); + } + + #[test] + fn test_extract_agent_count_empty_list() { + let response = json!({ + "results": { + "uuids": [] + } + }); + assert_eq!(extract_agent_count(&response), Some(0)); + } +} diff --git a/keylimectl/src/commands/info/self_info.rs b/keylimectl/src/commands/info/self_info.rs new file mode 100644 index 000000000..e804e7f27 --- /dev/null +++ b/keylimectl/src/commands/info/self_info.rs @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Local diagnostic information (no network calls). +//! +//! Shows effective configuration, version, features, config file +//! search results, and per-field source annotations. + +use serde_json::{json, Value}; + +use crate::config; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; + +/// Execute the `info` command (no subcommand). +pub fn execute(_output: &OutputHandler) -> Result { + let config = config::singleton::get_config(); + + let version_info = build_version_info(); + let config_files = build_config_file_info(config); + let effective_config = build_effective_config(config); + let env_vars = scan_keylime_env_vars(); + + Ok(json!({ + "keylimectl": version_info, + "config_files": config_files, + "effective_config": effective_config, + "environment_variables": env_vars, + })) +} + +/// Build version and feature information. +fn build_version_info() -> Value { + json!({ + "version": env!("CARGO_PKG_VERSION"), + "features": { + "api-v2": cfg!(feature = "api-v2"), + "api-v3": cfg!(feature = "api-v3"), + "wizard": cfg!(feature = "wizard"), + "tpm-quote-validation": cfg!(feature = "tpm-quote-validation"), + }, + }) +} + +/// Build config file search information. +fn build_config_file_info(config: &config::Config) -> Value { + let search_paths = config::Config::config_search_paths(); + let searched: Vec = search_paths + .iter() + .map(|p| { + json!({ + "path": p.display().to_string(), + "exists": p.exists(), + }) + }) + .collect(); + + json!({ + "loaded_from": config.loaded_from.as_ref() + .map(|p| p.display().to_string()), + "searched": searched, + }) +} + +/// Build the effective configuration with per-field source annotations. +fn build_effective_config(config: &config::Config) -> Value { + let defaults = config::Config::default(); + let overrides = &config.cli_overrides; + + json!({ + "verifier": { + "ip": { + "value": config.verifier.ip, + "source": determine_source_str( + &config.verifier.ip, + &defaults.verifier.ip, + overrides.verifier_ip, + "KEYLIME_VERIFIER__IP", + ), + }, + "port": { + "value": config.verifier.port, + "source": determine_source_u16( + config.verifier.port, + defaults.verifier.port, + overrides.verifier_port, + "KEYLIME_VERIFIER__PORT", + ), + }, + "id": config.verifier.id, + }, + "registrar": { + "ip": { + "value": config.registrar.ip, + "source": determine_source_str( + &config.registrar.ip, + &defaults.registrar.ip, + overrides.registrar_ip, + "KEYLIME_REGISTRAR__IP", + ), + }, + "port": { + "value": config.registrar.port, + "source": determine_source_u16( + config.registrar.port, + defaults.registrar.port, + overrides.registrar_port, + "KEYLIME_REGISTRAR__PORT", + ), + }, + }, + "tls": { + "verify_server_cert": config.tls.verify_server_cert, + "enable_agent_mtls": config.tls.enable_agent_mtls, + "client_cert": config.tls.client_cert, + "client_key": config.tls.client_key, + "trusted_ca": config.tls.trusted_ca, + }, + "client": { + "timeout": { + "value": config.client.timeout, + "source": determine_source_u64( + config.client.timeout, + defaults.client.timeout, + overrides.timeout, + "KEYLIME_CLIENT__TIMEOUT", + ), + }, + "retry_interval": config.client.retry_interval, + "max_retries": config.client.max_retries, + "exponential_backoff": config.client.exponential_backoff, + }, + }) +} + +/// Determine the source of a string config field. +/// +/// Priority: CLI > env var > config file / default. +fn determine_source_str( + current: &str, + default: &str, + cli_override: bool, + env_var_name: &str, +) -> &'static str { + if cli_override { + return "cli"; + } + if std::env::var(env_var_name).is_ok() { + return "env_var"; + } + if current != default { + return "config_file"; + } + "default" +} + +/// Determine the source of a u16 config field. +fn determine_source_u16( + current: u16, + default: u16, + cli_override: bool, + env_var_name: &str, +) -> &'static str { + if cli_override { + return "cli"; + } + if std::env::var(env_var_name).is_ok() { + return "env_var"; + } + if current != default { + return "config_file"; + } + "default" +} + +/// Determine the source of a u64 config field. +fn determine_source_u64( + current: u64, + default: u64, + cli_override: bool, + env_var_name: &str, +) -> &'static str { + if cli_override { + return "cli"; + } + if std::env::var(env_var_name).is_ok() { + return "env_var"; + } + if current != default { + return "config_file"; + } + "default" +} + +/// Scan for KEYLIME_ environment variables, redacting sensitive values. +fn scan_keylime_env_vars() -> Value { + let mut vars = serde_json::Map::new(); + for (key, value) in std::env::vars() { + if key.starts_with("KEYLIME_") { + let display_value = if is_sensitive_env_var(&key) { + "[REDACTED]".to_string() + } else { + value + }; + let _ = vars.insert(key, Value::String(display_value)); + } + } + Value::Object(vars) +} + +fn is_sensitive_env_var(key: &str) -> bool { + let upper = key.to_uppercase(); + upper.ends_with("_PASSWORD") + || upper.ends_with("_SECRET") + || upper.ends_with("_KEY_DATA") + || upper.ends_with("_TOKEN") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::CliOverrides; + + #[test] + fn test_build_version_info() { + let info = build_version_info(); + assert!(info["version"].is_string()); + assert!(info["features"]["api-v2"].is_boolean()); + assert!(info["features"]["api-v3"].is_boolean()); + assert!(info["features"]["wizard"].is_boolean()); + assert!(info["features"]["tpm-quote-validation"].is_boolean()); + } + + #[test] + fn test_build_config_file_info_defaults() { + let config = config::Config::default(); + let info = build_config_file_info(&config); + assert!(info["loaded_from"].is_null()); + assert!(info["searched"].is_array()); + let searched = info["searched"].as_array().unwrap(); //#[allow_ci] + assert!(!searched.is_empty()); + // Each entry should have path and exists + for entry in searched { + assert!(entry["path"].is_string()); + assert!(entry["exists"].is_boolean()); + } + } + + #[test] + fn test_determine_source_cli() { + assert_eq!( + determine_source_str( + "10.0.0.1", + "127.0.0.1", + true, + "KEYLIME_VERIFIER__IP" + ), + "cli" + ); + } + + #[test] + fn test_determine_source_default() { + assert_eq!( + determine_source_str( + "127.0.0.1", + "127.0.0.1", + false, + // Use an unlikely-to-exist env var name + "KEYLIME_TEST_NONEXISTENT_VAR_12345" + ), + "default" + ); + } + + #[test] + fn test_determine_source_config_file() { + assert_eq!( + determine_source_str( + "10.0.0.1", + "127.0.0.1", + false, + "KEYLIME_TEST_NONEXISTENT_VAR_12345" + ), + "config_file" + ); + } + + #[test] + fn test_determine_source_u16_cli() { + assert_eq!( + determine_source_u16( + 9001, + 8881, + true, + "KEYLIME_TEST_NONEXISTENT_VAR_12345" + ), + "cli" + ); + } + + #[test] + fn test_determine_source_u16_default() { + assert_eq!( + determine_source_u16( + 8881, + 8881, + false, + "KEYLIME_TEST_NONEXISTENT_VAR_12345" + ), + "default" + ); + } + + #[test] + fn test_determine_source_u64_config_file() { + assert_eq!( + determine_source_u64( + 120, + 60, + false, + "KEYLIME_TEST_NONEXISTENT_VAR_12345" + ), + "config_file" + ); + } + + #[test] + fn test_scan_keylime_env_vars() { + let vars = scan_keylime_env_vars(); + assert!(vars.is_object()); + // All keys should start with KEYLIME_ + if let Value::Object(map) = vars { + for key in map.keys() { + assert!(key.starts_with("KEYLIME_")); + } + } + } + + #[test] + fn test_build_effective_config() { + let config = config::Config { + cli_overrides: CliOverrides { + verifier_ip: true, + ..CliOverrides::default() + }, + ..config::Config::default() + }; + let effective = build_effective_config(&config); + + // verifier_ip should report "cli" source since we set the override + assert_eq!(effective["verifier"]["ip"]["source"], "cli"); + // verifier_port should be "default" (no override, default value) + assert_eq!(effective["verifier"]["port"]["source"], "default"); + } +} diff --git a/keylimectl/src/commands/info/tls_info.rs b/keylimectl/src/commands/info/tls_info.rs new file mode 100644 index 000000000..ab47a90e6 --- /dev/null +++ b/keylimectl/src/commands/info/tls_info.rs @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! TLS certificate diagnostic information. +//! +//! Validates TLS certificate files, checks expiration, and verifies +//! certificate/key pairing. No network calls required. + +use log::debug; +use openssl::pkey::PKey; +use openssl::x509::X509; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; + +use crate::config; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; + +/// Execute the `info tls` subcommand. +pub fn execute(_output: &OutputHandler) -> Result { + let cfg = config::singleton::get_config(); + + let mut issues: Vec = Vec::new(); + let mut suggestions: Vec = Vec::new(); + + let tls_config = json!({ + "verify_server_cert": cfg.tls.verify_server_cert, + "enable_agent_mtls": cfg.tls.enable_agent_mtls, + }); + + // Inspect client certificate + let client_cert_info = cfg + .tls + .client_cert + .as_ref() + .map(|path| { + inspect_certificate( + path, + &mut issues, + &mut suggestions, + ) + }) + .unwrap_or_else(|| { + if cfg.tls.enable_agent_mtls { + suggestions.push( + "enable_agent_mtls is true but no client certificate is configured".to_string(), + ); + } + json!({ "configured": false }) + }); + + // Inspect client key + let client_key_info = cfg + .tls + .client_key + .as_ref() + .map(|path| inspect_key(path, &mut issues)) + .unwrap_or_else(|| { + if cfg.tls.client_cert.is_some() { + issues.push( + "Client certificate is configured but client key is missing".to_string(), + ); + } + json!({ "configured": false }) + }); + + // Verify cert/key pairing + let cert_key_match = + match (cfg.tls.client_cert.as_ref(), cfg.tls.client_key.as_ref()) { + (Some(cert_path), Some(key_path)) => { + verify_cert_key_pair(cert_path, key_path, &mut issues) + } + _ => None, + }; + + // Inspect trusted CA certificates + let trusted_ca_info: Vec = cfg + .tls + .trusted_ca + .iter() + .map(|path| inspect_certificate(path, &mut issues, &mut suggestions)) + .collect(); + + let mut certificates = json!({ + "client_cert": client_cert_info, + "client_key": client_key_info, + }); + + if let Some(matches) = cert_key_match { + certificates["cert_key_match"] = json!(matches); + } + + if !trusted_ca_info.is_empty() { + certificates["trusted_ca"] = Value::Array(trusted_ca_info); + } + + Ok(json!({ + "tls_config": tls_config, + "certificates": certificates, + "issues": issues, + "suggestions": suggestions, + })) +} + +/// Inspect a certificate file and return diagnostic information. +fn inspect_certificate( + path: &str, + issues: &mut Vec, + suggestions: &mut Vec, +) -> Value { + let p = Path::new(path); + + if !p.exists() { + issues.push(format!("Certificate file not found: {path}")); + return json!({ + "path": path, + "exists": false, + }); + } + + let pem_data = match fs::read(p) { + Ok(data) => data, + Err(e) => { + issues.push(format!("Cannot read certificate file {path}: {e}")); + return json!({ + "path": path, + "exists": true, + "readable": false, + "error": e.to_string(), + }); + } + }; + + let cert = match X509::from_pem(&pem_data) { + Ok(cert) => cert, + Err(e) => { + issues.push(format!("Invalid PEM certificate {path}: {e}")); + return json!({ + "path": path, + "exists": true, + "readable": true, + "valid_pem": false, + "error": e.to_string(), + }); + } + }; + + let subject = x509_name_to_string(cert.subject_name()); + let issuer = x509_name_to_string(cert.issuer_name()); + let not_after = cert.not_after().to_string(); + let not_before = cert.not_before().to_string(); + + // Calculate days until expiry + let days_until_expiry = { + let now = openssl::asn1::Asn1Time::days_from_now(0); + match now { + Ok(now) => { + let diff = now.diff(cert.not_after()); + match diff { + Ok(diff) => Some(diff.days), + Err(_) => None, + } + } + Err(_) => None, + } + }; + + // Check for expiration issues + if let Some(days) = days_until_expiry { + if days < 0 { + issues.push(format!( + "Certificate {path} has EXPIRED ({} days ago)", + -days + )); + } else if days <= 30 { + suggestions + .push(format!("Certificate {path} expires in {days} days")); + } + } + + let mut info = json!({ + "path": path, + "exists": true, + "readable": true, + "valid_pem": true, + "subject": subject, + "issuer": issuer, + "not_before": not_before, + "not_after": not_after, + "status": "ok", + }); + + if let Some(days) = days_until_expiry { + info["days_until_expiry"] = json!(days); + if days < 0 { + info["status"] = json!("expired"); + } else if days <= 30 { + info["status"] = json!("expiring_soon"); + } + } + + info +} + +/// Convert an X509 name to a human-readable string. +fn x509_name_to_string(name: &openssl::x509::X509NameRef) -> String { + name.entries() + .map(|entry| { + let key = entry.object().nid().short_name().unwrap_or("??"); + let value = entry + .data() + .to_string() + .unwrap_or_else(|_| "??".to_string()); + format!("{key}={value}") + }) + .collect::>() + .join(", ") +} + +/// Inspect a private key file. +fn inspect_key(path: &str, issues: &mut Vec) -> Value { + let p = Path::new(path); + + if !p.exists() { + issues.push(format!("Key file not found: {path}")); + return json!({ + "path": path, + "exists": false, + }); + } + + let pem_data = match fs::read(p) { + Ok(data) => data, + Err(e) => { + issues.push(format!("Cannot read key file {path}: {e}")); + return json!({ + "path": path, + "exists": true, + "readable": false, + "error": e.to_string(), + }); + } + }; + + // Try parsing as PEM private key + match PKey::private_key_from_pem(&pem_data) { + Ok(_) => json!({ + "path": path, + "exists": true, + "readable": true, + "valid_pem": true, + }), + Err(e) => { + debug!("Failed to parse key {path}: {e}"); + issues.push(format!("Invalid PEM private key {path}: {e}")); + json!({ + "path": path, + "exists": true, + "readable": true, + "valid_pem": false, + "error": e.to_string(), + }) + } + } +} + +/// Verify that a certificate and key file match. +fn verify_cert_key_pair( + cert_path: &str, + key_path: &str, + issues: &mut Vec, +) -> Option { + let cert_data = fs::read(cert_path).ok()?; + let key_data = fs::read(key_path).ok()?; + + let cert = X509::from_pem(&cert_data).ok()?; + let key = PKey::private_key_from_pem(&key_data).ok()?; + + let cert_pubkey = match cert.public_key() { + Ok(pk) => pk, + Err(_) => return None, + }; + + let matches = cert_pubkey.public_eq(&key); + + if !matches { + issues.push(format!( + "Client certificate ({cert_path}) and key ({key_path}) do not match" + )); + } + + Some(matches) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + /// Generate a self-signed certificate and key pair for testing. + fn generate_test_cert_and_key() -> (Vec, Vec) { + use openssl::asn1::Asn1Time; + use openssl::hash::MessageDigest; + use openssl::pkey::PKey; + use openssl::rsa::Rsa; + use openssl::x509::extension::SubjectAlternativeName; + use openssl::x509::{X509NameBuilder, X509}; + + let rsa = Rsa::generate(2048).expect("Failed to generate RSA key"); + let key = PKey::from_rsa(rsa).expect("Failed to create PKey"); + + let mut name_builder = + X509NameBuilder::new().expect("Failed to create X509NameBuilder"); + let _ = name_builder.append_entry_by_text("CN", "test-keylimectl"); + let name = name_builder.build(); + + let mut builder = + X509::builder().expect("Failed to create X509 builder"); + let _ = builder.set_version(2); + let _ = builder.set_subject_name(&name); + let _ = builder.set_issuer_name(&name); + let _ = builder.set_pubkey(&key); + + let not_before = + Asn1Time::days_from_now(0).expect("Failed to create not_before"); + let not_after = + Asn1Time::days_from_now(365).expect("Failed to create not_after"); + let _ = builder.set_not_before(¬_before); + let _ = builder.set_not_after(¬_after); + + let san = SubjectAlternativeName::new() + .dns("localhost") + .build(&builder.x509v3_context(None, None)) + .expect("Failed to build SAN"); + let _ = builder.append_extension(san); + + let _ = builder.sign(&key, MessageDigest::sha256()); + let cert = builder.build(); + + let cert_pem = cert.to_pem().expect("Failed to serialize cert"); + let key_pem = key + .private_key_to_pem_pkcs8() + .expect("Failed to serialize key"); + + (cert_pem, key_pem) + } + + #[test] + fn test_inspect_certificate_not_found() { + let mut issues = Vec::new(); + let mut suggestions = Vec::new(); + let info = inspect_certificate( + "/nonexistent/cert.pem", + &mut issues, + &mut suggestions, + ); + assert_eq!(info["exists"], false); + assert_eq!(issues.len(), 1); + assert!(issues[0].contains("not found")); + } + + #[test] + fn test_inspect_certificate_valid() { + let (cert_pem, _) = generate_test_cert_and_key(); + + let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + tmpfile.write_all(&cert_pem).unwrap(); //#[allow_ci] + + let mut issues = Vec::new(); + let mut suggestions = Vec::new(); + let info = inspect_certificate( + tmpfile.path().to_str().unwrap(), //#[allow_ci] + &mut issues, + &mut suggestions, + ); + + assert_eq!(info["exists"], true); + assert_eq!(info["readable"], true); + assert_eq!(info["valid_pem"], true); + assert_eq!(info["status"], "ok"); + assert!(info["subject"] + .as_str() + .unwrap() //#[allow_ci] + .contains("test-keylimectl")); + assert!(issues.is_empty()); + } + + #[test] + fn test_inspect_certificate_invalid_pem() { + let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + tmpfile.write_all(b"not a certificate").unwrap(); //#[allow_ci] + + let mut issues = Vec::new(); + let mut suggestions = Vec::new(); + let info = inspect_certificate( + tmpfile.path().to_str().unwrap(), //#[allow_ci] + &mut issues, + &mut suggestions, + ); + + assert_eq!(info["valid_pem"], false); + assert_eq!(issues.len(), 1); + assert!(issues[0].contains("Invalid PEM")); + } + + #[test] + fn test_inspect_key_not_found() { + let mut issues = Vec::new(); + let info = inspect_key("/nonexistent/key.pem", &mut issues); + assert_eq!(info["exists"], false); + assert_eq!(issues.len(), 1); + } + + #[test] + fn test_inspect_key_valid() { + let (_, key_pem) = generate_test_cert_and_key(); + + let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + tmpfile.write_all(&key_pem).unwrap(); //#[allow_ci] + + let mut issues = Vec::new(); + let info = inspect_key(tmpfile.path().to_str().unwrap(), &mut issues); //#[allow_ci] + + assert_eq!(info["exists"], true); + assert_eq!(info["readable"], true); + assert_eq!(info["valid_pem"], true); + assert!(issues.is_empty()); + } + + #[test] + fn test_verify_cert_key_pair_matching() { + let (cert_pem, key_pem) = generate_test_cert_and_key(); + + let mut cert_file = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + cert_file.write_all(&cert_pem).unwrap(); //#[allow_ci] + + let mut key_file = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + key_file.write_all(&key_pem).unwrap(); //#[allow_ci] + + let mut issues = Vec::new(); + let result = verify_cert_key_pair( + cert_file.path().to_str().unwrap(), //#[allow_ci] + key_file.path().to_str().unwrap(), //#[allow_ci] + &mut issues, + ); + + assert_eq!(result, Some(true)); + assert!(issues.is_empty()); + } + + #[test] + fn test_verify_cert_key_pair_mismatched() { + let (cert_pem, _) = generate_test_cert_and_key(); + let (_, other_key_pem) = generate_test_cert_and_key(); + + let mut cert_file = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + cert_file.write_all(&cert_pem).unwrap(); //#[allow_ci] + + let mut key_file = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + key_file.write_all(&other_key_pem).unwrap(); //#[allow_ci] + + let mut issues = Vec::new(); + let result = verify_cert_key_pair( + cert_file.path().to_str().unwrap(), //#[allow_ci] + key_file.path().to_str().unwrap(), //#[allow_ci] + &mut issues, + ); + + assert_eq!(result, Some(false)); + assert_eq!(issues.len(), 1); + assert!(issues[0].contains("do not match")); + } + + #[test] + fn test_verify_cert_key_pair_missing_files() { + let mut issues = Vec::new(); + let result = verify_cert_key_pair( + "/nonexistent/cert.pem", + "/nonexistent/key.pem", + &mut issues, + ); + assert_eq!(result, None); + } +} diff --git a/keylimectl/src/commands/info/verifier_info.rs b/keylimectl/src/commands/info/verifier_info.rs new file mode 100644 index 000000000..23032439d --- /dev/null +++ b/keylimectl/src/commands/info/verifier_info.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Verifier diagnostic information. +//! +//! Queries the verifier for status, API version, and agent count. + +use log::debug; +use serde_json::{json, Value}; + +use crate::client::factory; +use crate::config; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; + +/// Execute the `info verifier` subcommand. +pub async fn execute( + output: &OutputHandler, +) -> Result { + let cfg = config::singleton::get_config(); + let url = cfg.verifier_base_url(); + + output.progress("Connecting to verifier"); + + match factory::get_verifier().await { + Ok(client) => { + let api_version = client.api_version().to_string(); + debug!("Connected to verifier, API version: {api_version}"); + + // Try to get agent count + let verifier_id = cfg.verifier.id.as_deref(); + let agent_count = match client.list_agents(verifier_id).await { + Ok(response) => extract_agent_count(&response), + Err(e) => { + debug!("Failed to list agents: {e}"); + None + } + }; + + let mut result = json!({ + "verifier": { + "url": url, + "reachable": true, + "api_version": api_version, + } + }); + + if let Some(count) = agent_count { + result["verifier"]["agents"] = json!({ "count": count }); + } + + Ok(result) + } + Err(e) => { + debug!("Failed to connect to verifier: {e}"); + Ok(json!({ + "verifier": { + "url": url, + "reachable": false, + "error": e.to_string(), + } + })) + } + } +} + +/// Extract the agent count from a list_agents response. +fn extract_agent_count(response: &Value) -> Option { + // The response may have different structures depending on API version. + // Try common locations for the agent list. + if let Some(results) = response.get("results") { + if let Some(uuids) = results.get("uuids") { + return uuids.as_array().map(|a| a.len()); + } + if let Some(agents) = results.as_array() { + return Some(agents.len()); + } + } + if let Some(agents) = response.get("agents") { + return agents.as_array().map(|a| a.len()); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_agent_count_uuids() { + let response = json!({ + "results": { + "uuids": ["uuid-1", "uuid-2", "uuid-3"] + } + }); + assert_eq!(extract_agent_count(&response), Some(3)); + } + + #[test] + fn test_extract_agent_count_results_array() { + let response = json!({ + "results": [ + {"agent_id": "uuid-1"}, + {"agent_id": "uuid-2"}, + ] + }); + assert_eq!(extract_agent_count(&response), Some(2)); + } + + #[test] + fn test_extract_agent_count_agents_array() { + let response = json!({ + "agents": [ + {"id": "uuid-1"}, + ] + }); + assert_eq!(extract_agent_count(&response), Some(1)); + } + + #[test] + fn test_extract_agent_count_empty() { + let response = json!({}); + assert_eq!(extract_agent_count(&response), None); + } + + #[test] + fn test_extract_agent_count_empty_uuids() { + let response = json!({ + "results": { + "uuids": [] + } + }); + assert_eq!(extract_agent_count(&response), Some(0)); + } +} diff --git a/keylimectl/src/commands/measured_boot.rs b/keylimectl/src/commands/measured_boot.rs new file mode 100644 index 000000000..3f4d92837 --- /dev/null +++ b/keylimectl/src/commands/measured_boot.rs @@ -0,0 +1,1010 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Measured boot policy management commands for keylimectl +//! +//! This module provides comprehensive management of measured boot policies for the Keylime +//! attestation system. Measured boot policies define the expected boot state of agents +//! by specifying trusted boot components, kernel modules, and system configuration. +//! +//! # Measured Boot Overview +//! +//! Measured boot leverages the TPM (Trusted Platform Module) to measure and record +//! the boot process, creating an immutable chain of trust from firmware to OS: +//! +//! 1. **BIOS/UEFI**: Initial measurements stored in PCR 0-7 +//! 2. **Boot Loader**: Measurements of boot components in PCR 8-9 +//! 3. **Kernel**: OS kernel and initrd measurements in PCR 10-15 +//! 4. **Applications**: Runtime measurements in PCR 16-23 +//! +//! # Policy Structure +//! +//! Measured boot policies are JSON documents that specify: +//! - Expected PCR values for different boot stages +//! - Allowed boot components and their hashes +//! - Acceptable kernel configurations +//! - Trusted modules and drivers +//! +//! # Command Types +//! +//! - [`MeasuredBootAction::Push`]: Push a measured boot policy to the verifier +//! - [`MeasuredBootAction::Show`]: Display an existing policy +//! - [`MeasuredBootAction::Update`]: Update an existing policy +//! - [`MeasuredBootAction::Delete`]: Remove a policy +//! - [`MeasuredBootAction::List`]: List all available policies +//! +//! # Security Considerations +//! +//! - Policies must be validated before deployment +//! - Changes to policies affect agent attestation immediately +//! - Invalid policies can prevent agent enrollment +//! - Policy management requires proper authorization +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::commands::measured_boot; +//! use keylimectl::config::Config; +//! use keylimectl::output::OutputHandler; +//! use keylimectl::MeasuredBootAction; +//! +//! # async fn example() -> Result<(), Box> { +//! let config = Config::default(); +//! let output = OutputHandler::new(crate::OutputFormat::Json, false); +//! +//! // Push a measured boot policy to the verifier +//! let push_action = MeasuredBootAction::Push { +//! name: "secure-boot-policy".to_string(), +//! file: "/etc/keylime/policies/secure-boot.json".to_string(), +//! }; +//! +//! let result = measured_boot::execute(&push_action, &config, &output).await?; +//! println!("Policy pushed: {:?}", result); +//! +//! // List all policies +//! let list_action = MeasuredBootAction::List; +//! let policies = measured_boot::execute(&list_action, &config, &output).await?; +//! # Ok(()) +//! # } +//! ``` + +use crate::client::factory; +use crate::commands::error::CommandError; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use crate::MeasuredBootAction; +use chrono; +use log::debug; +use serde_json::{json, Value}; +use std::fs; + +/// Execute a measured boot policy management command +/// +/// This is the main entry point for all measured boot policy operations. It dispatches +/// to the appropriate handler based on the action type and manages the complete +/// operation lifecycle including file validation, policy processing, and result reporting. +/// +/// # Arguments +/// +/// * `action` - The specific measured boot action to perform (Push, Show, Update, Delete, or List) +/// * `config` - Configuration containing verifier endpoint and authentication settings +/// * `output` - Output handler for progress reporting and result formatting +/// +/// # Returns +/// +/// Returns a JSON value containing the operation results: +/// - `status`: "success" if operation completed successfully +/// - `message`: Human-readable status message +/// - `policy_name`: Name of the affected policy (for single-policy operations) +/// - `results`: Detailed operation results from the verifier service +/// +/// # Policy File Format +/// +/// Policy files must be valid JSON documents containing measured boot specifications: +/// ```json +/// { +/// "pcrs": { +/// "0": "expected_pcr0_value", +/// "1": "expected_pcr1_value" +/// }, +/// "components": [ +/// { +/// "name": "bootloader", +/// "hash": "sha256_hash_value" +/// } +/// ] +/// } +/// ``` +/// +/// # Error Handling +/// +/// This function handles various error conditions: +/// - Invalid policy file paths or unreadable files +/// - Malformed JSON in policy files +/// - Network failures when communicating with verifier +/// - Policy validation errors from the verifier +/// - Missing or duplicate policy names +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::commands::measured_boot; +/// use keylimectl::config::Config; +/// use keylimectl::output::OutputHandler; +/// use keylimectl::MeasuredBootAction; +/// +/// # async fn example() -> Result<(), Box> { +/// let config = Config::default(); +/// let output = OutputHandler::new(crate::OutputFormat::Json, false); +/// +/// // Push a policy +/// let push_action = MeasuredBootAction::Push { +/// name: "production-policy".to_string(), +/// file: "/etc/keylime/mb-policy.json".to_string(), +/// }; +/// let result = measured_boot::execute(&push_action, &config, &output).await?; +/// assert_eq!(result["status"], "success"); +/// +/// // Show the policy +/// let show_action = MeasuredBootAction::Show { +/// name: "production-policy".to_string(), +/// }; +/// let policy = measured_boot::execute(&show_action, &config, &output).await?; +/// +/// // List all policies +/// let list_action = MeasuredBootAction::List; +/// let policies = measured_boot::execute(&list_action, &config, &output).await?; +/// # Ok(()) +/// # } +/// ``` +pub async fn execute( + action: &MeasuredBootAction, + output: &OutputHandler, +) -> Result { + match action { + MeasuredBootAction::List => list_mb_policies(output) + .await + .map_err(KeylimectlError::from), + MeasuredBootAction::Push { name, file } => { + push_mb_policy(name, file, output) + .await + .map_err(KeylimectlError::from) + } + MeasuredBootAction::Show { name } => show_mb_policy(name, output) + .await + .map_err(KeylimectlError::from), + MeasuredBootAction::Update { name, file } => { + update_mb_policy(name, file, output) + .await + .map_err(KeylimectlError::from) + } + MeasuredBootAction::Delete { name } => delete_mb_policy(name, output) + .await + .map_err(KeylimectlError::from), + } +} + +/// Push a measured boot policy to the verifier +async fn push_mb_policy( + name: &str, + file_path: &str, + output: &OutputHandler, +) -> Result { + output.info(format!("Pushing measured boot policy '{name}'")); + + // Load policy from file + let policy_content = fs::read_to_string(file_path).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to read measured boot policy file: {e}"), + ) + })?; + + // Parse policy content (basic validation) + let _policy_json: Value = + serde_json::from_str(&policy_content).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to parse measured boot policy as JSON: {e}"), + ) + })?; + + debug!( + "Loaded measured boot policy from {}: {} bytes", + file_path, + policy_content.len() + ); + + // Create policy data structure for the API + // Parse the policy to extract metadata and validate structure + let policy_json: Value = + serde_json::from_str(&policy_content).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to parse measured boot policy as JSON: {e}"), + ) + })?; + + // Extract policy metadata for enhanced API payload + let mut policy_data = json!({ + "mb_policy": policy_content, + "policy_type": "measured_boot", + "format_version": "1.0", + "upload_timestamp": chrono::Utc::now().to_rfc3339() + }); + + // Add metadata based on policy content structure + if let Some(pcrs) = policy_json.get("pcrs").and_then(|v| v.as_object()) { + policy_data["pcr_count"] = json!(pcrs.len()); + policy_data["pcr_list"] = json!(pcrs.keys().collect::>()); + } + + if let Some(components) = + policy_json.get("components").and_then(|v| v.as_array()) + { + policy_data["components_count"] = json!(components.len()); + } + + if let Some(settings) = policy_json.get("settings") { + policy_data["mb_settings"] = settings.clone(); + if let Some(secure_boot) = settings.get("secure_boot") { + policy_data["secure_boot_enabled"] = secure_boot.clone(); + } + if let Some(tpm_version) = settings.get("tpm_version") { + policy_data["tpm_version"] = tpm_version.clone(); + } + } + + if let Some(meta) = policy_json.get("meta") { + policy_data["policy_metadata"] = meta.clone(); + } + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let response = verifier_client + .add_mb_policy(name, policy_data) + .await + .map_err(|e| { + CommandError::resource_error( + "verifier", + format!("Failed to push measured boot policy '{name}': {e}"), + ) + })?; + + output.info(format!("Measured boot policy '{name}' pushed successfully")); + + Ok(json!({ + "status": "success", + "message": format!("Measured boot policy '{name}' pushed successfully"), + "policy_name": name, + "results": response + })) +} + +/// Show a measured boot policy +async fn show_mb_policy( + name: &str, + output: &OutputHandler, +) -> Result { + output.info(format!("Retrieving measured boot policy '{name}'")); + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let policy = verifier_client.get_mb_policy(name).await.map_err(|e| { + CommandError::resource_error( + "verifier", + format!("Failed to retrieve measured boot policy '{name}': {e}"), + ) + })?; + + match policy { + Some(policy_data) => Ok(json!({ + "policy_name": name, + "results": policy_data + })), + None => Err(CommandError::policy_not_found(name)), + } +} + +/// Update an existing measured boot policy +async fn update_mb_policy( + name: &str, + file_path: &str, + output: &OutputHandler, +) -> Result { + output.info(format!("Updating measured boot policy '{name}'")); + + // Load policy from file + let policy_content = fs::read_to_string(file_path).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to read measured boot policy file: {e}"), + ) + })?; + + // Parse policy content (basic validation) + let _policy_json: Value = + serde_json::from_str(&policy_content).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to parse measured boot policy as JSON: {e}"), + ) + })?; + + debug!( + "Loaded measured boot policy from {}: {} bytes", + file_path, + policy_content.len() + ); + + // Create policy data structure for the API + // Parse the policy to extract metadata and validate structure + let policy_json: Value = + serde_json::from_str(&policy_content).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to parse measured boot policy as JSON: {e}"), + ) + })?; + + // Extract policy metadata for enhanced API payload + let mut policy_data = json!({ + "mb_policy": policy_content, + "policy_type": "measured_boot", + "format_version": "1.0", + "update_timestamp": chrono::Utc::now().to_rfc3339() + }); + + // Add metadata based on policy content structure + if let Some(pcrs) = policy_json.get("pcrs").and_then(|v| v.as_object()) { + policy_data["pcr_count"] = json!(pcrs.len()); + policy_data["pcr_list"] = json!(pcrs.keys().collect::>()); + } + + if let Some(components) = + policy_json.get("components").and_then(|v| v.as_array()) + { + policy_data["components_count"] = json!(components.len()); + } + + if let Some(settings) = policy_json.get("settings") { + policy_data["mb_settings"] = settings.clone(); + if let Some(secure_boot) = settings.get("secure_boot") { + policy_data["secure_boot_enabled"] = secure_boot.clone(); + } + if let Some(tpm_version) = settings.get("tpm_version") { + policy_data["tpm_version"] = tpm_version.clone(); + } + } + + if let Some(meta) = policy_json.get("meta") { + policy_data["policy_metadata"] = meta.clone(); + } + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let response = verifier_client + .update_mb_policy(name, policy_data) + .await + .map_err(|e| { + CommandError::resource_error( + "verifier", + format!( + "Failed to update measured boot policy '{name}': {e}" + ), + ) + })?; + + output.info(format!( + "Measured boot policy '{name}' updated successfully" + )); + + Ok(json!({ + "status": "success", + "message": format!("Measured boot policy '{name}' updated successfully"), + "policy_name": name, + "results": response + })) +} + +/// Delete a measured boot policy +async fn delete_mb_policy( + name: &str, + output: &OutputHandler, +) -> Result { + output.info(format!("Deleting measured boot policy '{name}'")); + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let response = + verifier_client.delete_mb_policy(name).await.map_err(|e| { + CommandError::resource_error( + "verifier", + format!( + "Failed to delete measured boot policy '{name}': {e}" + ), + ) + })?; + + output.info(format!( + "Measured boot policy '{name}' deleted successfully" + )); + + Ok(json!({ + "status": "success", + "message": format!("Measured boot policy '{name}' deleted successfully"), + "policy_name": name, + "results": response + })) +} + +/// List measured boot policies +async fn list_mb_policies( + output: &OutputHandler, +) -> Result { + output.info("Listing measured boot policies"); + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let policies = verifier_client.list_mb_policies().await.map_err(|e| { + CommandError::resource_error( + "verifier", + format!( + "Failed to list measured boot policies from verifier: {e}" + ), + ) + })?; + + Ok(policies) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + AgentConfig, CliOverrides, ClientConfig, Config, RegistrarConfig, + TlsConfig, VerifierConfig, + }; + use serde_json::json; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Create a test configuration for measured boot operations + fn create_test_config() -> Config { + Config { + loaded_from: None, + cli_overrides: CliOverrides::default(), + verifier: VerifierConfig { + ip: "127.0.0.1".to_string(), + port: 8881, + id: Some("test-verifier".to_string()), + }, + registrar: RegistrarConfig { + ip: "127.0.0.1".to_string(), + port: 8891, + }, + tls: TlsConfig { + client_cert: None, + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: false, // Disable for testing + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }, + client: ClientConfig { + timeout: 30, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + }, + agent: AgentConfig::default(), + } + } + + /// Create a test output handler + fn create_test_output() -> OutputHandler { + OutputHandler::new( + crate::OutputFormat::Json, + true, + crate::ColorMode::Never, + ) // Quiet mode for tests + } + + /// Create a test measured boot policy file + fn create_test_policy_file() -> Result { + let mut file = NamedTempFile::new()?; + let policy_content = json!({ + "pcrs": { + "0": "3a3f5c1f5b9e8f2a1d7e9b4a2c6f8e1d3a5b7c9e", + "1": "1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c", + "2": "9e8d7c6b5a4938271605f4e3d2c1b0a9f8e7d6c5" + }, + "components": [ + { + "name": "bootloader", + "hash": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + }, + { + "name": "kernel", + "hash": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + } + ], + "settings": { + "secure_boot": true, + "tpm_version": "2.0", + "expected_state": "trusted" + } + }); + + file.write_all( + serde_json::to_string_pretty(&policy_content)?.as_bytes(), + )?; + file.flush()?; + Ok(file) + } + + /// Create a test invalid policy file + fn create_invalid_policy_file() -> Result { + let mut file = NamedTempFile::new()?; + file.write_all(b"{ invalid json content")?; + file.flush()?; + Ok(file) + } + + #[test] + fn test_config_creation() { + let config = create_test_config(); + + assert_eq!(config.verifier.ip, "127.0.0.1"); + assert_eq!(config.verifier.port, 8881); + assert!(!config.tls.verify_server_cert); + assert_eq!(config.client.max_retries, 3); + } + + #[test] + fn test_output_handler_creation() { + let _output = create_test_output(); + // OutputHandler creation should not panic + } + + #[test] + fn test_valid_policy_file_creation() { + let policy_file = create_test_policy_file() + .expect("Failed to create test policy file"); + + // Verify file exists and can be read + let content = fs::read_to_string(policy_file.path()) + .expect("Failed to read policy file"); + let parsed: Value = + serde_json::from_str(&content).expect("Failed to parse JSON"); + + assert!(parsed["pcrs"].is_object()); + assert!(parsed["components"].is_array()); + assert_eq!(parsed["settings"]["secure_boot"], true); + } + + #[test] + fn test_invalid_policy_file_creation() { + let invalid_file = create_invalid_policy_file() + .expect("Failed to create invalid file"); + + // Verify file exists but contains invalid JSON + let content = fs::read_to_string(invalid_file.path()) + .expect("Failed to read file"); + let parse_result: Result = serde_json::from_str(&content); + assert!(parse_result.is_err()); + } + + // Test measured boot action variants + mod action_variants { + use super::*; + + #[test] + fn test_push_action() { + let action = MeasuredBootAction::Push { + name: "test-policy".to_string(), + file: "/path/to/policy.json".to_string(), + }; + + match action { + MeasuredBootAction::Push { name, file } => { + assert_eq!(name, "test-policy"); + assert_eq!(file, "/path/to/policy.json"); + } + _ => panic!("Expected Push action"), //#[allow_ci] + } + } + + #[test] + fn test_show_action() { + let action = MeasuredBootAction::Show { + name: "test-policy".to_string(), + }; + + match action { + MeasuredBootAction::Show { name } => { + assert_eq!(name, "test-policy"); + } + _ => panic!("Expected Show action"), //#[allow_ci] + } + } + + #[test] + fn test_update_action() { + let action = MeasuredBootAction::Update { + name: "test-policy".to_string(), + file: "/path/to/updated-policy.json".to_string(), + }; + + match action { + MeasuredBootAction::Update { name, file } => { + assert_eq!(name, "test-policy"); + assert_eq!(file, "/path/to/updated-policy.json"); + } + _ => panic!("Expected Update action"), //#[allow_ci] + } + } + + #[test] + fn test_delete_action() { + let action = MeasuredBootAction::Delete { + name: "test-policy".to_string(), + }; + + match action { + MeasuredBootAction::Delete { name } => { + assert_eq!(name, "test-policy"); + } + _ => panic!("Expected Delete action"), //#[allow_ci] + } + } + } + + // Test policy file validation + mod policy_validation { + use super::*; + + #[test] + fn test_valid_policy_structure() { + let policy = json!({ + "pcrs": { + "0": "abc123", + "1": "def456" + }, + "components": [ + { + "name": "bootloader", + "hash": "sha256:abcdef" + } + ] + }); + + // Verify policy structure + assert!(policy["pcrs"].is_object()); + assert!(policy["components"].is_array()); + assert_eq!(policy["components"].as_array().unwrap().len(), 1); //#[allow_ci] + } + + #[test] + fn test_policy_with_different_pcrs() { + let policy = json!({ + "pcrs": { + "0": "pcr0_value", + "1": "pcr1_value", + "2": "pcr2_value", + "3": "pcr3_value", + "7": "pcr7_value" + } + }); + + let pcrs = policy["pcrs"].as_object().unwrap(); //#[allow_ci] + assert_eq!(pcrs.len(), 5); + assert_eq!(pcrs["0"], "pcr0_value"); + assert_eq!(pcrs["7"], "pcr7_value"); + } + + #[test] + fn test_policy_with_multiple_components() { + let policy = json!({ + "components": [ + { + "name": "bootloader", + "hash": "sha256:bootloader_hash" + }, + { + "name": "kernel", + "hash": "sha256:kernel_hash" + }, + { + "name": "initrd", + "hash": "sha256:initrd_hash" + } + ] + }); + + let components = policy["components"].as_array().unwrap(); //#[allow_ci] + assert_eq!(components.len(), 3); + assert_eq!(components[0]["name"], "bootloader"); + assert_eq!(components[1]["name"], "kernel"); + assert_eq!(components[2]["name"], "initrd"); + } + + #[test] + fn test_policy_with_settings() { + let policy = json!({ + "settings": { + "secure_boot": true, + "tpm_version": "2.0", + "expected_state": "trusted", + "allow_debug": false + } + }); + + let settings = policy["settings"].as_object().unwrap(); //#[allow_ci] + assert_eq!(settings["secure_boot"], true); + assert_eq!(settings["tpm_version"], "2.0"); + assert_eq!(settings["expected_state"], "trusted"); + assert_eq!(settings["allow_debug"], false); + } + } + + // Test JSON response structures + mod json_responses { + use super::*; + + #[test] + fn test_success_response_structure() { + let response = json!({ + "status": "success", + "message": "Measured boot policy 'test-policy' pushed successfully", + "policy_name": "test-policy", + "results": { + "verifier_response": "OK", + "policy_id": "12345" + } + }); + + assert_eq!(response["status"], "success"); + assert_eq!(response["policy_name"], "test-policy"); + assert!(response["results"].is_object()); + assert!(response["message"] + .as_str() + .unwrap() //#[allow_ci] + .contains("pushed successfully")); + } + + #[test] + fn test_list_response_structure() { + // Test a simulated list response structure since List is not an action variant + let response = json!({ + "status": "success", + "message": "Listed 3 measured boot policies", + "results": { + "policies": [ + { + "name": "policy1", + "created": "2025-01-01T00:00:00Z" + }, + { + "name": "policy2", + "created": "2025-01-02T00:00:00Z" + }, + { + "name": "policy3", + "created": "2025-01-03T00:00:00Z" + } + ] + } + }); + + assert_eq!(response["status"], "success"); + assert!(response["results"]["policies"].is_array()); + assert_eq!( + response["results"]["policies"].as_array().unwrap().len(), //#[allow_ci] + 3 + ); + } + + #[test] + fn test_error_response_structure() { + let error = KeylimectlError::policy_not_found("missing-policy"); + let error_json = error.to_json(); + + assert_eq!(error_json["error"]["code"], "POLICY_NOT_FOUND"); + assert_eq!( + error_json["error"]["details"]["policy_name"], + "missing-policy" + ); + } + } + + // Test error handling scenarios + mod error_handling { + use super::*; + + #[test] + fn test_policy_not_found_error() { + let error = + KeylimectlError::policy_not_found("nonexistent-policy"); + + match &error { + KeylimectlError::PolicyNotFound { name } => { + assert_eq!(name, "nonexistent-policy"); + } + _ => panic!("Expected PolicyNotFound error"), //#[allow_ci] + } + + assert_eq!(error.error_code(), "POLICY_NOT_FOUND"); + assert!(!error.is_retryable()); + } + + #[test] + fn test_validation_error() { + let error = KeylimectlError::validation("Invalid policy format"); + + assert_eq!(error.error_code(), "VALIDATION_ERROR"); + assert!(!error.is_retryable()); + assert!(error.to_string().contains("Invalid policy format")); + } + + #[test] + fn test_io_error_context() { + use crate::error::ErrorContext; + + let io_error: Result<(), std::io::Error> = + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "file not found", + )); + + let contextual_error = io_error.with_context(|| { + "Failed to read measured boot policy file".to_string() + }); + + assert!(contextual_error.is_err()); + let error = contextual_error.unwrap_err(); + assert_eq!(error.error_code(), "GENERIC_ERROR"); + } + } + + // Test file operations + mod file_operations { + use super::*; + + #[test] + fn test_read_valid_policy_file() { + let policy_file = create_test_policy_file() + .expect("Failed to create test file"); + let file_path = policy_file.path().to_str().unwrap(); //#[allow_ci] + + // Test reading the file + let content = + fs::read_to_string(file_path).expect("Failed to read file"); + assert!(!content.is_empty()); + + // Test parsing the content + let parsed: Value = + serde_json::from_str(&content).expect("Failed to parse JSON"); + assert!(parsed.is_object()); + } + + #[test] + fn test_read_invalid_policy_file() { + let invalid_file = create_invalid_policy_file() + .expect("Failed to create invalid file"); + let file_path = invalid_file.path().to_str().unwrap(); //#[allow_ci] + + // Test reading the file succeeds + let content = + fs::read_to_string(file_path).expect("Failed to read file"); + assert!(!content.is_empty()); + + // Test parsing the content fails + let parse_result: Result = + serde_json::from_str(&content); + assert!(parse_result.is_err()); + } + + #[test] + fn test_nonexistent_file() { + let nonexistent_path = "/path/that/does/not/exist/policy.json"; + let read_result = fs::read_to_string(nonexistent_path); + assert!(read_result.is_err()); + } + } + + // Test configuration validation + mod config_validation { + use super::*; + + #[test] + fn test_config_validation_success() { + let config = create_test_config(); + let result = config.validate(); + assert!(result.is_ok(), "Test config should be valid"); + } + + #[test] + fn test_verifier_url_construction() { + let config = create_test_config(); + assert_eq!(config.verifier_base_url(), "https://127.0.0.1:8881"); + } + + #[test] + fn test_config_with_different_ports() { + let mut config = create_test_config(); + config.verifier.port = 9001; + + assert_eq!(config.verifier_base_url(), "https://127.0.0.1:9001"); + } + } + + // Test measured boot specific scenarios + mod measured_boot_scenarios { + + #[test] + fn test_policy_name_validation() { + // Test valid policy names + let valid_names = [ + "production-policy", + "test_policy", + "policy123", + "secure-boot-v2", + "minimal", + ]; + + for name in &valid_names { + // Policy names should be non-empty strings + assert!(!name.is_empty()); + assert!(name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_')); + } + } + + #[test] + fn test_pcr_value_formats() { + // Test different PCR value formats + let pcr_values = [ + "3a3f5c1f5b9e8f2a1d7e9b4a2c6f8e1d3a5b7c9e", // 40 chars (SHA-1) + "1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", // 64 chars (SHA-256) + "0000000000000000000000000000000000000000", // All zeros + "ffffffffffffffffffffffffffffffffffffffff", // All Fs + ]; + + for pcr_value in &pcr_values { + assert!(!pcr_value.is_empty()); + assert!(pcr_value.chars().all(|c| c.is_ascii_hexdigit())); + } + } + + #[test] + fn test_hash_algorithm_formats() { + let hash_formats = [ + "sha1:da39a3ee5e6b4b0d3255bfef95601890afd80709", + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "sha384:38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b", + "md5:d41d8cd98f00b204e9800998ecf8427e", + ]; + + for hash_format in &hash_formats { + assert!(hash_format.contains(':')); + let parts: Vec<&str> = hash_format.split(':').collect(); + assert_eq!(parts.len(), 2); + + let algorithm = parts[0]; + let hash_value = parts[1]; + + assert!(!algorithm.is_empty()); + assert!(!hash_value.is_empty()); + assert!(hash_value.chars().all(|c| c.is_ascii_hexdigit())); + } + } + } +} diff --git a/keylimectl/src/commands/mod.rs b/keylimectl/src/commands/mod.rs new file mode 100644 index 000000000..b59c96f43 --- /dev/null +++ b/keylimectl/src/commands/mod.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Command implementations for keylimectl + +pub mod agent; +pub mod configure; +pub mod error; +pub mod info; +pub mod measured_boot; +pub mod policy; +pub mod verify; diff --git a/keylimectl/src/commands/policy/convert.rs b/keylimectl/src/commands/policy/convert.rs new file mode 100644 index 000000000..30b1f6f61 --- /dev/null +++ b/keylimectl/src/commands/policy/convert.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Legacy policy format conversion. + +use crate::commands::error::CommandError; +use crate::output::OutputHandler; +use crate::policy_tools::conversion; +use crate::policy_tools::ima_parser; +use serde_json::Value; +use std::path::Path; + +/// Execute the policy convert command. +pub async fn execute( + file: &str, + output_file: &str, + excludelist: Option<&str>, + verification_keys: Option<&str>, + output: &OutputHandler, +) -> Result { + output.info(format!( + "Converting legacy allowlist '{file}' to runtime policy" + )); + + // Convert the input file + let mut policy = conversion::convert_allowlist_file(Path::new(file))?; + + output.info(format!("Converted {} file entries", policy.digest_count())); + + // Merge exclude list if provided + if let Some(excl_path) = excludelist { + let excludes = ima_parser::parse_excludelist(Path::new(excl_path))?; + conversion::merge_excludelist(&mut policy, &excludes); + output.info(format!( + "Added {} exclude patterns from '{excl_path}'", + excludes.len() + )); + } + + // Add verification keys if provided + if let Some(key_path) = verification_keys { + conversion::add_verification_keys(&mut policy, key_path)?; + output.info(format!("Added verification keys from '{key_path}'")); + } + + // Serialize and write + let json_val = serde_json::to_value(&policy)?; + let json_str = serde_json::to_string_pretty(&json_val)?; + + std::fs::write(output_file, &json_str)?; + + output.info(format!("Runtime policy written to {output_file}")); + + Ok(json_val) +} diff --git a/keylimectl/src/commands/policy/crud.rs b/keylimectl/src/commands/policy/crud.rs new file mode 100644 index 000000000..5ed3a644f --- /dev/null +++ b/keylimectl/src/commands/policy/crud.rs @@ -0,0 +1,955 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Runtime policy CRUD operations (verifier-side management). +//! +//! This module handles push, show, update, and delete operations for +//! runtime policies stored on the Keylime verifier. + +use crate::client::factory; +use crate::commands::error::CommandError; +use crate::error::{ErrorContext, KeylimectlError}; +use crate::output::OutputHandler; +use crate::PolicyAction; +use base64::{engine::general_purpose::STANDARD as Base64, Engine}; +use chrono; +use log::debug; +use serde_json::{json, Value}; +use std::fs; + +/// Execute a runtime policy CRUD command. +pub async fn execute( + action: &PolicyAction, + output: &OutputHandler, +) -> Result { + match action { + PolicyAction::List => list_runtime_policies(output).await, + PolicyAction::Push { name, file } => push_policy(name, file, output) + .await + .map_err(KeylimectlError::from), + PolicyAction::Show { name } => show_policy(name, output) + .await + .map_err(KeylimectlError::from), + PolicyAction::Update { name, file } => { + update_policy(name, file, output) + .await + .map_err(KeylimectlError::from) + } + PolicyAction::Delete { name } => delete_policy(name, output) + .await + .map_err(KeylimectlError::from), + // Non-CRUD actions are handled by the parent module + _ => unreachable!( //#[allow_ci] + "Non-CRUD policy actions should be dispatched by the parent module" + ), + } +} + +/// Push a runtime policy to the verifier +async fn push_policy( + name: &str, + file_path: &str, + output: &OutputHandler, +) -> Result { + output.info(format!("Pushing runtime policy '{name}'")); + + // Load policy from file + let policy_content = fs::read_to_string(file_path).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to read policy file: {e}"), + ) + })?; + + // Parse policy content (basic validation) + let _policy_json: Value = + serde_json::from_str(&policy_content).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to parse policy as JSON: {e}"), + ) + })?; + + debug!( + "Loaded policy from {}: {} bytes", + file_path, + policy_content.len() + ); + + // Create policy data structure for the API + // Parse the policy to extract metadata and validate structure + let policy_json: Value = + serde_json::from_str(&policy_content).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to parse policy as JSON: {e}"), + ) + })?; + + // Extract policy metadata for enhanced API payload + // Note: The verifier expects runtime_policy to be base64-encoded + let encoded_policy = Base64.encode(policy_content.as_bytes()); + let mut policy_data = json!({ + "runtime_policy": encoded_policy, + "policy_type": "runtime", + "format_version": "1.0", + "upload_timestamp": chrono::Utc::now().to_rfc3339() + }); + + // Add metadata based on policy content structure + if let Some(allowlist) = + policy_json.get("allowlist").and_then(|v| v.as_array()) + { + policy_data["allowlist_count"] = json!(allowlist.len()); + } + + if let Some(exclude) = + policy_json.get("exclude").and_then(|v| v.as_array()) + { + policy_data["exclude_count"] = json!(exclude.len()); + } + + if let Some(ima) = policy_json.get("ima") { + policy_data["ima_enabled"] = json!(true); + if let Some(require_sigs) = ima.get("require_signatures") { + policy_data["ima_require_signatures"] = require_sigs.clone(); + } + } else { + policy_data["ima_enabled"] = json!(false); + } + + if let Some(meta) = policy_json.get("meta") { + policy_data["policy_metadata"] = meta.clone(); + } + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let response = verifier_client + .add_runtime_policy(name, policy_data) + .await + .map_err(|e| { + CommandError::resource_error( + "verifier", + format!("Failed to push runtime policy '{name}': {e}"), + ) + })?; + + output.info(format!("Runtime policy '{name}' pushed successfully")); + + Ok(json!({ + "status": "success", + "message": format!("Runtime policy '{name}' pushed successfully"), + "policy_name": name, + "results": response + })) +} + +/// Show a runtime policy +async fn show_policy( + name: &str, + output: &OutputHandler, +) -> Result { + output.info(format!("Retrieving runtime policy '{name}'")); + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let policy = + verifier_client + .get_runtime_policy(name) + .await + .map_err(|e| { + CommandError::resource_error( + "verifier", + format!( + "Failed to retrieve runtime policy '{name}': {e}" + ), + ) + })?; + + match policy { + Some(policy_data) => Ok(json!({ + "policy_name": name, + "results": policy_data + })), + None => Err(CommandError::policy_not_found(name)), + } +} + +/// Update an existing runtime policy +async fn update_policy( + name: &str, + file_path: &str, + output: &OutputHandler, +) -> Result { + output.info(format!("Updating runtime policy '{name}'")); + + // Load policy from file + let policy_content = fs::read_to_string(file_path).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to read policy file: {e}"), + ) + })?; + + // Parse policy content (basic validation) + let _policy_json: Value = + serde_json::from_str(&policy_content).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to parse policy as JSON: {e}"), + ) + })?; + + debug!( + "Loaded policy from {}: {} bytes", + file_path, + policy_content.len() + ); + + // Create policy data structure for the API + // Parse the policy to extract metadata and validate structure + let policy_json: Value = + serde_json::from_str(&policy_content).map_err(|e| { + CommandError::policy_file_error( + file_path, + format!("Failed to parse policy as JSON: {e}"), + ) + })?; + + // Extract policy metadata for enhanced API payload + // Note: The verifier expects runtime_policy to be base64-encoded + let encoded_policy = Base64.encode(policy_content.as_bytes()); + let mut policy_data = json!({ + "runtime_policy": encoded_policy, + "policy_type": "runtime", + "format_version": "1.0", + "update_timestamp": chrono::Utc::now().to_rfc3339() + }); + + // Add metadata based on policy content structure + if let Some(allowlist) = + policy_json.get("allowlist").and_then(|v| v.as_array()) + { + policy_data["allowlist_count"] = json!(allowlist.len()); + } + + if let Some(exclude) = + policy_json.get("exclude").and_then(|v| v.as_array()) + { + policy_data["exclude_count"] = json!(exclude.len()); + } + + if let Some(ima) = policy_json.get("ima") { + policy_data["ima_enabled"] = json!(true); + if let Some(require_sigs) = ima.get("require_signatures") { + policy_data["ima_require_signatures"] = require_sigs.clone(); + } + } else { + policy_data["ima_enabled"] = json!(false); + } + + if let Some(meta) = policy_json.get("meta") { + policy_data["policy_metadata"] = meta.clone(); + } + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let response = verifier_client + .update_runtime_policy(name, policy_data) + .await + .map_err(|e| { + CommandError::resource_error( + "verifier", + format!("Failed to update runtime policy '{name}': {e}"), + ) + })?; + + output.info(format!("Runtime policy '{name}' updated successfully")); + + Ok(json!({ + "status": "success", + "message": format!("Runtime policy '{name}' updated successfully"), + "policy_name": name, + "results": response + })) +} + +/// Delete a runtime policy +async fn delete_policy( + name: &str, + output: &OutputHandler, +) -> Result { + output.info(format!("Deleting runtime policy '{name}'")); + + let verifier_client = factory::get_verifier().await.map_err(|e| { + CommandError::connection_error("verifier", e.to_string()) + })?; + let response = verifier_client + .delete_runtime_policy(name) + .await + .map_err(|e| { + CommandError::resource_error( + "verifier", + format!("Failed to delete runtime policy '{name}': {e}"), + ) + })?; + + output.info(format!("Runtime policy '{name}' deleted successfully")); + + Ok(json!({ + "status": "success", + "message": format!("Runtime policy '{name}' deleted successfully"), + "policy_name": name, + "results": response + })) +} + +/// List runtime policies from the verifier +async fn list_runtime_policies( + output: &OutputHandler, +) -> Result { + output.info("Listing runtime policies"); + + let verifier_client = factory::get_verifier().await?; + let policies = verifier_client + .list_runtime_policies() + .await + .with_context(|| { + "Failed to list runtime policies from verifier".to_string() + })?; + + Ok(policies) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + AgentConfig, CliOverrides, ClientConfig, Config, RegistrarConfig, + TlsConfig, VerifierConfig, + }; + use serde_json::json; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Create a test configuration for runtime policy operations + fn create_test_config() -> Config { + Config { + loaded_from: None, + cli_overrides: CliOverrides::default(), + verifier: VerifierConfig { + ip: "127.0.0.1".to_string(), + port: 8881, + id: Some("test-verifier".to_string()), + }, + registrar: RegistrarConfig { + ip: "127.0.0.1".to_string(), + port: 8891, + }, + tls: TlsConfig { + client_cert: None, + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: false, // Disable for testing + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }, + client: ClientConfig { + timeout: 30, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + }, + agent: AgentConfig::default(), + } + } + + /// Create a test output handler + fn create_test_output() -> OutputHandler { + OutputHandler::new( + crate::OutputFormat::Json, + true, + crate::ColorMode::Never, + ) // Quiet mode for tests + } + + /// Create a test runtime policy file + fn create_test_policy_file() -> Result { + let mut file = NamedTempFile::new()?; + let policy_content = json!({ + "allowlist": [ + { + "path": "/usr/bin/bash", + "hash": "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + }, + { + "path": "/lib/x86_64-linux-gnu/libc.so.6", + "hash": "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + }, + { + "path": "/usr/sbin/sshd", + "hash": "sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" + } + ], + "exclude": [ + "/tmp/*", + "/var/cache/*", + "/proc/*", + "/sys/*" + ], + "ima": { + "require_signatures": true, + "allowed_keyrings": ["builtin_trusted_keys", "_ima"], + "fail_action": "log" + }, + "meta": { + "version": "1.0", + "description": "Test runtime policy for web server", + "created": "2025-01-01T00:00:00Z" + } + }); + + file.write_all( + serde_json::to_string_pretty(&policy_content)?.as_bytes(), + )?; + file.flush()?; + Ok(file) + } + + /// Create a test invalid policy file + fn create_invalid_policy_file() -> Result { + let mut file = NamedTempFile::new()?; + file.write_all(b"{ invalid json content")?; + file.flush()?; + Ok(file) + } + + #[test] + fn test_config_creation() { + let config = create_test_config(); + + assert_eq!(config.verifier.ip, "127.0.0.1"); + assert_eq!(config.verifier.port, 8881); + assert!(!config.tls.verify_server_cert); + assert_eq!(config.client.max_retries, 3); + } + + #[test] + fn test_output_handler_creation() { + let _output = create_test_output(); + // OutputHandler creation should not panic + } + + #[test] + fn test_valid_policy_file_creation() { + let policy_file = create_test_policy_file() + .expect("Failed to create test policy file"); + + // Verify file exists and can be read + let content = fs::read_to_string(policy_file.path()) + .expect("Failed to read policy file"); + let parsed: Value = + serde_json::from_str(&content).expect("Failed to parse JSON"); + + assert!(parsed["allowlist"].is_array()); + assert!(parsed["exclude"].is_array()); + assert!(parsed["ima"].is_object()); + assert_eq!(parsed["ima"]["require_signatures"], true); + } + + #[test] + fn test_invalid_policy_file_creation() { + let invalid_file = create_invalid_policy_file() + .expect("Failed to create invalid file"); + + // Verify file exists but contains invalid JSON + let content = fs::read_to_string(invalid_file.path()) + .expect("Failed to read file"); + let parse_result: Result = serde_json::from_str(&content); + assert!(parse_result.is_err()); + } + + // Test policy action variants + mod action_variants { + use super::*; + + #[test] + fn test_push_action() { + let action = PolicyAction::Push { + name: "test-policy".to_string(), + file: "/path/to/policy.json".to_string(), + }; + + match action { + PolicyAction::Push { name, file } => { + assert_eq!(name, "test-policy"); + assert_eq!(file, "/path/to/policy.json"); + } + _ => panic!("Expected Push action"), //#[allow_ci] + } + } + + #[test] + fn test_show_action() { + let action = PolicyAction::Show { + name: "test-policy".to_string(), + }; + + match action { + PolicyAction::Show { name } => { + assert_eq!(name, "test-policy"); + } + _ => panic!("Expected Show action"), //#[allow_ci] + } + } + + #[test] + fn test_update_action() { + let action = PolicyAction::Update { + name: "test-policy".to_string(), + file: "/path/to/updated-policy.json".to_string(), + }; + + match action { + PolicyAction::Update { name, file } => { + assert_eq!(name, "test-policy"); + assert_eq!(file, "/path/to/updated-policy.json"); + } + _ => panic!("Expected Update action"), //#[allow_ci] + } + } + + #[test] + fn test_delete_action() { + let action = PolicyAction::Delete { + name: "test-policy".to_string(), + }; + + match action { + PolicyAction::Delete { name } => { + assert_eq!(name, "test-policy"); + } + _ => panic!("Expected Delete action"), //#[allow_ci] + } + } + } + + // Test policy file validation + mod policy_validation { + use super::*; + + #[test] + fn test_valid_allowlist_structure() { + let policy = json!({ + "allowlist": [ + { + "path": "/bin/ls", + "hash": "sha256:abc123" + }, + { + "path": "/usr/bin/cat", + "hash": "sha256:def456" + } + ] + }); + + // Verify policy structure + assert!(policy["allowlist"].is_array()); + let allowlist = policy["allowlist"].as_array().unwrap(); //#[allow_ci] + assert_eq!(allowlist.len(), 2); + assert_eq!(allowlist[0]["path"], "/bin/ls"); + assert_eq!(allowlist[1]["hash"], "sha256:def456"); + } + + #[test] + fn test_exclude_patterns() { + let policy = json!({ + "exclude": [ + "/tmp/*", + "/var/log/*", + "/proc/*", + "/sys/*", + "*.pyc", + "*.swp" + ] + }); + + let excludes = policy["exclude"].as_array().unwrap(); //#[allow_ci] + assert_eq!(excludes.len(), 6); + assert_eq!(excludes[0], "/tmp/*"); + assert_eq!(excludes[4], "*.pyc"); + } + + #[test] + fn test_ima_configuration() { + let policy = json!({ + "ima": { + "require_signatures": true, + "allowed_keyrings": ["builtin_trusted_keys", "_ima", "custom_keyring"], + "fail_action": "log", + "hash_algorithm": "sha256" + } + }); + + let ima = policy["ima"].as_object().unwrap(); //#[allow_ci] + assert_eq!(ima["require_signatures"], true); + assert_eq!(ima["fail_action"], "log"); + assert_eq!(ima["hash_algorithm"], "sha256"); + + let keyrings = ima["allowed_keyrings"].as_array().unwrap(); //#[allow_ci] + assert_eq!(keyrings.len(), 3); + assert!(keyrings.contains(&json!("builtin_trusted_keys"))); + } + + #[test] + fn test_complex_policy_structure() { + let policy = json!({ + "allowlist": [ + { + "path": "/usr/bin/python3", + "hash": "sha256:python_hash", + "flags": ["executable"] + } + ], + "exclude": ["/tmp/*"], + "ima": { + "require_signatures": false, + "allowed_keyrings": ["_ima"] + }, + "meta": { + "version": "2.1", + "description": "Production policy for Python applications", + "environment": "production" + } + }); + + // Verify all sections exist + assert!(policy["allowlist"].is_array()); + assert!(policy["exclude"].is_array()); + assert!(policy["ima"].is_object()); + assert!(policy["meta"].is_object()); + + // Verify specific values + assert_eq!(policy["meta"]["version"], "2.1"); + assert_eq!(policy["ima"]["require_signatures"], false); + } + } + + // Test JSON response structures + mod json_responses { + use super::*; + + #[test] + fn test_success_response_structure() { + let response = json!({ + "status": "success", + "message": "Runtime policy 'test-policy' pushed successfully", + "policy_name": "test-policy", + "results": { + "verifier_response": "OK", + "policy_id": "12345" + } + }); + + assert_eq!(response["status"], "success"); + assert_eq!(response["policy_name"], "test-policy"); + assert!(response["results"].is_object()); + assert!(response["message"] + .as_str() + .unwrap() //#[allow_ci] + .contains("pushed successfully")); + } + + #[test] + fn test_policy_show_response() { + let response = json!({ + "policy_name": "web-server-policy", + "results": { + "policy": { + "allowlist": [ + { + "path": "/usr/bin/nginx", + "hash": "sha256:nginx_hash" + } + ], + "exclude": ["/var/log/*"], + "ima": { + "require_signatures": true + } + }, + "metadata": { + "created": "2025-01-01T12:00:00Z", + "last_modified": "2025-01-02T14:30:00Z" + } + } + }); + + assert_eq!(response["policy_name"], "web-server-policy"); + assert!(response["results"]["policy"].is_object()); + assert!(response["results"]["metadata"].is_object()); + } + + #[test] + fn test_error_response_structure() { + let error = KeylimectlError::policy_not_found("missing-policy"); + let error_json = error.to_json(); + + assert_eq!(error_json["error"]["code"], "POLICY_NOT_FOUND"); + assert_eq!( + error_json["error"]["details"]["policy_name"], + "missing-policy" + ); + } + } + + // Test error handling scenarios + mod error_handling { + use super::*; + + #[test] + fn test_policy_not_found_error() { + let error = + KeylimectlError::policy_not_found("nonexistent-policy"); + + match &error { + KeylimectlError::PolicyNotFound { name } => { + assert_eq!(name, "nonexistent-policy"); + } + _ => panic!("Expected PolicyNotFound error"), //#[allow_ci] + } + + assert_eq!(error.error_code(), "POLICY_NOT_FOUND"); + assert!(!error.is_retryable()); + } + + #[test] + fn test_validation_error() { + let error = KeylimectlError::validation("Invalid policy format"); + + assert_eq!(error.error_code(), "VALIDATION_ERROR"); + assert!(!error.is_retryable()); + assert!(error.to_string().contains("Invalid policy format")); + } + + #[test] + fn test_io_error_context() { + use crate::error::ErrorContext; + + let io_error: Result<(), std::io::Error> = + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "file not found", + )); + + let contextual_error = io_error.with_context(|| { + "Failed to read runtime policy file".to_string() + }); + + assert!(contextual_error.is_err()); + let error = contextual_error.unwrap_err(); + assert_eq!(error.error_code(), "GENERIC_ERROR"); + } + } + + // Test file operations + mod file_operations { + use super::*; + + #[test] + fn test_read_valid_policy_file() { + let policy_file = create_test_policy_file() + .expect("Failed to create test file"); + let file_path = policy_file.path().to_str().unwrap(); //#[allow_ci] + + // Test reading the file + let content = + fs::read_to_string(file_path).expect("Failed to read file"); + assert!(!content.is_empty()); + + // Test parsing the content + let parsed: Value = + serde_json::from_str(&content).expect("Failed to parse JSON"); + assert!(parsed.is_object()); + } + + #[test] + fn test_read_invalid_policy_file() { + let invalid_file = create_invalid_policy_file() + .expect("Failed to create invalid file"); + let file_path = invalid_file.path().to_str().unwrap(); //#[allow_ci] + + // Test reading the file succeeds + let content = + fs::read_to_string(file_path).expect("Failed to read file"); + assert!(!content.is_empty()); + + // Test parsing the content fails + let parse_result: Result = + serde_json::from_str(&content); + assert!(parse_result.is_err()); + } + + #[test] + fn test_nonexistent_file() { + let nonexistent_path = "/path/that/does/not/exist/policy.json"; + let read_result = fs::read_to_string(nonexistent_path); + assert!(read_result.is_err()); + } + } + + // Test configuration validation + mod config_validation { + use super::*; + + #[test] + fn test_config_validation_success() { + let config = create_test_config(); + let result = config.validate(); + assert!(result.is_ok(), "Test config should be valid"); + } + + #[test] + fn test_verifier_url_construction() { + let config = create_test_config(); + assert_eq!(config.verifier_base_url(), "https://127.0.0.1:8881"); + } + + #[test] + fn test_config_with_different_ports() { + let mut config = create_test_config(); + config.verifier.port = 9001; + + assert_eq!(config.verifier_base_url(), "https://127.0.0.1:9001"); + } + } + + // Test base64 encoding of policy data + mod base64_encoding { + #[test] + fn test_policy_content_is_base64_encoded() { + use base64::{ + engine::general_purpose::STANDARD as Base64, Engine, + }; + + let policy_content = r#"{"allowlist": [{"path": "/bin/ls"}]}"#; + let encoded_policy = Base64.encode(policy_content.as_bytes()); + + // Verify it's base64 encoded + assert!(!encoded_policy.contains("{")); + assert!(!encoded_policy.contains("}")); + assert!(!encoded_policy.contains("allowlist")); + + // Verify it can be decoded back + let decoded = Base64.decode(&encoded_policy).unwrap(); //#[allow_ci] + let decoded_str = String::from_utf8(decoded).unwrap(); //#[allow_ci] + assert_eq!(decoded_str, policy_content); + } + + #[test] + fn test_base64_roundtrip() { + use base64::{ + engine::general_purpose::STANDARD as Base64, Engine, + }; + + let original = r#"{"ima": {"require_signatures": true}}"#; + let encoded = Base64.encode(original.as_bytes()); + let decoded = Base64.decode(&encoded).unwrap(); //#[allow_ci] + let result = String::from_utf8(decoded).unwrap(); //#[allow_ci] + + assert_eq!(original, result); + } + } + + // Test runtime policy specific scenarios + mod runtime_policy_scenarios { + + #[test] + fn test_policy_name_validation() { + // Test valid policy names + let valid_names = [ + "production-policy", + "dev_environment", + "policy123", + "web-server-v2", + "minimal", + ]; + + for name in &valid_names { + // Policy names should be non-empty strings + assert!(!name.is_empty()); + assert!(name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_')); + } + } + + #[test] + fn test_hash_formats() { + // Test different hash formats used in allowlists + let hash_formats = [ + "sha1:da39a3ee5e6b4b0d3255bfef95601890afd80709", + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "sha384:38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b", + "md5:d41d8cd98f00b204e9800998ecf8427e", + ]; + + for hash_format in &hash_formats { + assert!(hash_format.contains(':')); + let parts: Vec<&str> = hash_format.split(':').collect(); + assert_eq!(parts.len(), 2); + + let algorithm = parts[0]; + let hash_value = parts[1]; + + assert!(!algorithm.is_empty()); + assert!(!hash_value.is_empty()); + assert!(hash_value.chars().all(|c| c.is_ascii_hexdigit())); + } + } + + #[test] + fn test_path_patterns() { + // Test different path patterns used in allowlists and excludes + let path_patterns = [ + "/usr/bin/bash", + "/lib/x86_64-linux-gnu/libc.so.6", + "/tmp/*", + "/var/cache/*", + "*.pyc", + "*.tmp", + "/proc/*/stat", + "/sys/devices/*/*", + ]; + + for pattern in &path_patterns { + assert!(!pattern.is_empty()); + // All patterns should start with / or * + assert!(pattern.starts_with('/') || pattern.starts_with('*')); + } + } + + #[test] + fn test_ima_keyring_names() { + // Test valid IMA keyring names + let keyring_names = [ + "builtin_trusted_keys", + "_ima", + "_evm", + "custom_keyring", + "platform_keyring", + ]; + + for keyring in &keyring_names { + assert!(!keyring.is_empty()); + // Keyring names should contain only alphanumeric, underscore, or hyphen + assert!(keyring + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-')); + } + } + } +} diff --git a/keylimectl/src/commands/policy/generate.rs b/keylimectl/src/commands/policy/generate.rs new file mode 100644 index 000000000..1fa392e08 --- /dev/null +++ b/keylimectl/src/commands/policy/generate.rs @@ -0,0 +1,802 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Local policy generation commands. +//! +//! Generates runtime, measured boot, and TPM policies from local +//! input sources without requiring network connectivity. + +use crate::commands::error::CommandError; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use crate::policy_tools::filesystem; +use crate::policy_tools::ima_parser; +use crate::policy_tools::initrd; +use crate::policy_tools::measured_boot_gen; +use crate::policy_tools::privilege; +use crate::policy_tools::runtime_policy::RuntimePolicy; +use crate::policy_tools::tpm_policy_gen; +use crate::GenerateSubcommand; +use serde_json::Value; +use std::path::Path; + +/// Execute a policy generation subcommand. +pub async fn execute( + subcommand: &GenerateSubcommand, + output: &OutputHandler, +) -> Result { + match subcommand { + GenerateSubcommand::Runtime { + #[cfg(feature = "wizard")] + interactive, + ima_measurement_list, + allowlist, + rootfs, + skip_path, + base_policy, + excludelist, + output: output_file, + keyrings, + ima_buf, + ignored_keyrings, + add_ima_signature_verification_key, + hash_alg, + ramdisk_dir, + #[cfg(feature = "rpm-repo")] + local_rpm_repo, + #[cfg(feature = "rpm-repo")] + remote_rpm_repo, + #[cfg(feature = "rpm-repo")] + gpg_key, + #[cfg(feature = "rpm-repo")] + allow_unsigned_repo, + } => { + #[cfg(feature = "wizard")] + if *interactive { + let defaults = super::wizard_runtime::Defaults { + ima_measurement_list: ima_measurement_list.as_deref(), + allowlist: allowlist.as_deref(), + rootfs: rootfs.as_deref(), + skip_path, + base_policy: base_policy.as_deref(), + excludelist: excludelist.as_deref(), + output_file: output_file.as_deref(), + keyrings: *keyrings, + ima_buf: *ima_buf, + ignored_keyrings, + hash_alg: hash_alg.as_deref(), + ramdisk_dir: ramdisk_dir.as_deref(), + #[cfg(feature = "rpm-repo")] + local_rpm_repo: local_rpm_repo.as_deref(), + #[cfg(feature = "rpm-repo")] + remote_rpm_repo: remote_rpm_repo.as_deref(), + #[cfg(feature = "rpm-repo")] + gpg_key: gpg_key.as_deref(), + add_ima_signature_verification_key, + }; + return super::wizard_runtime::run(&defaults, output).await; + } + generate_runtime( + ima_measurement_list.as_deref(), + allowlist.as_deref(), + rootfs.as_deref(), + skip_path, + base_policy.as_deref(), + excludelist.as_deref(), + output_file.as_deref(), + *keyrings, + *ima_buf, + ignored_keyrings, + hash_alg.as_deref(), + ramdisk_dir.as_deref(), + #[cfg(feature = "rpm-repo")] + local_rpm_repo.as_deref(), + #[cfg(not(feature = "rpm-repo"))] + None, + #[cfg(feature = "rpm-repo")] + remote_rpm_repo.as_deref(), + #[cfg(not(feature = "rpm-repo"))] + None, + #[cfg(feature = "rpm-repo")] + gpg_key.as_deref(), + #[cfg(not(feature = "rpm-repo"))] + None, + #[cfg(feature = "rpm-repo")] + *allow_unsigned_repo, + #[cfg(not(feature = "rpm-repo"))] + false, + add_ima_signature_verification_key, + output, + ) + .await + .map_err(KeylimectlError::from) + } + GenerateSubcommand::MeasuredBoot { + #[cfg(feature = "wizard")] + interactive, + eventlog_file, + without_secureboot, + output: output_file, + } => { + #[cfg(feature = "wizard")] + if *interactive { + let defaults = super::wizard_measured_boot::Defaults { + eventlog_file, + without_secureboot: *without_secureboot, + output_file: output_file.as_deref(), + }; + return super::wizard_measured_boot::run(&defaults, output); + } + generate_measured_boot( + eventlog_file, + *without_secureboot, + output_file.as_deref(), + output, + ) + .map_err(KeylimectlError::from) + } + GenerateSubcommand::Tpm { + #[cfg(feature = "wizard")] + interactive, + pcr_file, + #[cfg(any( + feature = "tpm-local", + feature = "tpm-quote-validation" + ))] + from_tpm, + pcrs, + mask, + hash_alg, + output: output_file, + } => { + #[cfg(feature = "wizard")] + if *interactive { + use crate::policy_tools::tpm_policy_gen; + let pcr_indices = if let Some(mask_str) = mask.as_deref() { + crate::policy_tools::tpm_policy::TpmPolicy::parse_mask( + mask_str, + ) + .unwrap_or_else(|_| vec![0, 1, 2, 3, 4, 5, 6, 7]) + } else { + tpm_policy_gen::parse_pcr_indices(pcrs) + .unwrap_or_else(|_| vec![0, 1, 2, 3, 4, 5, 6, 7]) + }; + let defaults = super::wizard_tpm::Defaults { + pcr_file: pcr_file.as_deref(), + #[cfg(any( + feature = "tpm-local", + feature = "tpm-quote-validation" + ))] + from_tpm: *from_tpm, + #[cfg(not(any( + feature = "tpm-local", + feature = "tpm-quote-validation" + )))] + from_tpm: false, + pcr_indices, + hash_alg, + output_file: output_file.as_deref(), + }; + return super::wizard_tpm::run(&defaults, output); + } + generate_tpm( + pcr_file.as_deref(), + #[cfg(any( + feature = "tpm-local", + feature = "tpm-quote-validation" + ))] + *from_tpm, + #[cfg(not(any( + feature = "tpm-local", + feature = "tpm-quote-validation" + )))] + false, + pcrs, + mask.as_deref(), + hash_alg, + output_file.as_deref(), + output, + ) + .map_err(KeylimectlError::from) + } + } +} + +/// Generate a runtime policy from IMA logs, allowlists, and other sources. +#[allow(clippy::too_many_arguments)] +pub(super) async fn generate_runtime( + ima_measurement_list: Option<&str>, + allowlist: Option<&str>, + rootfs: Option<&str>, + skip_path: &[String], + base_policy: Option<&str>, + excludelist: Option<&str>, + output_file: Option<&str>, + get_keyrings: bool, + get_ima_buf: bool, + ignored_keyrings: &[String], + hash_alg: Option<&str>, + ramdisk_dir: Option<&str>, + local_rpm_repo: Option<&str>, + remote_rpm_repo: Option<&str>, + gpg_key: Option<&str>, + allow_unsigned_repo: bool, + add_ima_signature_verification_key: &[String], + output: &OutputHandler, +) -> Result { + let mut policy = if let Some(base_path) = base_policy { + load_base_policy(base_path)? + } else { + RuntimePolicy::new() + }; + + let mut detected_algorithm: Option = hash_alg.map(String::from); + let mut detected_log_hash_alg: Option = None; + + // Parse IMA measurement list + if let Some(ima_path) = ima_measurement_list { + let path = Path::new(ima_path); + if path.exists() { + privilege::check_file_readable( + path, + &format!("policy generate runtime --ima-measurement-list {ima_path}"), + )?; + output.info(format!("Parsing IMA measurement list: {ima_path}")); + + let ima_data = ima_parser::parse_ima_measurement_list( + path, + get_keyrings, + get_ima_buf, + ignored_keyrings, + )?; + + // Use detected algorithm if not explicitly specified + if detected_algorithm.is_none() { + detected_algorithm = ima_data.detected_algorithm; + } + + // Detect the IMA template hash algorithm (log_hash_alg) + // from the template hash field, which may differ from the + // file digest algorithm + if detected_log_hash_alg.is_none() { + detected_log_hash_alg = ima_data.detected_log_hash_alg; + } + + // Merge digests + for (file_path, digests) in &ima_data.digests { + for digest in digests { + policy.add_digest(file_path.clone(), digest.clone()); + } + } + + // Merge keyrings + for (keyring, digests) in &ima_data.keyrings { + for digest in digests { + policy.add_keyring(keyring.clone(), digest.clone()); + } + } + + // Merge ima-buf + for (name, digests) in &ima_data.ima_buf { + for digest in digests { + policy.add_ima_buf(name.clone(), digest.clone()); + } + } + + output.info(format!( + "Extracted {} file digests from IMA log", + ima_data.digests.len() + )); + } else { + return Err(CommandError::invalid_parameter( + "ima_measurement_list", + format!("IMA measurement list file not found: {ima_path}"), + )); + } + } + + // Parse allowlist + if let Some(allowlist_path) = allowlist { + let path = Path::new(allowlist_path); + privilege::check_file_readable( + path, + &format!("policy generate runtime --allowlist {allowlist_path}"), + )?; + output.info(format!("Parsing allowlist: {allowlist_path}")); + + // Auto-detect format: try JSON first, fall back to flat text + let allowlist_digests = if allowlist_path.ends_with(".json") { + ima_parser::parse_json_allowlist(path)? + } else { + match ima_parser::parse_json_allowlist(path) { + Ok(d) => d, + Err(_) => ima_parser::parse_flat_allowlist(path)?, + } + }; + + for (file_path, digests) in &allowlist_digests { + for digest in digests { + policy.add_digest(file_path.clone(), digest.clone()); + } + } + + output.info(format!( + "Loaded {} entries from allowlist", + allowlist_digests.len() + )); + } + + // Scan filesystem + if let Some(rootfs_path) = rootfs { + let algorithm = detected_algorithm.as_deref().unwrap_or("sha256"); + + let root = Path::new(rootfs_path); + + // Merge user-provided skip paths with built-in defaults. + let (mut effective_skip, redundant) = + filesystem::build_effective_skip_paths(root, skip_path); + + // Auto-detect non-root mount points and skip them during scanning. + // This is best-effort: /proc/mounts may not exist in containers. + match filesystem::detect_non_root_mounts() { + Ok(mounts) if !mounts.is_empty() => { + output.info(format!( + "Auto-excluding non-root mount points: {}", + mounts.join(", ") + )); + for mount in mounts { + let rel = mount.trim_start_matches('/'); + let mount_path = + root.join(rel).to_string_lossy().into_owned(); + effective_skip.push(mount_path); + } + } + Ok(_) => {} + Err(e) => { + output.info(format!( + "Warning: Could not detect non-root mounts: {e}" + )); + } + } + + // Inform the user about default excluded paths. + output.info(format!( + "Default excluded directories (volatile/virtual data): {}", + filesystem::BASE_EXCLUDE_DIRS.join(", ") + )); + + // Warn about user-provided paths already covered by defaults. + for path in &redundant { + output.info(format!( + "Note: --skip-path '{path}' is already excluded by default (no additional effect)" + )); + } + + output.info(format!( + "Scanning filesystem: {rootfs_path} (algorithm: {algorithm})" + )); + + let fs_digests = tokio::task::spawn_blocking({ + let root = root.to_path_buf(); + let alg = algorithm.to_string(); + move || { + filesystem::scan_filesystem( + &root, &effective_skip, &alg, + ) + } + }) + .await + .map_err(|e| { + CommandError::from( + crate::commands::error::PolicyGenerationError::FilesystemScan { + path: root.to_path_buf(), + reason: format!("Task join error: {e}"), + }, + ) + })??; + + for (file_path, digests) in &fs_digests { + for digest in digests { + policy.add_digest(file_path.clone(), digest.clone()); + } + } + + output.info(format!( + "Scanned {} files from filesystem", + fs_digests.len() + )); + } + + // Extract initramfs digests + if let Some(ramdisk_path) = ramdisk_dir { + let algorithm = detected_algorithm.as_deref().unwrap_or("sha256"); + + output + .info(format!("Extracting initramfs files from: {ramdisk_path}")); + + let ramdisk_dir_path = std::path::PathBuf::from(ramdisk_path); + + // Check read access (may require root for /boot) + privilege::check_dir_readable( + &ramdisk_dir_path, + &format!("policy generate runtime --ramdisk-dir {ramdisk_path}"), + )?; + + let initrd_digests = tokio::task::spawn_blocking({ + let alg = algorithm.to_string(); + move || initrd::process_ramdisk_dir(&ramdisk_dir_path, &alg) + }) + .await + .map_err(|e| { + CommandError::from( + crate::commands::error::PolicyGenerationError::Output { + path: std::path::PathBuf::from(ramdisk_path), + reason: format!("Task join error: {e}"), + }, + ) + })??; + + for (file_path, digests) in &initrd_digests { + for digest in digests { + policy.add_digest(file_path.clone(), digest.clone()); + } + } + + output.info(format!( + "Extracted {} file digests from initramfs", + initrd_digests.len() + )); + } + + // Analyze local RPM repository + if let Some(rpm_dir) = local_rpm_repo { + #[cfg(feature = "rpm-repo")] + { + use crate::policy_tools::rpm_repo; + + output.info(format!("Analyzing local RPM repository: {rpm_dir}")); + + let rpm_dir_path = std::path::PathBuf::from(rpm_dir); + + privilege::check_dir_readable( + &rpm_dir_path, + &format!( + "policy generate runtime --local-rpm-repo {rpm_dir}" + ), + )?; + + let gpg_key_path = gpg_key.map(std::path::PathBuf::from); + let rpm_digests = tokio::task::spawn_blocking({ + move || { + rpm_repo::analyze_local_repo( + &rpm_dir_path, + gpg_key_path.as_deref(), + ) + } + }) + .await + .map_err(|e| { + CommandError::from( + crate::commands::error::PolicyGenerationError::RpmParse { + path: std::path::PathBuf::from(rpm_dir), + reason: format!("Task join error: {e}"), + }, + ) + })??; + + for (file_path, digests) in &rpm_digests { + for digest in digests { + policy.add_digest(file_path.clone(), digest.clone()); + } + } + + output.info(format!( + "Extracted {} file digests from local RPM repository", + rpm_digests.len() + )); + } + + #[cfg(not(feature = "rpm-repo"))] + { + let _ = rpm_dir; + let _ = gpg_key; + return Err(CommandError::from( + crate::commands::error::PolicyGenerationError::UnsupportedAlgorithm { + algorithm: "--local-rpm-repo requires the 'rpm-repo' feature flag. \ + Rebuild with: cargo build --features rpm-repo".to_string(), + }, + )); + } + } + + // Analyze remote RPM repository + if let Some(rpm_url) = remote_rpm_repo { + #[cfg(feature = "rpm-repo")] + { + use crate::policy_tools::rpm_repo; + + output + .info(format!("Analyzing remote RPM repository: {rpm_url}")); + + let gpg_key_path = gpg_key.map(Path::new); + let rpm_digests = rpm_repo::analyze_remote_repo( + rpm_url, + gpg_key_path, + allow_unsigned_repo, + ) + .await?; + + for (file_path, digests) in &rpm_digests { + for digest in digests { + policy.add_digest(file_path.clone(), digest.clone()); + } + } + + output.info(format!( + "Extracted {} file digests from remote RPM repository", + rpm_digests.len() + )); + } + + #[cfg(not(feature = "rpm-repo"))] + { + let _ = rpm_url; + let _ = gpg_key; + let _ = allow_unsigned_repo; + return Err(CommandError::from( + crate::commands::error::PolicyGenerationError::UnsupportedAlgorithm { + algorithm: "--remote-rpm-repo requires the 'rpm-repo' feature flag. \ + Rebuild with: cargo build --features rpm-repo".to_string(), + }, + )); + } + } + + // Parse exclude list + if let Some(excludelist_path) = excludelist { + let path = Path::new(excludelist_path); + output.info(format!("Parsing exclude list: {excludelist_path}")); + + let excludes = ima_parser::parse_excludelist(path)?; + for pattern in &excludes { + policy.add_exclude(pattern.clone()); + } + + output.info(format!("Loaded {} exclude patterns", excludes.len())); + } + + // Set ignored keyrings + for keyring in ignored_keyrings { + policy.add_ignored_keyring(keyring.clone()); + } + + // Set the IMA template hash algorithm (log_hash_alg). + // This is the algorithm used by IMA to hash template data, determined + // from the template hash field length in the IMA log. It may differ + // from the file digest algorithm (e.g., sha1 template hashes with + // sha256 file digests is common when ima_hash=sha1 is used). + if let Some(alg) = &detected_log_hash_alg { + policy.set_log_hash_alg(alg.clone()); + } + + // Add IMA signature verification keys + for key_path in add_ima_signature_verification_key { + use crate::policy_tools::conversion; + conversion::add_verification_keys(&mut policy, key_path)?; + output.info(format!("Added verification keys from '{key_path}'")); + } + + // Serialize the policy + let policy_json = serde_json::to_value(&policy)?; + + // Write to file or return for stdout display + if let Some(out_path) = output_file { + let json_str = serde_json::to_string_pretty(&policy_json)?; + std::fs::write(out_path, &json_str).map_err(|e| { + CommandError::from( + crate::commands::error::PolicyGenerationError::Output { + path: std::path::PathBuf::from(out_path), + reason: e.to_string(), + }, + ) + })?; + output.info(format!("Policy written to {out_path}")); + output.info(format!( + "Policy contains {} file digests, {} exclude patterns", + policy.digest_count(), + policy.exclude_count() + )); + } + + Ok(policy_json) +} + +/// Generate a measured boot policy from a UEFI event log. +pub(super) fn generate_measured_boot( + eventlog_file: &str, + without_secureboot: bool, + output_file: Option<&str>, + output: &OutputHandler, +) -> Result { + let path = Path::new(eventlog_file); + output.info(format!("Parsing UEFI event log: {eventlog_file}")); + + let include_secureboot = !without_secureboot; + let policy = + measured_boot_gen::generate_from_eventlog(path, include_secureboot)?; + + output.info(format!( + "Generated measured boot policy (secureboot: {})", + if include_secureboot { "yes" } else { "no" } + )); + output.info(format!( + " PK entries: {}, KEK entries: {}, db entries: {}, dbx entries: {}", + policy.pk.len(), + policy.kek.len(), + policy.db.len(), + policy.dbx.len() + )); + output.info(format!( + " Kernel entries: {}, S-CRTM/BIOS entries: {}", + policy.kernels.len(), + policy.scrtm_and_bios.len() + )); + output.info(format!( + " MOK digests: {}, MOKx digests: {}, vendor_db entries: {}", + policy.mokdig.len(), + policy.mokxdig.len(), + policy.vendor_db.len() + )); + + let policy_json = serde_json::to_value(&policy)?; + + if let Some(out_path) = output_file { + let json_str = serde_json::to_string_pretty(&policy_json)?; + std::fs::write(out_path, &json_str)?; + output.info(format!("Measured boot policy written to {out_path}")); + } + + Ok(policy_json) +} + +/// Generate a TPM policy from PCR values. +pub(super) fn generate_tpm( + pcr_file: Option<&str>, + from_tpm: bool, + pcrs_str: &str, + mask: Option<&str>, + hash_alg: &str, + output_file: Option<&str>, + output: &OutputHandler, +) -> Result { + if from_tpm { + // Determine PCR indices first (needed for both paths) + let pcr_indices = if let Some(mask_str) = mask { + crate::policy_tools::tpm_policy::TpmPolicy::parse_mask(mask_str) + .map_err(|e| { + CommandError::from( + crate::commands::error::PolicyGenerationError::Output { + path: "".into(), + reason: e, + }, + ) + })? + } else { + tpm_policy_gen::parse_pcr_indices(pcrs_str)? + }; + + #[cfg(any(feature = "tpm-local", feature = "tpm-quote-validation"))] + { + output.info(format!( + "Reading PCR values from local TPM (algorithm: {hash_alg})" + )); + output.info(format!("PCR indices: {:?}", pcr_indices)); + + let policy = + tpm_policy_gen::generate_from_tpm(&pcr_indices, hash_alg)?; + + output.info(format!( + "Generated TPM policy with mask: {}", + policy.mask + )); + output.info(format!(" {} PCR values", policy.pcr_values.len())); + + let policy_json = serde_json::to_value(&policy)?; + + if let Some(out_path) = output_file { + let json_str = serde_json::to_string_pretty(&policy_json)?; + std::fs::write(out_path, &json_str)?; + output.info(format!("TPM policy written to {out_path}")); + } + + return Ok(policy_json); + } + + #[cfg(not(any( + feature = "tpm-local", + feature = "tpm-quote-validation" + )))] + { + // Suppress unused variable warnings + let _ = (hash_alg, pcr_indices); + return Err(CommandError::from( + crate::commands::error::PolicyGenerationError::UnsupportedAlgorithm { + algorithm: "Reading from local TPM requires the 'tpm-local' or 'tpm-quote-validation' feature flag. \ + Rebuild with: cargo build --features tpm-local".to_string(), + }, + )); + } + } + + let pcr_file = pcr_file.ok_or_else(|| { + CommandError::from( + crate::commands::error::PolicyGenerationError::Output { + path: "".into(), + reason: "Either --pcr-file or --from-tpm is required" + .to_string(), + }, + ) + })?; + + // Determine PCR indices from mask or pcrs argument + let pcr_indices = if let Some(mask_str) = mask { + crate::policy_tools::tpm_policy::TpmPolicy::parse_mask(mask_str) + .map_err(|e| { + CommandError::from( + crate::commands::error::PolicyGenerationError::Output { + path: "".into(), + reason: e, + }, + ) + })? + } else { + tpm_policy_gen::parse_pcr_indices(pcrs_str)? + }; + + output.info(format!("Reading PCR values from: {pcr_file}")); + output.info(format!("PCR indices: {:?}", pcr_indices)); + + let policy = tpm_policy_gen::generate_from_file( + Path::new(pcr_file), + &pcr_indices, + )?; + + output.info(format!("Generated TPM policy with mask: {}", policy.mask)); + output.info(format!(" {} PCR values", policy.pcr_values.len())); + + let policy_json = serde_json::to_value(&policy)?; + + if let Some(out_path) = output_file { + let json_str = serde_json::to_string_pretty(&policy_json)?; + std::fs::write(out_path, &json_str)?; + output.info(format!("TPM policy written to {out_path}")); + } + + Ok(policy_json) +} + +/// Load a base policy from a JSON file. +fn load_base_policy(path: &str) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| { + CommandError::from( + crate::commands::error::PolicyGenerationError::AllowlistParse { + path: std::path::PathBuf::from(path), + reason: format!("Failed to read base policy: {e}"), + }, + ) + })?; + + let policy: RuntimePolicy = + serde_json::from_str(&content).map_err(|e| { + CommandError::from( + crate::commands::error::PolicyGenerationError::AllowlistParse { + path: std::path::PathBuf::from(path), + reason: format!( + "Failed to parse base policy: {e}" + ), + }, + ) + })?; + + Ok(policy) +} diff --git a/keylimectl/src/commands/policy/merge.rs b/keylimectl/src/commands/policy/merge.rs new file mode 100644 index 000000000..8b8f40975 --- /dev/null +++ b/keylimectl/src/commands/policy/merge.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Policy merge command — combines two runtime policies into one. + +use crate::commands::error::CommandError; +use crate::output::OutputHandler; +use crate::policy_tools::merge::merge_policies; +use crate::policy_tools::runtime_policy::RuntimePolicy; +use serde_json::Value; + +/// Execute the `policy merge` command. +pub async fn execute( + base: &str, + other: &str, + output_file: Option<&str>, + output: &OutputHandler, +) -> Result { + // Read and parse base policy + let base_content = std::fs::read_to_string(base).map_err(|e| { + CommandError::InvalidParameter { + parameter: "base".to_string(), + reason: format!("Failed to read base policy '{base}': {e}"), + } + })?; + let base_policy: RuntimePolicy = serde_json::from_str(&base_content) + .map_err(|e| CommandError::InvalidParameter { + parameter: "base".to_string(), + reason: format!( + "Base policy '{base}' is not a valid runtime policy: {e}" + ), + })?; + + // Read and parse other policy + let other_content = std::fs::read_to_string(other).map_err(|e| { + CommandError::InvalidParameter { + parameter: "other".to_string(), + reason: format!("Failed to read policy '{other}': {e}"), + } + })?; + let other_policy: RuntimePolicy = serde_json::from_str(&other_content) + .map_err(|e| CommandError::InvalidParameter { + parameter: "other".to_string(), + reason: format!( + "Policy '{other}' is not a valid runtime policy: {e}" + ), + })?; + + output.info(format!( + "Merging: {} ({} digests) + {} ({} digests)", + base, + base_policy.digest_count(), + other, + other_policy.digest_count(), + )); + + let merged = merge_policies(&base_policy, &other_policy); + + output.info(format!( + "Merged policy: {} digests, {} excludes", + merged.digest_count(), + merged.exclude_count(), + )); + + let merged_json = serde_json::to_value(&merged)?; + + if let Some(out_path) = output_file { + let json_str = serde_json::to_string_pretty(&merged_json)?; + std::fs::write(out_path, &json_str).map_err(|e| { + CommandError::InvalidParameter { + parameter: "output".to_string(), + reason: format!("Failed to write merged policy: {e}"), + } + })?; + output.info(format!("Merged policy written to {out_path}")); + } + + Ok(merged_json) +} diff --git a/keylimectl/src/commands/policy/mod.rs b/keylimectl/src/commands/policy/mod.rs new file mode 100644 index 000000000..f61ac7807 --- /dev/null +++ b/keylimectl/src/commands/policy/mod.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Policy management commands for keylimectl. +//! +//! This module provides both verifier-side policy CRUD operations and +//! local policy tools (generation, signing, validation, conversion). + +mod convert; +mod crud; +mod generate; +mod merge; +mod sign; +mod validate; +#[cfg(feature = "wizard")] +mod wizard_measured_boot; +#[cfg(feature = "wizard")] +mod wizard_runtime; +#[cfg(feature = "wizard")] +mod wizard_tpm; + +use crate::client::factory; +use crate::error::{ErrorContext, KeylimectlError}; +use crate::output::OutputHandler; +use crate::PolicyAction; +use serde_json::Value; + +/// Execute a policy command. +pub async fn execute( + action: &PolicyAction, + output: &OutputHandler, +) -> Result { + match action { + // List runtime policies + PolicyAction::List => list_runtime_policies(output).await, + + // Verifier-side CRUD operations + PolicyAction::Push { .. } + | PolicyAction::Show { .. } + | PolicyAction::Update { .. } + | PolicyAction::Delete { .. } => crud::execute(action, output).await, + + // Local policy generation + PolicyAction::Generate { subcommand } => { + generate::execute(subcommand, output).await + } + + // Policy signing + PolicyAction::Sign { + file, + keyfile, + keypath, + backend, + output: output_file, + cert_file, + cert_outfile, + } => { + sign::execute( + file, + keyfile.as_deref(), + keypath.as_deref(), + backend, + output_file.as_deref(), + cert_file.as_deref(), + cert_outfile.as_deref(), + output, + ) + .await + } + + // Signature verification + PolicyAction::VerifySignature { file, key } => { + validate::verify_signature(file, key, output).await + } + + // Policy validation + PolicyAction::Validate { + file, + policy_type, + signature_key, + } => { + validate::execute( + file, + policy_type.as_deref(), + signature_key.as_deref(), + output, + ) + .await + } + + // Legacy policy conversion + PolicyAction::Convert { + file, + output: output_file, + excludelist, + verification_keys, + } => convert::execute( + file, + output_file, + excludelist.as_deref(), + verification_keys.as_deref(), + output, + ) + .await + .map_err(KeylimectlError::from), + + // Merge two runtime policies + PolicyAction::Merge { + base, + other, + output: output_file, + } => merge::execute(base, other, output_file.as_deref(), output) + .await + .map_err(KeylimectlError::from), + } +} + +/// List runtime policies from the verifier +async fn list_runtime_policies( + output: &OutputHandler, +) -> Result { + output.info("Listing runtime policies"); + + let verifier_client = factory::get_verifier().await?; + let policies = verifier_client + .list_runtime_policies() + .await + .with_context(|| { + "Failed to list runtime policies from verifier".to_string() + })?; + + Ok(policies) +} diff --git a/keylimectl/src/commands/policy/sign.rs b/keylimectl/src/commands/policy/sign.rs new file mode 100644 index 000000000..d9331323d --- /dev/null +++ b/keylimectl/src/commands/policy/sign.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Policy signing using DSSE (Dead Simple Signing Envelope). + +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use crate::policy_tools::dsse::{ + self, ecdsa_backend::EcdsaSigner, x509_backend::X509Signer, + KEYLIME_PAYLOAD_TYPE, +}; +use crate::policy_tools::privilege; +use crate::SigningBackend; +use serde_json::Value; + +/// Execute the policy sign command. +#[allow(clippy::too_many_arguments)] +pub async fn execute( + file: &str, + keyfile: Option<&str>, + keypath: Option<&str>, + backend: &SigningBackend, + output_file: Option<&str>, + cert_file: Option<&str>, + cert_outfile: Option<&str>, + output: &OutputHandler, +) -> Result { + // Check readability before opening — gives a sudo hint on EACCES. + privilege::check_file_readable( + std::path::Path::new(file), + &format!("policy sign {file}"), + ) + .map_err(|e| KeylimectlError::Command(e.into()))?; + + // Read the policy file + let policy_content = std::fs::read(file).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to read policy file '{file}': {e}" + )) + })?; + + // Validate it's valid JSON + let _: Value = serde_json::from_slice(&policy_content).map_err(|e| { + KeylimectlError::validation(format!( + "Policy file is not valid JSON: {e}" + )) + })?; + + // Create the signer based on backend + let envelope = match backend { + SigningBackend::Ecdsa => { + sign_ecdsa(&policy_content, keyfile, keypath, output)? + } + SigningBackend::X509 => sign_x509( + &policy_content, + keyfile, + keypath, + cert_file, + cert_outfile, + output, + )?, + }; + + let envelope_json = serde_json::to_value(&envelope)?; + + // Write to file or stdout + if let Some(out_path) = output_file { + let json_str = serde_json::to_string_pretty(&envelope_json)?; + std::fs::write(out_path, &json_str).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to write output file: {e}" + )) + })?; + output.info(format!("Signed policy written to {out_path}")); + } + + Ok(envelope_json) +} + +/// Sign using ECDSA backend. +fn sign_ecdsa( + payload: &[u8], + keyfile: Option<&str>, + keypath: Option<&str>, + output: &OutputHandler, +) -> Result { + let signer = if let Some(kf) = keyfile { + output.info(format!("Loading ECDSA key from {kf}")); + EcdsaSigner::from_pem_file(kf).map_err(|e| { + KeylimectlError::validation(format!("Failed to load key: {e}")) + })? + } else { + output.info("Generating new ECDSA P-256 key pair"); + let s = EcdsaSigner::generate().map_err(|e| { + KeylimectlError::validation(format!( + "Failed to generate key: {e}" + )) + })?; + + let key_path = keypath.unwrap_or("keylime-ecdsa-key.pem"); + s.save_private_key(key_path).map_err(|e| { + KeylimectlError::validation(format!("Failed to save key: {e}")) + })?; + output.info(format!("Private key saved to {key_path}")); + + // Save public key + let pub_path = format!("{key_path}.pub"); + std::fs::write(&pub_path, s.public_key_pem()).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to save public key: {e}" + )) + })?; + output.info(format!("Public key saved to {pub_path}")); + + s + }; + + dsse::sign_payload(payload, KEYLIME_PAYLOAD_TYPE, &signer).map_err(|e| { + KeylimectlError::validation(format!("Signing failed: {e}")) + }) +} + +/// Sign using X.509 backend. +fn sign_x509( + payload: &[u8], + keyfile: Option<&str>, + keypath: Option<&str>, + cert_file: Option<&str>, + cert_outfile: Option<&str>, + output: &OutputHandler, +) -> Result { + let signer = if let Some(kf) = keyfile { + let cert_path = cert_file.ok_or_else(|| { + KeylimectlError::validation( + "X.509 backend requires --cert-file when using --keyfile", + ) + })?; + output.info(format!( + "Loading key from {kf} and certificate from {cert_path}" + )); + let s = X509Signer::from_files(kf, cert_path).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to load key/cert: {e}" + )) + })?; + // Optionally export the loaded certificate to a different path. + if let Some(out_path) = cert_outfile { + std::fs::write(out_path, s.certificate_pem()).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to export certificate to '{out_path}': {e}" + )) + })?; + output.info(format!("Certificate exported to {out_path}")); + } + s + } else { + output.info( + "Generating new ECDSA P-256 key pair and X.509 certificate", + ); + + // Generate without writing cert internally; write via certificate_pem(). + let s = X509Signer::generate(None).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to generate key/cert: {e}" + )) + })?; + + let cert_path = cert_outfile.unwrap_or("keylime-cert.pem"); + std::fs::write(cert_path, s.certificate_pem()).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to write certificate to '{cert_path}': {e}" + )) + })?; + output.info(format!("Certificate saved to {cert_path}")); + + let key_path = keypath.unwrap_or("keylime-ecdsa-key.pem"); + s.save_private_key(key_path).map_err(|e| { + KeylimectlError::validation(format!("Failed to save key: {e}")) + })?; + output.info(format!("Private key saved to {key_path}")); + + s + }; + + dsse::sign_payload(payload, KEYLIME_PAYLOAD_TYPE, &signer).map_err(|e| { + KeylimectlError::validation(format!("Signing failed: {e}")) + }) +} diff --git a/keylimectl/src/commands/policy/validate.rs b/keylimectl/src/commands/policy/validate.rs new file mode 100644 index 000000000..0dbeaac91 --- /dev/null +++ b/keylimectl/src/commands/policy/validate.rs @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Policy validation and signature verification. + +use base64::Engine; + +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use crate::policy_tools::dsse::{ + self, ecdsa_backend::EcdsaVerifier, x509_backend::X509Verifier, + DsseEnvelope, Verifier, +}; +use crate::policy_tools::privilege; +use crate::policy_tools::validation::{self, ValidationResult}; +use serde_json::Value; + +/// Execute the policy validate command. +pub async fn execute( + file: &str, + policy_type: Option<&str>, + signature_key: Option<&str>, + output: &OutputHandler, +) -> Result { + // Check readability before opening — gives a sudo hint on EACCES. + privilege::check_file_readable( + std::path::Path::new(file), + &format!("policy validate {file}"), + ) + .map_err(|e| KeylimectlError::Command(e.into()))?; + + // Read the policy file + let content = std::fs::read_to_string(file).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to read policy file '{file}': {e}" + )) + })?; + + let mut json_value: Value = + serde_json::from_str(&content).map_err(|e| { + KeylimectlError::validation(format!( + "Policy file is not valid JSON: {e}" + )) + })?; + + // If this is a DSSE envelope, extract the inner payload + if json_value.get("payloadType").is_some() + && json_value.get("payload").is_some() + && json_value.get("signatures").is_some() + { + output.info("Detected DSSE envelope, extracting payload"); + let envelope: DsseEnvelope = serde_json::from_value(json_value) + .map_err(|e| { + KeylimectlError::validation(format!( + "Invalid DSSE envelope: {e}" + )) + })?; + let payload_bytes = base64::engine::general_purpose::STANDARD + .decode(&envelope.payload) + .map_err(|e| { + KeylimectlError::validation(format!( + "Failed to decode DSSE payload: {e}" + )) + })?; + json_value = serde_json::from_slice(&payload_bytes).map_err(|e| { + KeylimectlError::validation(format!( + "DSSE payload is not valid JSON: {e}" + )) + })?; + } + + // Determine policy type + let detected_type = + policy_type.or_else(|| validation::detect_policy_type(&json_value)); + + let policy_type_str = match detected_type { + Some(t) => t, + None => { + return Err(KeylimectlError::validation( + "Could not auto-detect policy type. Use --type to specify one of: runtime, measured-boot, tpm", + )); + } + }; + + output.info(format!("Validating {policy_type_str} policy from {file}")); + + // Validate based on type + let result = match policy_type_str { + "runtime" => { + let policy: crate::policy_tools::runtime_policy::RuntimePolicy = + serde_json::from_value(json_value.clone()).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to parse as runtime policy: {e}" + )) + })?; + validation::validate_runtime_policy(&policy) + } + "measured-boot" => { + let policy: crate::policy_tools::measured_boot_policy::MeasuredBootPolicy = + serde_json::from_value(json_value.clone()) + .map_err(|e| { + KeylimectlError::validation(format!( + "Failed to parse as measured boot policy: {e}" + )) + })?; + validation::validate_measured_boot_policy(&policy) + } + "tpm" => { + let policy: crate::policy_tools::tpm_policy::TpmPolicy = + serde_json::from_value(json_value.clone()).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to parse as TPM policy: {e}" + )) + })?; + validation::validate_tpm_policy(&policy) + } + other => { + return Err(KeylimectlError::validation( + format!( + "Unknown policy type '{other}'. Expected: runtime, measured-boot, tpm" + ), + )); + } + }; + + // If a signature key is provided, also verify the DSSE signature. + // verify_signature returns Err on failure, so ? propagates it. + if let Some(key) = signature_key { + let _sig_result = verify_signature(file, key, output).await?; + } + + // Format and return results + format_validation_result(&result, policy_type_str, output) +} + +/// Format validation result as JSON and print messages. +fn format_validation_result( + result: &ValidationResult, + policy_type: &str, + output: &OutputHandler, +) -> Result { + if result.valid { + output.info(format!("Policy validation passed ({policy_type})")); + } else { + output.info(format!("Policy validation failed ({policy_type})")); + } + + for error in &result.errors { + output.info(format!(" ERROR [{}]: {}", error.code, error.message)); + } + + for warning in &result.warnings { + output.info(format!( + " WARNING [{}]: {}", + warning.code, warning.message + )); + } + + let errors_json: Vec = result + .errors + .iter() + .map(|e| { + serde_json::json!({ + "code": e.code, + "message": e.message + }) + }) + .collect(); + + let warnings_json: Vec = result + .warnings + .iter() + .map(|w| { + serde_json::json!({ + "code": w.code, + "message": w.message + }) + }) + .collect(); + + let details = serde_json::json!({ + "valid": result.valid, + "policy_type": policy_type, + "errors": errors_json, + "warnings": warnings_json + }); + + if result.valid { + Ok(details) + } else { + Err(KeylimectlError::validation_failed( + format!("Policy validation failed ({policy_type})"), + details, + )) + } +} + +/// Verify a DSSE signature on a signed policy file. +pub async fn verify_signature( + file: &str, + key: &str, + output: &OutputHandler, +) -> Result { + // Read the signed policy (DSSE envelope) + let envelope_content = std::fs::read_to_string(file).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to read signed policy '{file}': {e}" + )) + })?; + + let envelope: DsseEnvelope = serde_json::from_str(&envelope_content) + .map_err(|e| { + KeylimectlError::validation(format!("Invalid DSSE envelope: {e}")) + })?; + + // Detect file format by trying X.509 first, then ECDSA public key. + // The file-reading constructors avoid a separate std::fs::read call + // and delegate I/O errors with descriptive messages. + let verifiers: Vec<(&str, Box)> = + if let Ok(v) = X509Verifier::from_cert_file(key) { + let boxed: Box = Box::new(v); + vec![("x509", boxed)] + } else if let Ok(v) = EcdsaVerifier::from_pem_file(key) { + let boxed: Box = Box::new(v); + vec![("ecdsa", boxed)] + } else { + return Err(KeylimectlError::validation(format!( + "Key file '{key}' has an unknown format \ + (expected an X.509 certificate or an ECDSA public key)" + ))); + }; + + let verifier_refs: Vec<(&str, &dyn Verifier)> = verifiers + .iter() + .map(|(name, v)| (*name, v.as_ref())) + .collect(); + + match dsse::verify_envelope(&envelope, &verifier_refs) { + Ok(result) => { + output.info(format!( + "Signature verified successfully by: {}", + result.recognized_signers.join(", ") + )); + output.info(format!("Payload type: {}", result.payload_type)); + output.info(format!( + "Payload size: {} bytes", + result.payload.len() + )); + + Ok(serde_json::json!({ + "valid": true, + "payload_type": result.payload_type, + "recognized_signers": result.recognized_signers, + "payload_size": result.payload.len() + })) + } + Err(e) => { + output.info(format!("Signature verification failed: {e}")); + Err(KeylimectlError::validation_failed( + format!("Signature verification failed: {e}"), + serde_json::json!({ + "valid": false, + "error": format!("{e}") + }), + )) + } + } +} diff --git a/keylimectl/src/commands/policy/wizard_measured_boot.rs b/keylimectl/src/commands/policy/wizard_measured_boot.rs new file mode 100644 index 000000000..a057c42e4 --- /dev/null +++ b/keylimectl/src/commands/policy/wizard_measured_boot.rs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Interactive wizard for measured boot policy generation. + +use crate::commands::policy::generate; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use crate::policy_tools::measured_boot_gen; +use dialoguer::{Confirm, Input}; +use serde_json::Value; +use std::path::Path; + +/// Default UEFI event log path. +const DEFAULT_EVENTLOG_PATH: &str = + "/sys/kernel/security/tpm0/binary_bios_measurements"; + +/// Map a dialoguer error to a `KeylimectlError`. +fn input_err(e: dialoguer::Error) -> KeylimectlError { + KeylimectlError::Validation(format!("Failed to read user input: {e}")) +} + +/// Run the interactive measured boot policy generation wizard. +pub fn run( + defaults: &Defaults<'_>, + output: &OutputHandler, +) -> Result { + eprintln!(); + eprintln!("Measured Boot Policy Generation Wizard"); + eprintln!("======================================"); + eprintln!(); + + // ── Step 1: Event log ───────────────────────────────────────── + eprintln!("Step 1: Event log"); + eprintln!(); + + let eventlog_path: String = Input::new() + .with_prompt("UEFI event log file") + .default(defaults.eventlog_file.to_string()) + .interact_text() + .map_err(input_err)?; + + // ── Step 2: Secure Boot ─────────────────────────────────────── + eprintln!(); + eprintln!("Step 2: Secure Boot"); + eprintln!(); + + let include_secureboot = Confirm::new() + .with_prompt("Include Secure Boot variables?") + .default(!defaults.without_secureboot) + .interact() + .map_err(input_err)?; + + // ── Step 3: Preview ─────────────────────────────────────────── + eprintln!(); + eprintln!("Step 3: Event log preview"); + eprintln!(); + + let path = Path::new(&eventlog_path); + match measured_boot_gen::get_eventlog_stats(path) { + Ok(stats) => { + eprintln!(" Total events: {}", stats.total_events); + eprintln!(" S-CRTM entries: {}", stats.scrtm_entries); + eprintln!(" Secure Boot entries: {}", stats.secureboot_entries); + eprintln!( + " Algorithms: {}", + if stats.algorithms.is_empty() { + "(none detected)".to_string() + } else { + stats.algorithms.join(", ") + } + ); + } + Err(e) => { + eprintln!(" (Could not preview event log: {e})"); + eprintln!( + " The policy will still be generated if the file becomes available." + ); + } + } + + // ── Step 4: Output ──────────────────────────────────────────── + eprintln!(); + eprintln!("Step 4: Output"); + eprintln!(); + + let output_raw: String = Input::new() + .with_prompt("Output file (empty for stdout)") + .default(defaults.output_file.unwrap_or("").to_string()) + .allow_empty(true) + .interact_text() + .map_err(input_err)?; + + let output_file = if output_raw.trim().is_empty() { + None + } else { + Some(output_raw) + }; + + // ── Step 5: Confirm ─────────────────────────────────────────── + eprintln!(); + + let confirm = Confirm::new() + .with_prompt("Generate this policy?") + .default(true) + .interact() + .map_err(input_err)?; + + if !confirm { + return Err(KeylimectlError::Validation("Cancelled by user".into())); + } + + // ── Generate ────────────────────────────────────────────────── + eprintln!(); + + generate::generate_measured_boot( + &eventlog_path, + !include_secureboot, + output_file.as_deref(), + output, + ) + .map_err(KeylimectlError::from) +} + +/// Default values for the wizard, populated from CLI arguments. +#[derive(Debug)] +pub struct Defaults<'a> { + /// UEFI event log file path. + pub eventlog_file: &'a str, + /// Whether to exclude Secure Boot variables. + pub without_secureboot: bool, + /// Output file. + pub output_file: Option<&'a str>, +} + +impl<'a> Default for Defaults<'a> { + fn default() -> Self { + Self { + eventlog_file: DEFAULT_EVENTLOG_PATH, + without_secureboot: false, + output_file: None, + } + } +} diff --git a/keylimectl/src/commands/policy/wizard_runtime.rs b/keylimectl/src/commands/policy/wizard_runtime.rs new file mode 100644 index 000000000..439049427 --- /dev/null +++ b/keylimectl/src/commands/policy/wizard_runtime.rs @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Interactive wizard for runtime policy generation. + +use crate::commands::policy::generate; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use dialoguer::{Confirm, Input, MultiSelect, Select}; +use serde_json::Value; + +/// Default IMA measurement list path. +const DEFAULT_IMA_PATH: &str = + "/sys/kernel/security/ima/ascii_runtime_measurements"; + +/// Map a dialoguer error to a `KeylimectlError`. +fn input_err(e: dialoguer::Error) -> KeylimectlError { + KeylimectlError::Validation(format!("Failed to read user input: {e}")) +} + +/// Run the interactive runtime policy generation wizard. +/// +/// Prompts the user for input sources and options, then delegates to +/// the existing `generate::generate_runtime()` function. +#[allow(clippy::too_many_lines)] +pub async fn run( + defaults: &Defaults<'_>, + output: &OutputHandler, +) -> Result { + eprintln!(); + eprintln!("Runtime Policy Generation Wizard"); + eprintln!("================================"); + eprintln!(); + + // ── Step 1: Input sources ─────────────────────────────────────── + eprintln!("Step 1: Select input sources"); + eprintln!(); + + #[allow(unused_mut)] + let mut source_labels: Vec<&str> = vec![ + "IMA measurement list", + "Allowlist file", + "Root filesystem scan", + "Initramfs / ramdisk directory", + ]; + + #[cfg(feature = "rpm-repo")] + { + source_labels.push("Local RPM repository"); + source_labels.push("Remote RPM repository"); + } + + // Pre-select sources that were provided via CLI args. + let preselected: Vec = source_labels + .iter() + .enumerate() + .map(|(i, _)| match i { + 0 => defaults.ima_measurement_list.is_some(), + 1 => defaults.allowlist.is_some(), + 2 => defaults.rootfs.is_some(), + 3 => defaults.ramdisk_dir.is_some(), + #[cfg(feature = "rpm-repo")] + 4 => defaults.local_rpm_repo.is_some(), + #[cfg(feature = "rpm-repo")] + 5 => defaults.remote_rpm_repo.is_some(), + _ => false, + }) + .collect(); + + let selected = MultiSelect::new() + .with_prompt("Which input sources should be used?") + .items(&source_labels) + .defaults(&preselected) + .interact() + .map_err(input_err)?; + + let use_ima = selected.contains(&0); + let use_allowlist = selected.contains(&1); + let use_rootfs = selected.contains(&2); + let use_ramdisk = selected.contains(&3); + #[cfg(feature = "rpm-repo")] + let use_local_rpm = selected.contains(&4); + #[cfg(feature = "rpm-repo")] + let use_remote_rpm = selected.contains(&5); + + // ── Step 2: Paths for each selected source ────────────────────── + eprintln!(); + eprintln!("Step 2: Configure selected sources"); + eprintln!(); + + let ima_path = if use_ima { + let path: String = Input::new() + .with_prompt("IMA measurement list path") + .default( + defaults + .ima_measurement_list + .unwrap_or(DEFAULT_IMA_PATH) + .to_string(), + ) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + + let allowlist_path = if use_allowlist { + let path: String = Input::new() + .with_prompt("Allowlist file path") + .default(defaults.allowlist.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + + let rootfs_path = if use_rootfs { + let path: String = Input::new() + .with_prompt("Root filesystem path") + .default(defaults.rootfs.unwrap_or("/").to_string()) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + + let skip_paths: Vec = if use_rootfs { + let default_skip = if defaults.skip_path.is_empty() { + String::new() + } else { + defaults.skip_path.join(", ") + }; + + let raw: String = Input::new() + .with_prompt( + "Paths to skip during scan (comma-separated, empty for none)", + ) + .default(default_skip) + .allow_empty(true) + .interact_text() + .map_err(input_err)?; + + if raw.trim().is_empty() { + vec![] + } else { + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } + } else { + vec![] + }; + + let ramdisk_path = if use_ramdisk { + let path: String = Input::new() + .with_prompt("Initramfs / ramdisk directory (e.g., /boot)") + .default(defaults.ramdisk_dir.unwrap_or("/boot").to_string()) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + + #[cfg(feature = "rpm-repo")] + let local_rpm_path = if use_local_rpm { + let path: String = Input::new() + .with_prompt("Local RPM repository directory") + .default(defaults.local_rpm_repo.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + #[cfg(not(feature = "rpm-repo"))] + let local_rpm_path: Option = None; + + #[cfg(feature = "rpm-repo")] + let remote_rpm_url = if use_remote_rpm { + let url: String = Input::new() + .with_prompt("Remote RPM repository URL") + .default(defaults.remote_rpm_repo.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + Some(url) + } else { + None + }; + #[cfg(not(feature = "rpm-repo"))] + let remote_rpm_url: Option = None; + + // ── Step 3: IMA options ───────────────────────────────────────── + eprintln!(); + eprintln!("Step 3: IMA options"); + eprintln!(); + + let get_keyrings = Confirm::new() + .with_prompt("Include keyrings entries?") + .default(defaults.keyrings) + .interact() + .map_err(input_err)?; + + let ignored_keyrings: Vec = if get_keyrings { + let default_ignored = if defaults.ignored_keyrings.is_empty() { + String::new() + } else { + defaults.ignored_keyrings.join(", ") + }; + + let raw: String = Input::new() + .with_prompt( + "Keyrings to ignore (comma-separated, empty for none)", + ) + .default(default_ignored) + .allow_empty(true) + .interact_text() + .map_err(input_err)?; + + if raw.trim().is_empty() { + vec![] + } else { + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } + } else { + vec![] + }; + + let get_ima_buf = Confirm::new() + .with_prompt("Include ima-buf entries?") + .default(defaults.ima_buf) + .interact() + .map_err(input_err)?; + + // ── Step 4: Hash algorithm ────────────────────────────────────── + eprintln!(); + eprintln!("Step 4: Hash algorithm"); + eprintln!(); + + let alg_options = ["auto-detect", "sha256", "sha1", "sha384", "sha512"]; + let default_alg_idx = defaults + .hash_alg + .and_then(|a| alg_options.iter().position(|&o| o == a)) + .unwrap_or(0); + + let alg_idx = Select::new() + .with_prompt("Hash algorithm") + .items(alg_options) + .default(default_alg_idx) + .interact() + .map_err(input_err)?; + + let hash_alg = if alg_idx == 0 { + None // auto-detect + } else { + Some(alg_options[alg_idx].to_string()) + }; + + // ── Step 5: Additional options ────────────────────────────────── + eprintln!(); + eprintln!("Step 5: Additional options"); + eprintln!(); + + let merge_base = Confirm::new() + .with_prompt("Merge into an existing base policy?") + .default(defaults.base_policy.is_some()) + .interact() + .map_err(input_err)?; + + let base_policy_path = if merge_base { + let path: String = Input::new() + .with_prompt("Base policy file path") + .default(defaults.base_policy.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + + let excludelist_raw: String = Input::new() + .with_prompt("Exclude list file (empty for none)") + .default(defaults.excludelist.unwrap_or("").to_string()) + .allow_empty(true) + .interact_text() + .map_err(input_err)?; + + let excludelist_path = if excludelist_raw.trim().is_empty() { + None + } else { + Some(excludelist_raw) + }; + + // ── Step 6: Output ────────────────────────────────────────────── + eprintln!(); + eprintln!("Step 6: Output"); + eprintln!(); + + let output_raw: String = Input::new() + .with_prompt("Output file (empty for stdout)") + .default(defaults.output_file.unwrap_or("").to_string()) + .allow_empty(true) + .interact_text() + .map_err(input_err)?; + + let output_file = if output_raw.trim().is_empty() { + None + } else { + Some(output_raw) + }; + + // ── Generate ──────────────────────────────────────────────────── + eprintln!(); + + generate::generate_runtime( + ima_path.as_deref(), + allowlist_path.as_deref(), + rootfs_path.as_deref(), + &skip_paths, + base_policy_path.as_deref(), + excludelist_path.as_deref(), + output_file.as_deref(), + get_keyrings, + get_ima_buf, + &ignored_keyrings, + hash_alg.as_deref(), + ramdisk_path.as_deref(), + local_rpm_path.as_deref(), + remote_rpm_url.as_deref(), + #[cfg(feature = "rpm-repo")] + defaults.gpg_key, + #[cfg(not(feature = "rpm-repo"))] + None, + false, + defaults.add_ima_signature_verification_key, + output, + ) + .await + .map_err(KeylimectlError::from) +} + +/// Default values for the wizard, populated from CLI arguments. +pub struct Defaults<'a> { + /// IMA measurement list path. + pub ima_measurement_list: Option<&'a str>, + /// Allowlist file path. + pub allowlist: Option<&'a str>, + /// Root filesystem path. + pub rootfs: Option<&'a str>, + /// Paths to skip during filesystem scan. + pub skip_path: &'a [String], + /// Base policy to merge into. + pub base_policy: Option<&'a str>, + /// Exclude list file. + pub excludelist: Option<&'a str>, + /// Output file. + pub output_file: Option<&'a str>, + /// Include keyrings. + pub keyrings: bool, + /// Include ima-buf entries. + pub ima_buf: bool, + /// Keyrings to ignore. + pub ignored_keyrings: &'a [String], + /// Hash algorithm. + pub hash_alg: Option<&'a str>, + /// Ramdisk directory. + pub ramdisk_dir: Option<&'a str>, + /// Local RPM repository. + #[cfg(feature = "rpm-repo")] + pub local_rpm_repo: Option<&'a str>, + /// Remote RPM repository. + #[cfg(feature = "rpm-repo")] + pub remote_rpm_repo: Option<&'a str>, + /// GPG public key for verifying RPM repository metadata signatures. + #[cfg(feature = "rpm-repo")] + pub gpg_key: Option<&'a str>, + /// IMA signature verification key files. + pub add_ima_signature_verification_key: &'a [String], +} diff --git a/keylimectl/src/commands/policy/wizard_tpm.rs b/keylimectl/src/commands/policy/wizard_tpm.rs new file mode 100644 index 000000000..35323e45f --- /dev/null +++ b/keylimectl/src/commands/policy/wizard_tpm.rs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Interactive wizard for TPM policy generation. + +use crate::commands::policy::generate; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use dialoguer::{Confirm, Input, MultiSelect, Select}; +use serde_json::Value; + +/// PCR descriptions for the interactive selector. +const PCR_DESCRIPTIONS: [&str; 24] = [ + "PCR 0: S-CRTM, BIOS, firmware", + "PCR 1: Host platform configuration", + "PCR 2: Option ROM code", + "PCR 3: Option ROM configuration and data", + "PCR 4: IPL code (boot loaders, shim, GRUB)", + "PCR 5: IPL configuration and data", + "PCR 6: State transitions and wake events", + "PCR 7: Secure Boot state", + "PCR 8: Kernel command line (GRUB)", + "PCR 9: Initrd, kernel (GRUB)", + "PCR 10: IMA", + "PCR 11: (application-specific)", + "PCR 12: (application-specific)", + "PCR 13: (application-specific)", + "PCR 14: (application-specific)", + "PCR 15: (application-specific)", + "PCR 16: Debug", + "PCR 17: DRTM / TXT", + "PCR 18: Trusted OS (TXT)", + "PCR 19: Trusted OS (TXT)", + "PCR 20: Trusted OS (TXT)", + "PCR 21: (defined by OS)", + "PCR 22: (defined by OS)", + "PCR 23: Application support", +]; + +/// Map a dialoguer error to a `KeylimectlError`. +fn input_err(e: dialoguer::Error) -> KeylimectlError { + KeylimectlError::Validation(format!("Failed to read user input: {e}")) +} + +/// Run the interactive TPM policy generation wizard. +pub fn run( + defaults: &Defaults<'_>, + output: &OutputHandler, +) -> Result { + eprintln!(); + eprintln!("TPM Policy Generation Wizard"); + eprintln!("============================"); + eprintln!(); + + // ── Step 1: PCR source ──────────────────────────────────────── + eprintln!("Step 1: PCR source"); + eprintln!(); + + let source_options = ["Read from PCR values file", "Read from local TPM"]; + let default_source = if defaults.from_tpm { 1 } else { 0 }; + + let source_idx = Select::new() + .with_prompt("Where should PCR values be read from?") + .items(source_options) + .default(default_source) + .interact() + .map_err(input_err)?; + + let from_tpm = source_idx == 1; + + let pcr_file = if !from_tpm { + let path: String = Input::new() + .with_prompt("PCR values file path") + .default(defaults.pcr_file.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + + // ── Step 2: PCR indices ─────────────────────────────────────── + eprintln!(); + eprintln!("Step 2: PCR indices"); + eprintln!(); + + // Pre-select PCRs from defaults (default: 0-7) + let preselected: Vec = (0..24) + .map(|i| defaults.pcr_indices.contains(&(i as u32))) + .collect(); + + let selected = MultiSelect::new() + .with_prompt("Which PCR indices should be included in the policy?") + .items(PCR_DESCRIPTIONS) + .defaults(&preselected) + .interact() + .map_err(input_err)?; + + if selected.is_empty() { + return Err(KeylimectlError::Validation( + "At least one PCR index must be selected".into(), + )); + } + + let pcr_indices: Vec = selected.iter().map(|&i| i as u32).collect(); + let pcrs_str = pcr_indices + .iter() + .map(|i| i.to_string()) + .collect::>() + .join(","); + + // ── Step 3: Hash algorithm ──────────────────────────────────── + eprintln!(); + eprintln!("Step 3: Hash algorithm"); + eprintln!(); + + let alg_options = ["sha256", "sha1", "sha384", "sha512"]; + let default_alg_idx = alg_options + .iter() + .position(|&a| a == defaults.hash_alg) + .unwrap_or(0); + + let alg_idx = Select::new() + .with_prompt("Hash algorithm") + .items(alg_options) + .default(default_alg_idx) + .interact() + .map_err(input_err)?; + + let hash_alg = alg_options[alg_idx]; + + // ── Step 4: Output ──────────────────────────────────────────── + eprintln!(); + eprintln!("Step 4: Output"); + eprintln!(); + + let output_raw: String = Input::new() + .with_prompt("Output file (empty for stdout)") + .default(defaults.output_file.unwrap_or("").to_string()) + .allow_empty(true) + .interact_text() + .map_err(input_err)?; + + let output_file = if output_raw.trim().is_empty() { + None + } else { + Some(output_raw) + }; + + // ── Step 5: Confirm ─────────────────────────────────────────── + eprintln!(); + + eprintln!( + " Source: {}", + if from_tpm { "local TPM" } else { "file" } + ); + eprintln!(" PCR indices: {pcrs_str}"); + eprintln!(" Algorithm: {hash_alg}"); + eprintln!(); + + let confirm = Confirm::new() + .with_prompt("Generate this policy?") + .default(true) + .interact() + .map_err(input_err)?; + + if !confirm { + return Err(KeylimectlError::Validation("Cancelled by user".into())); + } + + // ── Generate ────────────────────────────────────────────────── + eprintln!(); + + generate::generate_tpm( + pcr_file.as_deref(), + from_tpm, + &pcrs_str, + None, // mask not used in wizard — pcrs_str is explicit + hash_alg, + output_file.as_deref(), + output, + ) + .map_err(KeylimectlError::from) +} + +/// Default values for the wizard, populated from CLI arguments. +#[derive(Debug)] +pub struct Defaults<'a> { + /// PCR values file path. + pub pcr_file: Option<&'a str>, + /// Whether to read from the local TPM. + pub from_tpm: bool, + /// PCR indices to include. + pub pcr_indices: Vec, + /// Hash algorithm. + pub hash_alg: &'a str, + /// Output file. + pub output_file: Option<&'a str>, +} diff --git a/keylimectl/src/commands/verify/evidence.rs b/keylimectl/src/commands/verify/evidence.rs new file mode 100644 index 000000000..514cbfccc --- /dev/null +++ b/keylimectl/src/commands/verify/evidence.rs @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! One-shot evidence verification via the verifier. + +use crate::client::factory; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use crate::VerifyAction; +use base64::{engine::general_purpose::STANDARD as Base64, Engine}; +use serde_json::{json, Value}; + +/// Execute the verify evidence command. +pub async fn execute( + action: &VerifyAction, + output: &OutputHandler, +) -> Result { + let VerifyAction::Evidence { + #[cfg(feature = "wizard")] + interactive, + nonce, + quote, + hash_alg, + tpm_ak, + tpm_ek, + runtime_policy, + ima_measurement_list, + mb_policy, + mb_log, + tpm_policy, + evidence_type, + } = action; + + #[cfg(feature = "wizard")] + if *interactive { + let defaults = super::wizard_evidence::Defaults { + evidence_type, + nonce: nonce.as_deref(), + quote: quote.as_deref(), + hash_alg, + tpm_ak: tpm_ak.as_deref(), + tpm_ek: tpm_ek.as_deref(), + runtime_policy: runtime_policy.as_deref(), + ima_measurement_list: ima_measurement_list.as_deref(), + mb_policy: mb_policy.as_deref(), + mb_log: mb_log.as_deref(), + tpm_policy: tpm_policy.as_deref(), + }; + return super::wizard_evidence::run(&defaults, output).await; + } + + // Validate required fields in non-interactive mode + let nonce = nonce + .as_deref() + .ok_or_else(|| KeylimectlError::validation("--nonce is required"))?; + let quote = quote + .as_deref() + .ok_or_else(|| KeylimectlError::validation("--quote is required"))?; + let tpm_ak = tpm_ak + .as_deref() + .ok_or_else(|| KeylimectlError::validation("--tpm-ak is required"))?; + let tpm_ek = tpm_ek + .as_deref() + .ok_or_else(|| KeylimectlError::validation("--tpm-ek is required"))?; + + output.info(format!("Verifying {evidence_type} attestation evidence")); + + // Build the evidence data object + let data = build_evidence_data( + nonce, + quote, + hash_alg, + tpm_ak, + tpm_ek, + runtime_policy.as_deref(), + ima_measurement_list.as_deref(), + mb_policy.as_deref(), + mb_log.as_deref(), + tpm_policy.as_deref(), + )?; + + let request_body = json!({ + "type": evidence_type, + "data": data, + }); + + // Connect to the verifier and send the evidence + let client = factory::get_verifier().await?; + + output.info("Sending evidence to verifier..."); + + let response = client.verify_evidence(request_body).await?; + + // Parse and display the result + format_evidence_result(&response, output) +} + +/// Build the evidence data object from CLI arguments. +#[allow(clippy::too_many_arguments)] +pub(super) fn build_evidence_data( + nonce: &str, + quote_path: &str, + hash_alg: &str, + tpm_ak_path: &str, + tpm_ek_path: &str, + runtime_policy_path: Option<&str>, + ima_ml_path: Option<&str>, + mb_policy_path: Option<&str>, + mb_log_path: Option<&str>, + tpm_policy_path: Option<&str>, +) -> Result { + // Read and base64-encode binary files + let quote_data = read_and_b64(quote_path)?; + let tpm_ak_data = read_and_b64(tpm_ak_path)?; + let tpm_ek_data = read_and_b64(tpm_ek_path)?; + + let mut data = json!({ + "nonce": nonce, + "quote": quote_data, + "hash_alg": hash_alg, + "tpm_ak": tpm_ak_data, + "tpm_ek": tpm_ek_data, + }); + + // Add optional policy files + if let Some(path) = runtime_policy_path { + let content = read_file_string(path)?; + data["runtime_policy"] = Value::String(content); + } + + if let Some(path) = ima_ml_path { + let content = read_file_string(path)?; + data["ima_measurement_list"] = Value::String(content); + } + + if let Some(path) = mb_policy_path { + let content = read_file_string(path)?; + data["mb_policy"] = Value::String(content); + } + + if let Some(path) = mb_log_path { + let content = read_and_b64(path)?; + data["mb_log"] = Value::String(content); + } + + if let Some(path) = tpm_policy_path { + let content = read_file_string(path)?; + data["tpm_policy"] = Value::String(content); + } + + // Verify at least one policy is provided + if runtime_policy_path.is_none() + && mb_policy_path.is_none() + && tpm_policy_path.is_none() + { + return Err(KeylimectlError::validation( + "At least one policy must be provided (--runtime-policy, --mb-policy, or --tpm-policy)", + )); + } + + Ok(data) +} + +/// Read a file and return its contents as a base64 string. +fn read_and_b64(path: &str) -> Result { + let data = std::fs::read(path).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to read file '{path}': {e}" + )) + })?; + Ok(Base64.encode(&data)) +} + +/// Read a file and return its contents as a UTF-8 string. +fn read_file_string(path: &str) -> Result { + std::fs::read_to_string(path).map_err(|e| { + KeylimectlError::validation(format!( + "Failed to read file '{path}': {e}" + )) + }) +} + +/// Format and display the evidence verification result. +pub(super) fn format_evidence_result( + response: &Value, + output: &OutputHandler, +) -> Result { + let results = response.get("results").unwrap_or(response); + + let valid = results + .get("valid") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if valid { + output.info("Evidence verification: PASSED"); + } else { + output.info("Evidence verification: FAILED"); + + // Display failures if present + if let Some(failures) = + results.get("failures").and_then(|f| f.as_array()) + { + for failure in failures { + let failure_type = failure + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("unknown"); + let message = failure + .get("context") + .and_then(|c| c.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("No details"); + output.info(format!(" [{failure_type}]: {message}")); + } + } + } + + let details = json!({ + "valid": valid, + "results": results, + }); + + if valid { + Ok(details) + } else { + Err(KeylimectlError::validation_failed( + "Evidence verification failed", + details, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_evidence_data_missing_policy() { + let result = build_evidence_data( + "nonce123", + "/nonexistent/quote", + "sha256", + "/nonexistent/ak", + "/nonexistent/ek", + None, + None, + None, + None, + None, + ); + // Should fail because file doesn't exist + // (or because no policy is provided) + assert!(result.is_err()); + } + + #[test] + fn test_format_evidence_result_valid() { + let response = json!({ + "code": 200, + "status": "Success", + "results": { + "valid": true, + "claims": {}, + "failures": [] + } + }); + + let output = OutputHandler::new( + crate::OutputFormat::Json, + false, + crate::ColorMode::Never, + ); + let result = format_evidence_result(&response, &output).unwrap(); //#[allow_ci] + assert_eq!(result.get("valid"), Some(&Value::Bool(true))); + } + + #[test] + fn test_format_evidence_result_invalid() { + let response = json!({ + "code": 200, + "status": "Success", + "results": { + "valid": false, + "claims": {}, + "failures": [{ + "type": "pcr_mismatch", + "context": { + "message": "PCR 0 does not match expected value" + } + }] + } + }); + + let output = OutputHandler::new( + crate::OutputFormat::Json, + false, + crate::ColorMode::Never, + ); + let err = format_evidence_result(&response, &output).unwrap_err(); //#[allow_ci] + assert_eq!(err.error_code(), "VALIDATION_FAILED"); + let json = err.to_json(); + assert_eq!(json["error"]["details"]["valid"], Value::Bool(false)); + } + + #[test] + fn test_read_and_b64() { + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), b"test data").unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let result = read_and_b64(&path).unwrap(); //#[allow_ci] + let decoded = Base64.decode(&result).unwrap(); //#[allow_ci] + assert_eq!(decoded, b"test data"); + } + + #[test] + fn test_read_file_string() { + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), "text content").unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let result = read_file_string(&path).unwrap(); //#[allow_ci] + assert_eq!(result, "text content"); + } +} diff --git a/keylimectl/src/commands/verify/mod.rs b/keylimectl/src/commands/verify/mod.rs new file mode 100644 index 000000000..a7d226c27 --- /dev/null +++ b/keylimectl/src/commands/verify/mod.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Attestation verification commands. + +mod evidence; +#[cfg(feature = "wizard")] +mod wizard_evidence; + +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use crate::VerifyAction; +use serde_json::Value; + +/// Execute a verify command. +pub async fn execute( + action: &VerifyAction, + output: &OutputHandler, +) -> Result { + match action { + VerifyAction::Evidence { .. } => { + evidence::execute(action, output).await + } + } +} diff --git a/keylimectl/src/commands/verify/wizard_evidence.rs b/keylimectl/src/commands/verify/wizard_evidence.rs new file mode 100644 index 000000000..456b437c2 --- /dev/null +++ b/keylimectl/src/commands/verify/wizard_evidence.rs @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Interactive wizard for evidence verification. + +use crate::client::factory; +use crate::commands::verify::evidence; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; +use dialoguer::{Confirm, Input, Select}; +use serde_json::{json, Value}; + +/// Map a dialoguer error to a `KeylimectlError`. +fn input_err(e: dialoguer::Error) -> KeylimectlError { + KeylimectlError::Validation(format!("Failed to read user input: {e}")) +} + +/// Run the interactive evidence verification wizard. +pub async fn run( + defaults: &Defaults<'_>, + output: &OutputHandler, +) -> Result { + eprintln!(); + eprintln!("Evidence Verification Wizard"); + eprintln!("============================"); + eprintln!(); + + // ── Step 1: Evidence type ───────────────────────────────────── + eprintln!("Step 1: Evidence type"); + eprintln!(); + + let type_options = ["tpm", "tee"]; + let default_type_idx = type_options + .iter() + .position(|&t| t == defaults.evidence_type) + .unwrap_or(0); + + let type_idx = Select::new() + .with_prompt("Evidence type") + .items(type_options) + .default(default_type_idx) + .interact() + .map_err(input_err)?; + + let evidence_type = type_options[type_idx]; + + // ── Step 2: Required files ──────────────────────────────────── + eprintln!(); + eprintln!("Step 2: Required files"); + eprintln!(); + + let nonce: String = Input::new() + .with_prompt("Nonce") + .default(defaults.nonce.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + + if nonce.trim().is_empty() { + return Err(KeylimectlError::Validation("Nonce is required".into())); + } + + let quote: String = Input::new() + .with_prompt("TPM quote file path") + .default(defaults.quote.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + + if quote.trim().is_empty() { + return Err(KeylimectlError::Validation( + "TPM quote file is required".into(), + )); + } + + let tpm_ak: String = Input::new() + .with_prompt("TPM Attestation Key (AK) file path") + .default(defaults.tpm_ak.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + + if tpm_ak.trim().is_empty() { + return Err(KeylimectlError::Validation( + "TPM AK file is required".into(), + )); + } + + let tpm_ek: String = Input::new() + .with_prompt("TPM Endorsement Key (EK) file path") + .default(defaults.tpm_ek.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + + if tpm_ek.trim().is_empty() { + return Err(KeylimectlError::Validation( + "TPM EK file is required".into(), + )); + } + + // ── Step 3: Hash algorithm ──────────────────────────────────── + eprintln!(); + eprintln!("Step 3: Hash algorithm"); + eprintln!(); + + let alg_options = ["sha256", "sha1", "sha384", "sha512"]; + let default_alg_idx = alg_options + .iter() + .position(|&a| a == defaults.hash_alg) + .unwrap_or(0); + + let alg_idx = Select::new() + .with_prompt("Hash algorithm") + .items(alg_options) + .default(default_alg_idx) + .interact() + .map_err(input_err)?; + + let hash_alg = alg_options[alg_idx]; + + // ── Step 4: Policies ────────────────────────────────────────── + eprintln!(); + eprintln!("Step 4: Policies (at least one required)"); + eprintln!(); + + let use_runtime = Confirm::new() + .with_prompt("Include runtime policy?") + .default(defaults.runtime_policy.is_some()) + .interact() + .map_err(input_err)?; + + let runtime_policy = if use_runtime { + let path: String = Input::new() + .with_prompt("Runtime policy file path") + .default(defaults.runtime_policy.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + + let use_mb = Confirm::new() + .with_prompt("Include measured boot policy?") + .default(defaults.mb_policy.is_some()) + .interact() + .map_err(input_err)?; + + let mb_policy = if use_mb { + let path: String = Input::new() + .with_prompt("Measured boot policy file path") + .default(defaults.mb_policy.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + + let use_tpm = Confirm::new() + .with_prompt("Include TPM policy?") + .default(defaults.tpm_policy.is_some()) + .interact() + .map_err(input_err)?; + + let tpm_policy = if use_tpm { + let path: String = Input::new() + .with_prompt("TPM policy file path") + .default(defaults.tpm_policy.unwrap_or("").to_string()) + .interact_text() + .map_err(input_err)?; + Some(path) + } else { + None + }; + + if runtime_policy.is_none() && mb_policy.is_none() && tpm_policy.is_none() + { + return Err(KeylimectlError::Validation( + "At least one policy must be provided".into(), + )); + } + + // ── Step 5: Measurement logs ────────────────────────────────── + eprintln!(); + eprintln!("Step 5: Measurement logs"); + eprintln!(); + + let ima_ml = if runtime_policy.is_some() { + let path: String = Input::new() + .with_prompt("IMA measurement list file (empty to skip)") + .default(defaults.ima_measurement_list.unwrap_or("").to_string()) + .allow_empty(true) + .interact_text() + .map_err(input_err)?; + if path.trim().is_empty() { + None + } else { + Some(path) + } + } else { + None + }; + + let mb_log = if mb_policy.is_some() { + let path: String = Input::new() + .with_prompt("Measured boot log file (empty to skip)") + .default(defaults.mb_log.unwrap_or("").to_string()) + .allow_empty(true) + .interact_text() + .map_err(input_err)?; + if path.trim().is_empty() { + None + } else { + Some(path) + } + } else { + None + }; + + // ── Step 6: Confirm ─────────────────────────────────────────── + eprintln!(); + + eprintln!(" Evidence type: {evidence_type}"); + eprintln!(" Nonce: {nonce}"); + eprintln!(" Quote: {quote}"); + eprintln!(" TPM AK: {tpm_ak}"); + eprintln!(" TPM EK: {tpm_ek}"); + eprintln!(" Algorithm: {hash_alg}"); + if let Some(ref p) = runtime_policy { + eprintln!(" Runtime policy: {p}"); + } + if let Some(ref p) = ima_ml { + eprintln!(" IMA log: {p}"); + } + if let Some(ref p) = mb_policy { + eprintln!(" MB policy: {p}"); + } + if let Some(ref p) = mb_log { + eprintln!(" MB log: {p}"); + } + if let Some(ref p) = tpm_policy { + eprintln!(" TPM policy: {p}"); + } + eprintln!(); + + let confirm = Confirm::new() + .with_prompt("Send to verifier for verification?") + .default(true) + .interact() + .map_err(input_err)?; + + if !confirm { + return Err(KeylimectlError::Validation("Cancelled by user".into())); + } + + // ── Verify ──────────────────────────────────────────────────── + eprintln!(); + + let data = evidence::build_evidence_data( + &nonce, + "e, + hash_alg, + &tpm_ak, + &tpm_ek, + runtime_policy.as_deref(), + ima_ml.as_deref(), + mb_policy.as_deref(), + mb_log.as_deref(), + tpm_policy.as_deref(), + )?; + + let request_body = json!({ + "type": evidence_type, + "data": data, + }); + + let client = factory::get_verifier().await?; + + output.info("Sending evidence to verifier..."); + + let response = client.verify_evidence(request_body).await?; + + evidence::format_evidence_result(&response, output) +} + +/// Default values for the wizard, populated from CLI arguments. +#[derive(Debug)] +pub struct Defaults<'a> { + /// Evidence type (tpm or tee). + pub evidence_type: &'a str, + /// Nonce. + pub nonce: Option<&'a str>, + /// TPM quote file path. + pub quote: Option<&'a str>, + /// Hash algorithm. + pub hash_alg: &'a str, + /// TPM AK file path. + pub tpm_ak: Option<&'a str>, + /// TPM EK file path. + pub tpm_ek: Option<&'a str>, + /// Runtime policy file path. + pub runtime_policy: Option<&'a str>, + /// IMA measurement list file path. + pub ima_measurement_list: Option<&'a str>, + /// Measured boot policy file path. + pub mb_policy: Option<&'a str>, + /// Measured boot log file path. + pub mb_log: Option<&'a str>, + /// TPM policy file path. + pub tpm_policy: Option<&'a str>, +} diff --git a/keylimectl/src/config/mod.rs b/keylimectl/src/config/mod.rs new file mode 100644 index 000000000..19077322f --- /dev/null +++ b/keylimectl/src/config/mod.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Configuration management for keylimectl +//! +//! This module provides comprehensive configuration management for the keylimectl CLI tool. +//! It supports multiple configuration sources with a clear precedence order: +//! +//! 1. Command-line arguments (highest priority) +//! 2. Environment variables (prefixed with `KEYLIME_`) +//! 3. Configuration files (TOML format) +//! 4. Default values (lowest priority) +//! +//! # Module Structure +//! +//! - [`validation`]: Configuration validation logic extracted for better organization +//! - Main configuration types and loading logic in this module +//! +//! # Configuration Sources +//! +//! ## Configuration Files (Optional) +//! Configuration files are completely optional. The system searches for TOML files in the following order: +//! - Explicit path provided via CLI argument (required to exist if specified) +//! - `keylimectl.toml` (current directory) +//! - `keylimectl.conf` (current directory) +//! - `/etc/keylime/keylimectl.conf` (system-wide) +//! - `/usr/etc/keylime/keylimectl.conf` (alternative system-wide) +//! - `~/.config/keylime/keylimectl.conf` (user-specific) +//! - `~/.keylimectl.toml` (user-specific) +//! - `$XDG_CONFIG_HOME/keylime/keylimectl.conf` (XDG standard) +//! +//! If no configuration files are found, keylimectl will work perfectly with defaults and environment variables. +//! +//! ## Environment Variables +//! Environment variables use the prefix `KEYLIME_` with double underscores as separators: +//! - `KEYLIME_VERIFIER__IP=192.168.1.100` +//! - `KEYLIME_VERIFIER__PORT=8881` +//! - `KEYLIME_TLS__VERIFY_SERVER_CERT=false` +//! +//! ## Example Configuration File +//! +//! ```toml +//! [verifier] +//! ip = "127.0.0.1" +//! port = 8881 +//! id = "verifier-1" +//! +//! [registrar] +//! ip = "127.0.0.1" +//! port = 8891 +//! +//! [tls] +//! client_cert = "/path/to/client.crt" +//! client_key = "/path/to/client.key" +//! verify_server_cert = true +//! enable_agent_mtls = true +//! +//! [client] +//! timeout = 60 +//! max_retries = 3 +//! exponential_backoff = true +//! ``` +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::config::{Config, validation}; +//! use keylimectl::Cli; +//! +//! // Load default configuration +//! let config = Config::default(); +//! +//! // Load from files and environment +//! let config = Config::load(None).expect("Failed to load config"); +//! +//! // Apply CLI overrides +//! let cli = Cli::default(); +//! let config = config.with_cli_overrides(&cli); +//! +//! // Validate configuration using extracted validation logic +//! validation::validate_complete_config( +//! &config.verifier, +//! &config.registrar, +//! &config.tls, +//! &config.client +//! ).expect("Invalid configuration"); +//! +//! // Get service URLs +//! let verifier_url = config.verifier_base_url(); +//! let registrar_url = config.registrar_base_url(); +//! ``` + +pub mod singleton; +pub mod validation; + +// Re-export main config types for backwards compatibility +pub use self::main_config::*; + +// Import the main configuration from the original file +#[path = "../config_main.rs"] +mod main_config; diff --git a/keylimectl/src/config/singleton.rs b/keylimectl/src/config/singleton.rs new file mode 100644 index 000000000..1937f09b2 --- /dev/null +++ b/keylimectl/src/config/singleton.rs @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Global configuration singleton for keylimectl +//! +//! This module provides a global singleton for the keylimectl configuration, +//! similar to the pattern used in the keylime agent. The configuration is +//! initialized once at application startup and accessed throughout the +//! application without passing it as a parameter. + +use super::Config; +use crate::error::KeylimectlError; +use std::sync::OnceLock; + +static GLOBAL_CONFIG: OnceLock = OnceLock::new(); + +/// Initialize the global configuration singleton +/// +/// This function must be called once at application startup to set the +/// global configuration. Subsequent calls will return an error. +/// +/// # Arguments +/// +/// * `config` - The configuration to use globally +/// +/// # Errors +/// +/// Returns an error if the configuration has already been initialized. +/// +/// # Examples +/// +/// ```rust,ignore +/// use keylimectl::config::{Config, singleton}; +/// +/// let config = Config::load(None)?; +/// singleton::initialize_config(config)?; +/// ``` +pub fn initialize_config(config: Config) -> Result<(), KeylimectlError> { + GLOBAL_CONFIG.set(config).map_err(|_| { + KeylimectlError::validation("Config singleton already initialized") + }) +} + +/// Get a reference to the global configuration +/// +/// This is the main factory method for accessing the configuration throughout +/// the application. The configuration must have been initialized via +/// `initialize_config()` first. +/// +/// # Panics +/// +/// Panics if the configuration has not been initialized. This is intentional +/// as the configuration should always be initialized at application startup. +/// +/// # Examples +/// +/// ```rust,ignore +/// use keylimectl::config::singleton; +/// +/// let config = singleton::get_config(); +/// println!("Verifier: {}:{}", config.verifier.ip, config.verifier.port); +/// ``` +pub fn get_config() -> &'static Config { + if !is_initialized() { + #[rustfmt::skip] + panic!( //#[allow_ci] + "Config not initialized. Run `keylimectl configure` to create a \ + configuration file, or check that initialization completed \ + successfully before running commands." + ); + } + GLOBAL_CONFIG.get().expect("Config not initialized") //#[allow_ci] +} + +/// Check if the configuration has been initialized +/// +/// This can be used for defensive programming or in tests to verify +/// initialization state. +/// +/// # Examples +/// +/// ```rust,ignore +/// use keylimectl::config::singleton; +/// +/// if !singleton::is_initialized() { +/// eprintln!("Warning: Config not initialized"); +/// } +/// ``` +pub fn is_initialized() -> bool { + GLOBAL_CONFIG.get().is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + AgentConfig, ClientConfig, RegistrarConfig, TlsConfig, VerifierConfig, + }; + + #[allow(dead_code)] + fn create_test_config() -> Config { + Config { + loaded_from: None, + cli_overrides: crate::config::CliOverrides::default(), + verifier: VerifierConfig { + ip: "127.0.0.1".to_string(), + port: 8881, + id: Some("test-verifier".to_string()), + }, + registrar: RegistrarConfig { + ip: "127.0.0.1".to_string(), + port: 8891, + }, + tls: TlsConfig { + client_cert: None, + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: false, + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }, + client: ClientConfig { + timeout: 30, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + }, + agent: AgentConfig::default(), + } + } + + #[test] + fn test_singleton_not_initialized() { + // Note: This test may fail if other tests have initialized the singleton + // In a real scenario, we'd need test isolation + assert!( + !is_initialized() || is_initialized(), + "Should return valid state" + ); + } + + #[test] + #[should_panic(expected = "Config not initialized")] + fn test_get_config_panics_when_not_initialized() { + // Clear any existing config (not possible with OnceLock, so this test + // assumes it runs in isolation or after initialization) + // This test demonstrates the expected panic behavior + if !is_initialized() { + let _ = get_config(); + } + } + + // Note: We can't easily test the full singleton pattern here because + // OnceLock can only be set once per process lifetime. Real tests would + // need to be in integration tests with process isolation. +} diff --git a/keylimectl/src/config/validation.rs b/keylimectl/src/config/validation.rs new file mode 100644 index 000000000..ef9d57c3e --- /dev/null +++ b/keylimectl/src/config/validation.rs @@ -0,0 +1,604 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Configuration validation logic for keylimectl +//! +//! This module provides comprehensive validation for all configuration components, +//! ensuring that configuration values are valid and usable before the application +//! attempts to use them. The validation is structured into logical groups for +//! better maintainability and testing. +//! +//! # Validation Categories +//! +//! 1. **Network Validation**: IP addresses, ports, and connectivity requirements +//! 2. **TLS Validation**: Certificate files, key files, and TLS settings +//! 3. **Client Validation**: Timeouts, retries, and HTTP client settings +//! 4. **Cross-Component Validation**: Validation that spans multiple config sections +//! +//! # Error Handling +//! +//! All validation functions return `Result<(), ConfigError>` where errors contain +//! descriptive messages that can be shown directly to users. +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::config::{Config, validation}; +//! +//! let config = Config::default(); +//! +//! // Validate entire configuration +//! validation::validate_complete_config(&config)?; +//! +//! // Validate specific components +//! validation::validate_network_config(&config.verifier, &config.registrar)?; +//! validation::validate_tls_config(&config.tls)?; +//! validation::validate_client_config(&config.client)?; +//! # Ok::<(), Box>(()) +//! ``` + +use super::{ClientConfig, RegistrarConfig, TlsConfig, VerifierConfig}; +use config::ConfigError; +use std::path::Path; + +/// Validate the complete configuration +/// +/// This is the main validation entry point that performs comprehensive validation +/// of all configuration components and their interactions. +/// +/// # Arguments +/// +/// * `verifier` - Verifier service configuration +/// * `registrar` - Registrar service configuration +/// * `tls` - TLS/SSL security configuration +/// * `client` - HTTP client behavior configuration +/// +/// # Returns +/// +/// Returns `Ok(())` if all validation passes, or `Err(ConfigError)` with a +/// descriptive error message indicating the first validation failure encountered. +/// +/// # Validation Performed +/// +/// 1. Network configuration (IPs and ports) +/// 2. TLS configuration (certificates and settings) +/// 3. Client configuration (timeouts and retries) +/// 4. Cross-component consistency checks +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::config::{Config, validation}; +/// +/// let config = Config::default(); +/// validation::validate_complete_config( +/// &config.verifier, +/// &config.registrar, +/// &config.tls, +/// &config.client +/// )?; +/// # Ok::<(), Box>(()) +/// ``` +pub fn validate_complete_config( + verifier: &VerifierConfig, + registrar: &RegistrarConfig, + tls: &TlsConfig, + client: &ClientConfig, +) -> Result<(), ConfigError> { + // Validate each component + validate_network_config(verifier, registrar)?; + validate_tls_config(tls)?; + validate_client_config(client)?; + + // Perform cross-component validation + validate_cross_component_config(verifier, registrar, tls, client)?; + + Ok(()) +} + +/// Validate network configuration (IP addresses and ports) +/// +/// Ensures that IP addresses are not empty and ports are valid (non-zero). +/// This validation is essential for establishing network connections to services. +/// +/// # Arguments +/// +/// * `verifier` - Verifier service configuration +/// * `registrar` - Registrar service configuration +/// +/// # Returns +/// +/// Returns `Ok(())` if network configuration is valid, or `Err(ConfigError)` +/// with a specific error message. +/// +/// # Validation Rules +/// +/// - IP addresses cannot be empty strings +/// - Ports must be greater than 0 (valid port range 1-65535) +/// - IPv6 addresses are automatically detected and handled properly +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::config::{VerifierConfig, RegistrarConfig, validation}; +/// +/// let verifier = VerifierConfig { +/// ip: "192.168.1.100".to_string(), +/// port: 8881, +/// id: None, +/// }; +/// let registrar = RegistrarConfig { +/// ip: "192.168.1.100".to_string(), +/// port: 8891, +/// }; +/// +/// validation::validate_network_config(&verifier, ®istrar)?; +/// # Ok::<(), Box>(()) +/// ``` +pub fn validate_network_config( + verifier: &VerifierConfig, + registrar: &RegistrarConfig, +) -> Result<(), ConfigError> { + // Validate verifier network configuration + validate_ip_address(&verifier.ip, "Verifier")?; + validate_port(verifier.port, "Verifier")?; + + // Validate registrar network configuration + validate_ip_address(®istrar.ip, "Registrar")?; + validate_port(registrar.port, "Registrar")?; + + Ok(()) +} + +/// Validate TLS configuration (certificates and security settings) +/// +/// Ensures that TLS certificate and key files exist if specified, and that +/// TLS settings are consistent and secure. +/// +/// # Arguments +/// +/// * `tls` - TLS configuration to validate +/// +/// # Returns +/// +/// Returns `Ok(())` if TLS configuration is valid, or `Err(ConfigError)` +/// with a specific error message. +/// +/// # Validation Rules +/// +/// - If client certificate is specified, the file must exist and be readable +/// - If client key is specified, the file must exist and be readable +/// - Certificate and key should be specified together for mTLS +/// - TLS settings should be consistent with security requirements +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::config::{TlsConfig, validation}; +/// +/// let tls = TlsConfig { +/// client_cert: None, +/// client_key: None, +/// client_key_password: None, +/// trusted_ca: vec![], +/// verify_server_cert: true, +/// enable_agent_mtls: true, +/// accept_invalid_hostnames: true, +/// }; +/// +/// validation::validate_tls_config(&tls)?; +/// # Ok::<(), Box>(()) +/// ``` +pub fn validate_tls_config(tls: &TlsConfig) -> Result<(), ConfigError> { + // Validate client certificate if specified + if let Some(ref cert_path) = tls.client_cert { + validate_file_exists(cert_path, "Client certificate")?; + } + + // Validate client key if specified + if let Some(ref key_path) = tls.client_key { + validate_file_exists(key_path, "Client key")?; + } + + // Validate trusted CA certificates if specified + for ca_path in &tls.trusted_ca { + validate_file_exists(ca_path, "Trusted CA certificate")?; + } + + // Validate TLS consistency + validate_tls_consistency(tls)?; + + Ok(()) +} + +/// Validate client configuration (timeouts, retries, and HTTP settings) +/// +/// Ensures that HTTP client settings are reasonable and will not cause +/// operational issues. +/// +/// # Arguments +/// +/// * `client` - Client configuration to validate +/// +/// # Returns +/// +/// Returns `Ok(())` if client configuration is valid, or `Err(ConfigError)` +/// with a specific error message. +/// +/// # Validation Rules +/// +/// - Timeout must be greater than 0 seconds +/// - Retry interval must be positive (> 0.0 seconds) +/// - Max retries should be reasonable (typically 0-10) +/// - Exponential backoff settings should be consistent +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::config::{ClientConfig, validation}; +/// +/// let client = ClientConfig { +/// timeout: 60, +/// retry_interval: 1.0, +/// exponential_backoff: true, +/// max_retries: 3, +/// }; +/// +/// validation::validate_client_config(&client)?; +/// # Ok::<(), Box>(()) +/// ``` +pub fn validate_client_config( + client: &ClientConfig, +) -> Result<(), ConfigError> { + // Validate timeout + if client.timeout == 0 { + return Err(ConfigError::Message( + "Client timeout cannot be 0".to_string(), + )); + } + + // Validate retry interval + if client.retry_interval <= 0.0 { + return Err(ConfigError::Message( + "Retry interval must be positive".to_string(), + )); + } + + // Validate max retries (reasonable upper bound) + if client.max_retries > 20 { + return Err(ConfigError::Message( + "Max retries should not exceed 20 (current value may cause excessive delays)".to_string(), + )); + } + + Ok(()) +} + +/// Validate cross-component configuration consistency +/// +/// Performs validation that spans multiple configuration components to ensure +/// they work together properly. +/// +/// # Arguments +/// +/// * `verifier` - Verifier service configuration +/// * `registrar` - Registrar service configuration +/// * `tls` - TLS configuration +/// * `client` - Client configuration +/// +/// # Validation Performed +/// +/// - Ensures TLS settings are appropriate for the deployment +/// - Validates that timeout settings are reasonable for the network configuration +/// - Checks for potential configuration conflicts +fn validate_cross_component_config( + _verifier: &VerifierConfig, + _registrar: &RegistrarConfig, + tls: &TlsConfig, + client: &ClientConfig, +) -> Result<(), ConfigError> { + // Validate TLS and client timeout relationship + if tls.verify_server_cert && client.timeout < 10 { + return Err(ConfigError::Message( + "Client timeout should be at least 10 seconds when server certificate verification is enabled".to_string(), + )); + } + + // More cross-component validations can be added here as needed + + Ok(()) +} + +/// Validate an IP address field +/// +/// Ensures the IP address is not empty. Additional validation for IP format +/// could be added here if needed. +fn validate_ip_address( + ip: &str, + service_name: &str, +) -> Result<(), ConfigError> { + if ip.is_empty() { + return Err(ConfigError::Message(format!( + "{service_name} IP cannot be empty" + ))); + } + + Ok(()) +} + +/// Validate a port number +/// +/// Ensures the port is in the valid range (1-65535). +fn validate_port(port: u16, service_name: &str) -> Result<(), ConfigError> { + if port == 0 { + return Err(ConfigError::Message(format!( + "{service_name} port cannot be 0" + ))); + } + + Ok(()) +} + +/// Validate that a file exists and is readable +/// +/// Used for validating certificate files, key files, and other required files. +fn validate_file_exists( + path: &str, + file_type: &str, +) -> Result<(), ConfigError> { + if !Path::new(path).exists() { + return Err(ConfigError::Message(format!( + "{file_type} file not found: {path}" + ))); + } + + Ok(()) +} + +/// Validate TLS configuration consistency +/// +/// Ensures that TLS settings are consistent and follow security best practices. +fn validate_tls_consistency(tls: &TlsConfig) -> Result<(), ConfigError> { + // Check if certificate and key are specified together + let has_cert = tls.client_cert.is_some(); + let has_key = tls.client_key.is_some(); + + if has_cert != has_key { + return Err(ConfigError::Message( + "Client certificate and key must be specified together for mutual TLS".to_string(), + )); + } + + // Warn if mTLS is enabled but no certificates are provided + if tls.enable_agent_mtls && !has_cert { + // This is not necessarily an error, as certificates might be auto-generated + // But we could add a warning mechanism here if needed + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn create_valid_verifier_config() -> VerifierConfig { + VerifierConfig { + ip: "127.0.0.1".to_string(), + port: 8881, + id: None, + } + } + + fn create_valid_registrar_config() -> RegistrarConfig { + RegistrarConfig { + ip: "127.0.0.1".to_string(), + port: 8891, + } + } + + fn create_valid_tls_config() -> TlsConfig { + TlsConfig { + client_cert: None, + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: true, + enable_agent_mtls: true, + accept_invalid_hostnames: true, + } + } + + fn create_valid_client_config() -> ClientConfig { + ClientConfig { + timeout: 60, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + } + } + + #[test] + fn test_validate_complete_config_success() { + let verifier = create_valid_verifier_config(); + let registrar = create_valid_registrar_config(); + let tls = create_valid_tls_config(); + let client = create_valid_client_config(); + + let result = + validate_complete_config(&verifier, ®istrar, &tls, &client); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_network_config_success() { + let verifier = create_valid_verifier_config(); + let registrar = create_valid_registrar_config(); + + let result = validate_network_config(&verifier, ®istrar); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_network_config_empty_verifier_ip() { + let mut verifier = create_valid_verifier_config(); + verifier.ip = String::new(); + let registrar = create_valid_registrar_config(); + + let result = validate_network_config(&verifier, ®istrar); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Verifier IP cannot be empty")); + } + + #[test] + fn test_validate_network_config_zero_port() { + let mut verifier = create_valid_verifier_config(); + verifier.port = 0; + let registrar = create_valid_registrar_config(); + + let result = validate_network_config(&verifier, ®istrar); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Verifier port cannot be 0")); + } + + #[test] + fn test_validate_tls_config_success() { + let tls = create_valid_tls_config(); + let result = validate_tls_config(&tls); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_tls_config_with_valid_files() { + // Create temporary files for testing + let mut cert_file = NamedTempFile::new().unwrap(); //#[allow_ci] + let mut key_file = NamedTempFile::new().unwrap(); //#[allow_ci] + + cert_file.write_all(b"dummy cert content").unwrap(); //#[allow_ci] + key_file.write_all(b"dummy key content").unwrap(); //#[allow_ci] + + let tls = TlsConfig { + client_cert: Some(cert_file.path().to_string_lossy().to_string()), + client_key: Some(key_file.path().to_string_lossy().to_string()), + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: true, + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }; + + let result = validate_tls_config(&tls); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_tls_config_missing_cert_file() { + let tls = TlsConfig { + client_cert: Some("/nonexistent/cert.pem".to_string()), + client_key: None, + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: true, + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }; + + let result = validate_tls_config(&tls); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Client certificate file not found")); + } + + #[test] + fn test_validate_tls_consistency_cert_without_key() { + let mut cert_file = NamedTempFile::new().unwrap(); //#[allow_ci] + cert_file.write_all(b"dummy cert content").unwrap(); //#[allow_ci] + + let tls = TlsConfig { + client_cert: Some(cert_file.path().to_string_lossy().to_string()), + client_key: None, // Missing key + client_key_password: None, + trusted_ca: vec![], + verify_server_cert: true, + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }; + + let result = validate_tls_config(&tls); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("must be specified together")); + } + + #[test] + fn test_validate_client_config_success() { + let client = create_valid_client_config(); + let result = validate_client_config(&client); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_client_config_zero_timeout() { + let mut client = create_valid_client_config(); + client.timeout = 0; + + let result = validate_client_config(&client); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("timeout cannot be 0")); + } + + #[test] + fn test_validate_client_config_negative_retry_interval() { + let mut client = create_valid_client_config(); + client.retry_interval = -1.0; + + let result = validate_client_config(&client); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("must be positive")); + } + + #[test] + fn test_validate_client_config_excessive_retries() { + let mut client = create_valid_client_config(); + client.max_retries = 50; + + let result = validate_client_config(&client); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("should not exceed 20")); + } + + #[test] + fn test_cross_component_validation_short_timeout_with_tls() { + let verifier = create_valid_verifier_config(); + let registrar = create_valid_registrar_config(); + let tls = create_valid_tls_config(); + let mut client = create_valid_client_config(); + client.timeout = 5; // Too short for TLS verification + + let result = + validate_complete_config(&verifier, ®istrar, &tls, &client); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("at least 10 seconds")); + } +} diff --git a/keylimectl/src/config_main.rs b/keylimectl/src/config_main.rs new file mode 100644 index 000000000..6706a2242 --- /dev/null +++ b/keylimectl/src/config_main.rs @@ -0,0 +1,1496 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Configuration management for keylimectl +//! +//! This module provides comprehensive configuration management for the keylimectl CLI tool. +//! It supports multiple configuration sources with a clear precedence order: +//! +//! 1. Command-line arguments (highest priority) +//! 2. Environment variables (prefixed with `KEYLIME_`) +//! 3. Configuration files (TOML format) +//! 4. Default values (lowest priority) +//! +//! # Configuration Sources +//! +//! ## Configuration Files (Optional) +//! Configuration files are completely optional. The system searches for TOML files in the following order: +//! - Explicit path provided via CLI argument `--config` (required to exist if specified) +//! - `.keylimectl/config.toml` (project-local) +//! - `keylimectl.toml` (current directory) +//! - `keylimectl.conf` (current directory) +//! - `~/.config/keylimectl/config.toml` (user, canonical) +//! - `$XDG_CONFIG_HOME/keylimectl/config.toml` (XDG override) +//! - `/etc/keylime/keylimectl.conf` (system-wide) +//! - `/usr/etc/keylime/keylimectl.conf` (alternative system-wide) +//! - Legacy paths: `~/.config/keylime/keylimectl.conf`, `~/.keylimectl.toml` +//! +//! If no configuration files are found, keylimectl will work perfectly with defaults and environment variables. +//! +//! ## Environment Variables +//! Environment variables use the prefix `KEYLIME_` with double underscores as separators: +//! - `KEYLIME_VERIFIER__IP=192.168.1.100` +//! - `KEYLIME_VERIFIER__PORT=8881` +//! - `KEYLIME_TLS__VERIFY_SERVER_CERT=false` +//! +//! ## Example Configuration File +//! +//! ```toml +//! [verifier] +//! ip = "127.0.0.1" +//! port = 8881 +//! id = "verifier-1" +//! +//! [registrar] +//! ip = "127.0.0.1" +//! port = 8891 +//! +//! [tls] +//! client_cert = "/path/to/client.crt" +//! client_key = "/path/to/client.key" +//! verify_server_cert = true +//! enable_agent_mtls = true +//! +//! [client] +//! timeout = 60 +//! max_retries = 3 +//! exponential_backoff = true +//! ``` +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::config::Config; +//! use keylimectl::Cli; +//! +//! // Load default configuration +//! let config = Config::default(); +//! +//! // Load from files and environment +//! let config = Config::load(None).expect("Failed to load config"); +//! +//! // Apply CLI overrides +//! let cli = Cli::default(); +//! let config = config.with_cli_overrides(&cli); +//! +//! // Validate configuration +//! config.validate().expect("Invalid configuration"); +//! +//! // Get service URLs +//! let verifier_url = config.verifier_base_url(); +//! let registrar_url = config.registrar_base_url(); +//! ``` + +use crate::Cli; +use config::{ConfigError, Environment, File, FileFormat}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Records which configuration fields were overridden by CLI arguments. +/// +/// This is used by the `info` command to annotate each config field with +/// its source (CLI, env var, config file, or default). +#[derive(Default, Debug, Clone)] +pub struct CliOverrides { + /// Whether `--verifier-ip` was provided + pub verifier_ip: bool, + /// Whether `--verifier-port` was provided + pub verifier_port: bool, + /// Whether `--registrar-ip` was provided + pub registrar_ip: bool, + /// Whether `--registrar-port` was provided + pub registrar_port: bool, + /// Whether `--timeout` was provided + pub timeout: bool, +} + +/// Main configuration structure for keylimectl +/// +/// This structure contains all configuration settings needed for keylimectl operations, +/// including service endpoints, TLS settings, and client behavior configuration. +/// +/// # Fields +/// +/// - `verifier`: Configuration for connecting to the Keylime verifier service +/// - `registrar`: Configuration for connecting to the Keylime registrar service +/// - `tls`: TLS/SSL security configuration +/// - `client`: HTTP client behavior and retry configuration +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Path of the configuration file that was loaded, if any + #[serde(skip)] + pub loaded_from: Option, + /// Records which fields were overridden by CLI arguments + #[serde(skip)] + pub cli_overrides: CliOverrides, + /// Verifier configuration + pub verifier: VerifierConfig, + /// Registrar configuration + pub registrar: RegistrarConfig, + /// TLS configuration + pub tls: TlsConfig, + /// Client configuration + pub client: ClientConfig, + /// Agent enrollment configuration (accepted TPM algorithms) + pub agent: AgentConfig, +} + +/// Configuration for the Keylime verifier service +/// +/// The verifier continuously monitors agent integrity and manages attestation policies. +/// This configuration specifies how to connect to the verifier service. +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::config::VerifierConfig; +/// +/// let config = VerifierConfig { +/// ip: "192.168.1.100".to_string(), +/// port: 8881, +/// id: Some("verifier-1".to_string()), +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifierConfig { + /// Verifier IP address + pub ip: String, + /// Verifier port + pub port: u16, + /// Verifier ID (optional) + pub id: Option, +} + +impl Default for VerifierConfig { + fn default() -> Self { + Self { + ip: "127.0.0.1".to_string(), + port: 8881, + id: None, + } + } +} + +/// Configuration for the Keylime registrar service +/// +/// The registrar maintains a database of registered agents and their TPM public keys. +/// This configuration specifies how to connect to the registrar service. +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::config::RegistrarConfig; +/// +/// let config = RegistrarConfig { +/// ip: "127.0.0.1".to_string(), +/// port: 8891, +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistrarConfig { + /// Registrar IP address + pub ip: String, + /// Registrar port + pub port: u16, +} + +impl Default for RegistrarConfig { + fn default() -> Self { + Self { + ip: "127.0.0.1".to_string(), + port: 8891, + } + } +} + +/// TLS/SSL security configuration +/// +/// This configuration controls how keylimectl establishes secure connections +/// to Keylime services, including client certificates and server verification. +/// +/// # Security Notes +/// +/// - `verify_server_cert` should only be disabled for testing +/// - Client certificates are required for mutual TLS authentication +/// - Trusted CA certificates ensure server identity verification +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::config::TlsConfig; +/// +/// let config = TlsConfig { +/// client_cert: Some("/path/to/client.crt".to_string()), +/// client_key: Some("/path/to/client.key".to_string()), +/// client_key_password: None, +/// trusted_ca: vec!["/path/to/ca.crt".to_string()], +/// verify_server_cert: true, +/// enable_agent_mtls: true, +/// accept_invalid_hostnames: false, +/// }; +/// ``` +#[derive(Clone, Serialize, Deserialize)] +pub struct TlsConfig { + /// Client certificate file path + pub client_cert: Option, + /// Client private key file path + pub client_key: Option, + /// Client key password + pub client_key_password: Option, + /// Trusted CA certificates + #[serde(default)] + pub trusted_ca: Vec, + /// Verify server certificates + pub verify_server_cert: bool, + /// Enable agent mTLS + pub enable_agent_mtls: bool, + /// Accept invalid hostnames in server certificates + /// + /// Set to `true` to skip hostname verification when certificates + /// lack proper SAN extensions. Set to `false` (default) for strict + /// hostname verification — certificates must include the correct + /// hostname/IP in the SAN extension. + #[serde(default = "default_accept_invalid_hostnames")] + pub accept_invalid_hostnames: bool, +} + +fn default_accept_invalid_hostnames() -> bool { + false +} + +impl std::fmt::Debug for TlsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsConfig") + .field("client_cert", &self.client_cert) + .field("client_key", &self.client_key) + .field( + "client_key_password", + &self.client_key_password.as_ref().map(|_| "[REDACTED]"), + ) + .field("trusted_ca", &self.trusted_ca) + .field("verify_server_cert", &self.verify_server_cert) + .field("enable_agent_mtls", &self.enable_agent_mtls) + .finish() + } +} + +impl Default for TlsConfig { + fn default() -> Self { + Self { + client_cert: Some( + "/var/lib/keylime/cv_ca/client-cert.crt".to_string(), + ), + client_key: Some( + "/var/lib/keylime/cv_ca/client-private.pem".to_string(), + ), + client_key_password: None, + trusted_ca: vec!["/var/lib/keylime/cv_ca/cacert.crt".to_string()], + verify_server_cert: true, + enable_agent_mtls: true, + accept_invalid_hostnames: false, + } + } +} + +/// HTTP client behavior and retry configuration +/// +/// This configuration controls how the HTTP client behaves when making requests +/// to Keylime services, including timeouts, retries, and backoff strategies. +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::config::ClientConfig; +/// +/// let config = ClientConfig { +/// timeout: 30, +/// retry_interval: 1.0, +/// exponential_backoff: true, +/// max_retries: 5, +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientConfig { + /// Request timeout in seconds + pub timeout: u64, + /// Retry interval in seconds + pub retry_interval: f64, + /// Use exponential backoff for retries + pub exponential_backoff: bool, + /// Maximum number of retries + pub max_retries: u32, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + timeout: 60, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + } + } +} + +/// Configuration for accepted TPM algorithms during agent enrollment +/// +/// These settings control which TPM algorithms the verifier will accept +/// when validating agent quotes. The defaults accept all commonly-used +/// algorithm variants (excluding known-weak ones like sha1, rsa1024, +/// ecc192, ecc224). +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::config::AgentConfig; +/// +/// // Restrict to only RSA-3072 encryption +/// let config = AgentConfig { +/// accept_tpm_encryption_algs: vec!["rsa3072".to_string()], +/// ..AgentConfig::default() +/// }; +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfig { + /// Accepted TPM hash algorithms for quote validation + pub accept_tpm_hash_algs: Vec, + /// Accepted TPM encryption algorithms for quote validation + pub accept_tpm_encryption_algs: Vec, + /// Accepted TPM signing algorithms for quote validation + pub accept_tpm_signing_algs: Vec, +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + accept_tpm_hash_algs: vec![ + "sha512".to_string(), + "sha384".to_string(), + "sha256".to_string(), + ], + accept_tpm_encryption_algs: vec![ + "rsa".to_string(), + "rsa2048".to_string(), + "rsa3072".to_string(), + "rsa4096".to_string(), + "ecc".to_string(), + "ecc256".to_string(), + "ecc384".to_string(), + "ecc521".to_string(), + ], + accept_tpm_signing_algs: vec![ + "ecschnorr".to_string(), + "rsassa".to_string(), + "rsapss".to_string(), + "ecdsa".to_string(), + "ecdaa".to_string(), + ], + } + } +} + +impl Config { + /// Check if a configuration file was loaded + #[must_use] + pub fn has_config_file(&self) -> bool { + self.loaded_from.is_some() + } + + /// Return the list of configuration file search paths. + /// + /// Used by the `info` command to show which paths were searched. + #[must_use] + pub fn config_search_paths() -> Vec { + Self::get_config_paths(None) + } + + /// Load configuration from multiple sources + /// + /// Loads configuration with the following precedence (highest to lowest): + /// 1. Environment variables (KEYLIME_*) + /// 2. Configuration files (TOML format) - **OPTIONAL** + /// 3. Default values + /// + /// Configuration files are completely optional. If no configuration files are found, + /// the system will use default values combined with any environment variables. + /// This allows keylimectl to work out-of-the-box without requiring any configuration. + /// + /// # Arguments + /// + /// * `config_path` - Optional explicit path to configuration file. + /// If None, searches standard locations. If Some() but file doesn't exist, returns error. + /// + /// # Returns + /// + /// Returns the merged configuration. Will not fail if no config files are found when + /// using automatic discovery (config_path = None). + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::config::Config; + /// + /// // Works with no config files - uses defaults + env vars + /// let config = Config::load(None)?; + /// + /// // Load from specific file (errors if file doesn't exist) + /// let config = Config::load(Some("/path/to/config.toml"))?; + /// # Ok::<(), config::ConfigError>(()) + /// ``` + /// + /// # Errors + /// + /// Returns ConfigError if: + /// - Explicit configuration file path provided but file doesn't exist + /// - Configuration file has invalid syntax + /// - Environment variables have invalid values + pub fn load(config_path: Option<&str>) -> Result { + let mut builder = config::Config::builder() + .add_source(config::Config::try_from(&Config::default())?); + + // Add configuration file sources + let config_paths = Self::get_config_paths(config_path); + let mut config_file_found = false; + + // Iterate in reverse: system paths added first (low priority), + // local paths added last (high priority, wins in merge) + for path in config_paths.iter().rev() { + if path.exists() { + config_file_found = true; + log::debug!("Loading config from: {}", path.display()); + builder = builder.add_source( + File::from(path.clone()) + .format(FileFormat::Toml) + .required(false), + ); + } + } + // loaded_from tracks the highest-precedence file + // (first existing in original order) + let loaded_path = config_paths.iter().find(|p| p.exists()).cloned(); + + // If an explicit config path was provided but the file doesn't exist, that's an error + if let Some(explicit_path) = config_path { + if !PathBuf::from(explicit_path).exists() { + return Err(ConfigError::Message(format!( + "Specified configuration file not found: {explicit_path}" + ))); + } + } + + // Add environment variables + builder = builder.add_source( + Environment::with_prefix("KEYLIME") + .prefix_separator("_") + .separator("__") + .try_parsing(true), + ); + + let mut config: Config = builder.build()?.try_deserialize()?; + config.loaded_from = loaded_path; + + // Log information about configuration sources used + if config_file_found { + log::debug!( + "Configuration loaded successfully with config files" + ); + } else { + log::info!("No configuration files found, using defaults and environment variables"); + } + + // Enforce restrictions on CWD-sourced configs to prevent planted + // config attacks (e.g. cloned repo with malicious keylimectl.conf) + if config_path.is_none() { + if let Some(ref path) = config.loaded_from { + if path.is_relative() { + log::warn!( + "Loading configuration from current directory: {}. \ + Security-sensitive settings are restricted.", + path.display() + ); + if !config.tls.verify_server_cert { + log::warn!( + "Ignoring verify_server_cert=false from CWD config — \ + use a system/user config or --config to disable" + ); + config.tls.verify_server_cert = true; + } + if config.tls.client_key_password.is_some() { + log::warn!( + "Ignoring client_key_password from CWD config — \ + use a system/user config, --config, or env var" + ); + config.tls.client_key_password = None; + } + } + } + } + + Ok(config) + } + + /// Apply command-line argument overrides + /// + /// CLI arguments have the highest precedence and will override any values + /// loaded from configuration files or environment variables. + /// + /// # Arguments + /// + /// * `cli` - Command-line arguments parsed by clap + /// + /// # Returns + /// + /// Returns the configuration with CLI overrides applied. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::config::Config; + /// use keylimectl::Cli; + /// + /// let config = Config::load(None)? + /// .with_cli_overrides(&cli); + /// # Ok::<(), config::ConfigError>(()) + /// ``` + pub fn with_cli_overrides(mut self, cli: &Cli) -> Self { + if let Some(ref ip) = cli.verifier_ip { + self.verifier.ip = ip.clone(); + self.cli_overrides.verifier_ip = true; + } + + if let Some(port) = cli.verifier_port { + self.verifier.port = port; + self.cli_overrides.verifier_port = true; + } + + if let Some(ref ip) = cli.registrar_ip { + self.registrar.ip = ip.clone(); + self.cli_overrides.registrar_ip = true; + } + + if let Some(port) = cli.registrar_port { + self.registrar.port = port; + self.cli_overrides.registrar_port = true; + } + + if let Some(timeout) = cli.timeout { + self.client.timeout = timeout; + self.cli_overrides.timeout = true; + } + + self + } + + /// Get configuration file search paths + /// + /// Returns paths in order of precedence (highest priority first): + /// 1. `.keylimectl/config.toml` (project-local) + /// 2. `keylimectl.toml` (current directory) + /// 3. `keylimectl.conf` (current directory) + /// 4. `~/.config/keylimectl/config.toml` (user, canonical) + /// 5. `$XDG_CONFIG_HOME/keylimectl/config.toml` (XDG override) + /// 6. `/etc/keylime/keylimectl.conf` (system-wide) + /// 7. `/usr/etc/keylime/keylimectl.conf` (alternative system-wide) + /// + /// Legacy paths (8-10) are included for backward compatibility. + /// + /// If `KEYLIMECTL_CONFIG` is set, its value is used as the sole + /// config file path, replacing the default search list entirely + /// (same pattern as `KEYLIME_AGENT_CONFIG` in the agent). + fn get_config_paths(config_path: Option<&str>) -> Vec { + let mut paths = Vec::new(); + + // If explicit path provided, use only that + if let Some(path) = config_path { + paths.push(PathBuf::from(path)); + return paths; + } + + // If KEYLIMECTL_CONFIG is set, use that path exclusively + if let Ok(config_env) = std::env::var("KEYLIMECTL_CONFIG") { + paths.push(PathBuf::from(config_env)); + return paths; + } + + // 1. Project-local directory + paths.push(PathBuf::from(".keylimectl/config.toml")); + + // 2-3. Current directory + paths.push(PathBuf::from("keylimectl.toml")); + paths.push(PathBuf::from("keylimectl.conf")); + + // 4. User config (canonical path) + if let Some(home) = std::env::var_os("HOME") { + let home_path = PathBuf::from(&home); + paths.push(home_path.join(".config/keylimectl/config.toml")); + } + + // 5. XDG config directory + if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") { + paths.push( + PathBuf::from(xdg_config).join("keylimectl/config.toml"), + ); + } + + // 6-7. System-wide + paths.push(PathBuf::from("/etc/keylime/keylimectl.conf")); + paths.push(PathBuf::from("/usr/etc/keylime/keylimectl.conf")); + + // 8-10. Legacy paths for backward compatibility + if let Some(home) = std::env::var_os("HOME") { + let home_path = PathBuf::from(&home); + paths.push(home_path.join(".config/keylime/keylimectl.conf")); + paths.push(home_path.join(".keylimectl.toml")); + } + if let Some(xdg_config) = std::env::var_os("XDG_CONFIG_HOME") { + paths.push( + PathBuf::from(xdg_config).join("keylime/keylimectl.conf"), + ); + } + + paths + } + + /// Get the verifier service base URL + /// + /// Constructs the complete HTTPS URL for the verifier service, + /// properly handling both IPv4 and IPv6 addresses. + /// + /// # Returns + /// + /// Returns the verifier base URL in the format `https://ip:port` + /// or `https://[ipv6]:port` for IPv6 addresses. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::config::Config; + /// + /// let config = Config::default(); + /// assert_eq!(config.verifier_base_url(), "https://127.0.0.1:8881"); + /// ``` + pub fn verifier_base_url(&self) -> String { + // Handle IPv6 addresses + if self.verifier.ip.contains(':') + && !self.verifier.ip.starts_with('[') + { + format!("https://[{}]:{}", self.verifier.ip, self.verifier.port) + } else { + format!("https://{}:{}", self.verifier.ip, self.verifier.port) + } + } + + /// Get the registrar service base URL + /// + /// Constructs the complete HTTPS URL for the registrar service, + /// properly handling both IPv4 and IPv6 addresses. + /// + /// # Returns + /// + /// Returns the registrar base URL in the format `https://ip:port` + /// or `https://[ipv6]:port` for IPv6 addresses. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::config::Config; + /// + /// let config = Config::default(); + /// assert_eq!(config.registrar_base_url(), "https://127.0.0.1:8891"); + /// ``` + pub fn registrar_base_url(&self) -> String { + // Handle IPv6 addresses + if self.registrar.ip.contains(':') + && !self.registrar.ip.starts_with('[') + { + format!("https://[{}]:{}", self.registrar.ip, self.registrar.port) + } else { + format!("https://{}:{}", self.registrar.ip, self.registrar.port) + } + } + + /// Validate the configuration for correctness + /// + /// Performs comprehensive validation of all configuration values, + /// checking for required fields, valid ranges, and file existence. + /// + /// # Returns + /// + /// Returns `Ok(())` if configuration is valid, or `ConfigError` + /// describing the first validation failure encountered. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::config::Config; + /// + /// let config = Config::default(); + /// config.validate().expect("Default config should be valid"); + /// ``` + /// + /// # Errors + /// + /// Returns ConfigError if: + /// - IP addresses are empty + /// - Ports are zero + /// - Certificate/key files don't exist + /// - Timeout is zero + /// - Retry interval is not positive + pub fn validate(&self) -> Result<(), ConfigError> { + // Use the extracted validation logic from the validation module + crate::config::validation::validate_complete_config( + &self.verifier, + &self.registrar, + &self.tls, + &self.client, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Helper function to create a test CLI instance + fn create_test_cli( + verifier_ip: Option, + verifier_port: Option, + registrar_ip: Option, + registrar_port: Option, + ) -> Cli { + Cli { + config: None, + verifier_ip, + verifier_port, + registrar_ip, + registrar_port, + timeout: None, + verbose: 0, + quiet: false, + color: crate::ColorMode::Never, + format: crate::OutputFormat::Json, + command: Some(crate::Commands::Agent { + action: crate::AgentAction::List { + detailed: false, + registrar_only: false, + }, + }), + } + } + + #[test] + fn test_default_config() { + let config = Config::default(); + + assert_eq!(config.verifier.ip, "127.0.0.1"); + assert_eq!(config.verifier.port, 8881); + assert!(config.verifier.id.is_none()); + + assert_eq!(config.registrar.ip, "127.0.0.1"); + assert_eq!(config.registrar.port, 8891); + + assert_eq!( + config.tls.client_cert, + Some("/var/lib/keylime/cv_ca/client-cert.crt".to_string()) + ); + assert_eq!( + config.tls.client_key, + Some("/var/lib/keylime/cv_ca/client-private.pem".to_string()) + ); + assert!(config.tls.verify_server_cert); + assert!(config.tls.enable_agent_mtls); + + assert_eq!(config.client.timeout, 60); + assert_eq!(config.client.max_retries, 3); + assert!(config.client.exponential_backoff); + } + + #[test] + fn test_verifier_base_url_ipv4() { + let config = Config { + verifier: VerifierConfig { + ip: "192.168.1.100".to_string(), + port: 8881, + id: None, + }, + ..Config::default() + }; + + assert_eq!(config.verifier_base_url(), "https://192.168.1.100:8881"); + } + + #[test] + fn test_verifier_base_url_ipv6() { + let config = Config { + verifier: VerifierConfig { + ip: "2001:db8::1".to_string(), + port: 8881, + id: None, + }, + ..Config::default() + }; + + assert_eq!(config.verifier_base_url(), "https://[2001:db8::1]:8881"); + } + + #[test] + fn test_verifier_base_url_ipv6_bracketed() { + let config = Config { + verifier: VerifierConfig { + ip: "[2001:db8::1]".to_string(), + port: 8881, + id: None, + }, + ..Config::default() + }; + + assert_eq!(config.verifier_base_url(), "https://[2001:db8::1]:8881"); + } + + #[test] + fn test_registrar_base_url_ipv4() { + let config = Config { + registrar: RegistrarConfig { + ip: "10.0.0.1".to_string(), + port: 9000, + }, + ..Config::default() + }; + + assert_eq!(config.registrar_base_url(), "https://10.0.0.1:9000"); + } + + #[test] + fn test_registrar_base_url_ipv6() { + let config = Config { + registrar: RegistrarConfig { + ip: "::1".to_string(), + port: 8891, + }, + ..Config::default() + }; + + assert_eq!(config.registrar_base_url(), "https://[::1]:8891"); + } + + #[test] + fn test_cli_overrides() { + let mut config = Config::default(); + + let cli = create_test_cli( + Some("10.0.0.1".to_string()), + Some(9001), + Some("10.0.0.2".to_string()), + Some(9002), + ); + + config = config.with_cli_overrides(&cli); + + assert_eq!(config.verifier.ip, "10.0.0.1"); + assert_eq!(config.verifier.port, 9001); + assert_eq!(config.registrar.ip, "10.0.0.2"); + assert_eq!(config.registrar.port, 9002); + } + + #[test] + fn test_cli_partial_overrides() { + let mut config = Config::default(); + + let cli = create_test_cli( + Some("192.168.1.1".to_string()), + None, + None, + None, + ); + + config = config.with_cli_overrides(&cli); + + assert_eq!(config.verifier.ip, "192.168.1.1"); + assert_eq!(config.verifier.port, 8881); // Should remain default + assert_eq!(config.registrar.ip, "127.0.0.1"); // Should remain default + } + + #[test] + fn test_cli_timeout_override() { + let config = Config::default(); + assert_eq!(config.client.timeout, 60); // Default + + let mut cli = create_test_cli(None, None, None, None); + cli.timeout = Some(120); + let config = config.with_cli_overrides(&cli); + assert_eq!(config.client.timeout, 120); + } + + #[test] + fn test_cli_timeout_no_override() { + let config = Config::default(); + let cli = create_test_cli(None, None, None, None); + let config = config.with_cli_overrides(&cli); + assert_eq!(config.client.timeout, 60); // Should remain default + } + + #[test] + fn test_validate_config_missing_certs() { + // Default config points to /var/lib/keylime/cv_ca/ which may or + // may not exist depending on the environment. Use paths that are + // guaranteed absent to verify that validation catches missing files. + let config = Config { + tls: TlsConfig { + client_cert: Some( + "/nonexistent/keylimectl-test/cert.crt".to_string(), + ), + client_key: Some( + "/nonexistent/keylimectl-test/key.pem".to_string(), + ), + trusted_ca: vec![ + "/nonexistent/keylimectl-test/ca.crt".to_string() + ], + ..TlsConfig::default() + }, + ..Config::default() + }; + assert!(config.validate().is_err()); + } + + #[test] + fn test_validate_empty_verifier_ip() { + let config = Config { + verifier: VerifierConfig { + ip: "".to_string(), + port: 8881, + id: None, + }, + ..Config::default() + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Verifier IP cannot be empty")); + } + + #[test] + fn test_validate_empty_registrar_ip() { + let config = Config { + registrar: RegistrarConfig { + ip: "".to_string(), + port: 8891, + }, + ..Config::default() + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Registrar IP cannot be empty")); + } + + #[test] + fn test_validate_zero_verifier_port() { + let config = Config { + verifier: VerifierConfig { + ip: "127.0.0.1".to_string(), + port: 0, + id: None, + }, + ..Config::default() + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Verifier port cannot be 0")); + } + + #[test] + fn test_validate_zero_registrar_port() { + let config = Config { + registrar: RegistrarConfig { + ip: "127.0.0.1".to_string(), + port: 0, + }, + ..Config::default() + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Registrar port cannot be 0")); + } + + #[test] + fn test_validate_nonexistent_cert_file() { + let config = Config { + tls: TlsConfig { + client_cert: Some("/nonexistent/cert.pem".to_string()), + ..TlsConfig::default() + }, + ..Config::default() + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Client certificate file not found")); + } + + #[test] + fn test_validate_nonexistent_key_file() { + let config = Config { + tls: TlsConfig { + client_cert: None, + client_key: Some("/nonexistent/key.pem".to_string()), + trusted_ca: vec![], + ..TlsConfig::default() + }, + ..Config::default() + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Client key file not found")); + } + + #[test] + fn test_validate_zero_timeout() { + let config = Config { + tls: TlsConfig { + client_cert: None, + client_key: None, + trusted_ca: vec![], + ..TlsConfig::default() + }, + client: ClientConfig { + timeout: 0, + retry_interval: 1.0, + exponential_backoff: true, + max_retries: 3, + }, + ..Config::default() + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Client timeout cannot be 0")); + } + + #[test] + fn test_validate_negative_retry_interval() { + let config = Config { + tls: TlsConfig { + client_cert: None, + client_key: None, + trusted_ca: vec![], + ..TlsConfig::default() + }, + client: ClientConfig { + timeout: 60, + retry_interval: -1.0, + exponential_backoff: true, + max_retries: 3, + }, + ..Config::default() + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Retry interval must be positive")); + } + + #[test] + fn test_validate_zero_retry_interval() { + let config = Config { + tls: TlsConfig { + client_cert: None, + client_key: None, + trusted_ca: vec![], + ..TlsConfig::default() + }, + client: ClientConfig { + timeout: 60, + retry_interval: 0.0, + exponential_backoff: true, + max_retries: 3, + }, + ..Config::default() + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Retry interval must be positive")); + } + + #[test] + fn test_validate_with_existing_cert_files() { + // Create temporary certificate and key files + let cert_file = NamedTempFile::new().unwrap(); //#[allow_ci] + let key_file = NamedTempFile::new().unwrap(); //#[allow_ci] + + let config = Config { + tls: TlsConfig { + client_cert: Some( + cert_file.path().to_string_lossy().to_string(), + ), + client_key: Some( + key_file.path().to_string_lossy().to_string(), + ), + client_key_password: None, + trusted_ca: vec![], // Empty trusted CA to avoid non-existent file validation + verify_server_cert: true, + enable_agent_mtls: true, + accept_invalid_hostnames: true, + }, + ..Config::default() + }; + + assert!(config.validate().is_ok()); + } + + #[test] + fn test_load_config_from_toml_string() { + let toml_content = r#" +[verifier] +ip = "10.0.0.1" +port = 9001 +id = "test-verifier" + +[registrar] +ip = "10.0.0.2" +port = 9002 + +[tls] +verify_server_cert = false +enable_agent_mtls = false +trusted_ca = [] + +[client] +timeout = 30 +max_retries = 5 +exponential_backoff = false +retry_interval = 2.0 +"#; + + // Create a temporary file with the TOML content + let mut temp_file = NamedTempFile::new().unwrap(); //#[allow_ci] + temp_file.write_all(toml_content.as_bytes()).unwrap(); //#[allow_ci] + temp_file.flush().unwrap(); //#[allow_ci] + + let config = + Config::load(Some(temp_file.path().to_str().unwrap())).unwrap(); //#[allow_ci] + + assert_eq!(config.verifier.ip, "10.0.0.1"); + assert_eq!(config.verifier.port, 9001); + assert_eq!(config.verifier.id, Some("test-verifier".to_string())); + + assert_eq!(config.registrar.ip, "10.0.0.2"); + assert_eq!(config.registrar.port, 9002); + + assert!(!config.tls.verify_server_cert); + assert!(!config.tls.enable_agent_mtls); + + assert_eq!(config.client.timeout, 30); + assert_eq!(config.client.max_retries, 5); + assert!(!config.client.exponential_backoff); + assert_eq!(config.client.retry_interval, 2.0); + } + + #[test] + fn test_load_config_no_files() { + // Test loading config when no config files exist + // This should always succeed with defaults since config files are optional + let result = Config::load(None); + + // Should always succeed now that config files are optional + match result { + Ok(config) => { + assert_eq!(config.verifier.ip, "127.0.0.1"); // Default value + assert_eq!(config.verifier.port, 8881); // Default value + assert_eq!(config.registrar.ip, "127.0.0.1"); // Default value + assert_eq!(config.registrar.port, 8891); // Default value + } + Err(e) => { + panic!("Config load with no files should succeed: {e:?}"); //#[allow_ci] + } + } + } + + #[test] + fn test_load_config_explicit_file_not_found() { + // Test that explicit config file paths are still required to exist + let result = Config::load(Some("/nonexistent/path/config.toml")); + + assert!( + result.is_err(), + "Should error when explicit config file doesn't exist" + ); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("Specified configuration file not found")); + assert!(error_msg.contains("/nonexistent/path/config.toml")); + } + + #[test] + fn test_get_config_paths_explicit() { + let paths = Config::get_config_paths(Some("/custom/path.toml")); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0], PathBuf::from("/custom/path.toml")); + } + + #[test] + fn test_get_config_paths_standard() { + let paths = Config::get_config_paths(None); + + // Project-local should be first + assert_eq!(paths[0], PathBuf::from(".keylimectl/config.toml")); + + // Should include standard paths + assert!(paths.contains(&PathBuf::from("keylimectl.toml"))); + assert!(paths.contains(&PathBuf::from("keylimectl.conf"))); + assert!( + paths.contains(&PathBuf::from("/etc/keylime/keylimectl.conf")) + ); + assert!(paths + .contains(&PathBuf::from("/usr/etc/keylime/keylimectl.conf"))); + } + + #[test] + fn test_loaded_from_with_explicit_file() { + let mut temp_file = NamedTempFile::new().unwrap(); //#[allow_ci] + temp_file //#[allow_ci] + .write_all(b"[verifier]\nip = \"10.0.0.1\"\n") + .unwrap(); //#[allow_ci] + temp_file.flush().unwrap(); //#[allow_ci] + + let config = Config::load(Some( + temp_file.path().to_str().unwrap(), //#[allow_ci] + )) + .unwrap(); //#[allow_ci] + assert!(config.has_config_file()); + assert_eq!( + config.loaded_from.unwrap(), //#[allow_ci] + temp_file.path() + ); + } + + #[test] + fn test_loaded_from_default_is_none() { + let config = Config::default(); + assert!(!config.has_config_file()); + assert!(config.loaded_from.is_none()); + } + + #[test] + fn test_config_serialization() { + let config = Config::default(); + + // Test that config can be serialized to and from TOML + let toml_str = toml::to_string(&config).unwrap(); //#[allow_ci] + let deserialized: Config = toml::from_str(&toml_str).unwrap(); //#[allow_ci] + + assert_eq!(config.verifier.ip, deserialized.verifier.ip); + assert_eq!(config.verifier.port, deserialized.verifier.port); + assert_eq!(config.registrar.ip, deserialized.registrar.ip); + assert_eq!(config.registrar.port, deserialized.registrar.port); + } + + #[test] + fn test_tls_config_defaults() { + let tls_config = TlsConfig::default(); + + assert_eq!( + tls_config.client_cert, + Some("/var/lib/keylime/cv_ca/client-cert.crt".to_string()) + ); + assert_eq!( + tls_config.client_key, + Some("/var/lib/keylime/cv_ca/client-private.pem".to_string()) + ); + assert!(tls_config.client_key_password.is_none()); + assert_eq!( + tls_config.trusted_ca, + vec!["/var/lib/keylime/cv_ca/cacert.crt".to_string()] + ); + assert!(tls_config.verify_server_cert); + assert!(tls_config.enable_agent_mtls); + } + + #[test] + fn test_client_config_defaults() { + let client_config = ClientConfig::default(); + + assert_eq!(client_config.timeout, 60); + assert_eq!(client_config.retry_interval, 1.0); + assert!(client_config.exponential_backoff); + assert_eq!(client_config.max_retries, 3); + } + + #[test] + fn test_agent_config_defaults_include_all_strong_encryption_algs() { + let agent = AgentConfig::default(); + + assert!(agent + .accept_tpm_encryption_algs + .contains(&"rsa".to_string())); + assert!(agent + .accept_tpm_encryption_algs + .contains(&"rsa2048".to_string())); + assert!(agent + .accept_tpm_encryption_algs + .contains(&"rsa3072".to_string())); + assert!(agent + .accept_tpm_encryption_algs + .contains(&"rsa4096".to_string())); + assert!(agent + .accept_tpm_encryption_algs + .contains(&"ecc".to_string())); + assert!(agent + .accept_tpm_encryption_algs + .contains(&"ecc256".to_string())); + assert!(agent + .accept_tpm_encryption_algs + .contains(&"ecc384".to_string())); + assert!(agent + .accept_tpm_encryption_algs + .contains(&"ecc521".to_string())); + } + + #[test] + fn test_agent_config_defaults_exclude_weak_encryption_algs() { + let agent = AgentConfig::default(); + + assert!( + !agent + .accept_tpm_encryption_algs + .contains(&"rsa1024".to_string()), + "rsa1024 is weak and should not be in defaults" + ); + assert!( + !agent + .accept_tpm_encryption_algs + .contains(&"ecc192".to_string()), + "ecc192 is weak and should not be in defaults" + ); + assert!( + !agent + .accept_tpm_encryption_algs + .contains(&"ecc224".to_string()), + "ecc224 is weak and should not be in defaults" + ); + } + + #[test] + fn test_agent_config_defaults_exclude_sha1() { + let agent = AgentConfig::default(); + + assert!( + !agent.accept_tpm_hash_algs.contains(&"sha1".to_string()), + "sha1 is weak and should not be in defaults" + ); + assert!(agent.accept_tpm_hash_algs.contains(&"sha256".to_string())); + assert!(agent.accept_tpm_hash_algs.contains(&"sha384".to_string())); + assert!(agent.accept_tpm_hash_algs.contains(&"sha512".to_string())); + } + + #[test] + fn test_agent_config_defaults_signing_algs() { + let agent = AgentConfig::default(); + + assert!(agent + .accept_tpm_signing_algs + .contains(&"rsassa".to_string())); + assert!(agent + .accept_tpm_signing_algs + .contains(&"rsapss".to_string())); + assert!(agent + .accept_tpm_signing_algs + .contains(&"ecschnorr".to_string())); + assert!(agent.accept_tpm_signing_algs.contains(&"ecdsa".to_string())); + assert!(agent.accept_tpm_signing_algs.contains(&"ecdaa".to_string())); + } + + #[test] + fn test_agent_config_from_toml_override() { + let toml_str = r#" +[verifier] +ip = "127.0.0.1" +port = 8881 + +[registrar] +ip = "127.0.0.1" +port = 8891 + +[tls] +verify_server_cert = true +enable_agent_mtls = true +trusted_ca = [] + +[client] +timeout = 60 +retry_interval = 1.0 +exponential_backoff = true +max_retries = 3 + +[agent] +accept_tpm_encryption_algs = ["rsa3072"] +accept_tpm_hash_algs = ["sha512"] +accept_tpm_signing_algs = ["rsassa"] +"#; + + let config: Config = toml::from_str(toml_str).unwrap(); //#[allow_ci] + assert_eq!( + config.agent.accept_tpm_encryption_algs, + vec!["rsa3072".to_string()] + ); + assert_eq!( + config.agent.accept_tpm_hash_algs, + vec!["sha512".to_string()] + ); + assert_eq!( + config.agent.accept_tpm_signing_algs, + vec!["rsassa".to_string()] + ); + } + + #[test] + fn test_agent_config_roundtrips_through_toml() { + let config = Config::default(); + let toml_str = toml::to_string_pretty(&config).unwrap(); //#[allow_ci] + let parsed: Config = toml::from_str(&toml_str).unwrap(); //#[allow_ci] + + assert_eq!( + parsed.agent.accept_tpm_encryption_algs, + config.agent.accept_tpm_encryption_algs + ); + assert_eq!( + parsed.agent.accept_tpm_hash_algs, + config.agent.accept_tpm_hash_algs + ); + assert_eq!( + parsed.agent.accept_tpm_signing_algs, + config.agent.accept_tpm_signing_algs + ); + } +} diff --git a/keylimectl/src/error.rs b/keylimectl/src/error.rs new file mode 100644 index 000000000..fc5f81c15 --- /dev/null +++ b/keylimectl/src/error.rs @@ -0,0 +1,592 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Error handling for keylimectl +//! +//! This module provides comprehensive error types and utilities for the keylimectl CLI tool. +//! It includes: +//! +//! - [`KeylimectlError`] - Main error enum covering all error types +//! - [`ErrorContext`] - Trait for adding context to errors +//! - JSON serialization support for structured error output +//! +//! # Examples +//! +//! ```rust +//! use keylimectl::error::{KeylimectlError, ErrorContext}; +//! +//! // Create an API error +//! let api_err = KeylimectlError::api_error(404, "Agent not found".to_string(), None); +//! +//! // Add context to an error +//! let result: Result<(), std::io::Error> = Err(std::io::Error::new( +//! std::io::ErrorKind::NotFound, +//! "file not found" +//! )); +//! let with_context = result.with_context(|| "Failed to read config file".to_string()); +//! ``` + +use serde_json::Value; +use thiserror::Error; + +/// Main error type for keylimectl operations +/// +/// This enum covers all possible error conditions that can occur during keylimectl operations, +/// from configuration issues to network failures and API errors. +#[derive(Error, Debug)] +pub enum KeylimectlError { + /// Configuration errors + #[error("Configuration error: {0}")] + Config(#[from] config::ConfigError), + + /// Network/HTTP errors + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + /// Request middleware errors + #[error("Request middleware error: {0}")] + RequestMiddleware(#[from] reqwest_middleware::Error), + + /// API errors from the verifier/registrar + #[error("API error: {message} (status: {status})")] + Api { + /// HTTP status code + status: u16, + /// Error message from the server + message: String, + /// Full response body if available + response: Option, + }, + + /// Agent not found errors + #[error("Agent {uuid} not found on {service}")] + #[cfg(test)] + AgentNotFound { + /// Agent UUID + uuid: String, + /// Service name (verifier/registrar) + service: String, + }, + + /// Policy not found errors + #[error("Policy '{name}' not found")] + #[cfg(test)] + PolicyNotFound { + /// Policy name + name: String, + }, + + /// Validation errors + #[error("Validation error: {0}")] + Validation(String), + + /// Validation/verification completed but the result was negative + #[error("{message}")] + ValidationFailed { + /// Human-readable summary + message: String, + /// Structured details for JSON output + details: Value, + }, + + /// File I/O errors + #[error("File error: {0}")] + Io(#[from] std::io::Error), + + /// JSON parsing errors + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// UUID parsing errors + #[error("Invalid UUID: {0}")] + Uuid(#[from] uuid::Error), + + /// Client-specific errors + #[error("Client error: {0}")] + Client(#[from] crate::client::error::ClientError), + + /// Command-specific errors + #[error("Command error: {0}")] + Command(#[from] crate::commands::error::CommandError), + + /// Generic errors with context + #[error("Error: {0}")] + Generic(#[from] anyhow::Error), +} + +impl KeylimectlError { + /// Create a new API error + /// + /// # Arguments + /// + /// * `status` - HTTP status code + /// * `message` - Error message from the server + /// * `response` - Optional full response body + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::error::KeylimectlError; + /// + /// let error = KeylimectlError::api_error( + /// 404, + /// "Agent not found".to_string(), + /// None + /// ); + /// ``` + pub fn api_error( + status: u16, + message: String, + response: Option, + ) -> Self { + Self::Api { + status, + message, + response, + } + } + + /// Create a new validation error + /// + /// # Arguments + /// + /// * `message` - Validation error message + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::error::KeylimectlError; + /// + /// let error = KeylimectlError::validation("Invalid UUID format"); + /// ``` + pub fn validation>(message: T) -> Self { + Self::Validation(message.into()) + } + + /// Create a validation-failed error (validation ran but result was negative) + pub fn validation_failed>( + message: T, + details: Value, + ) -> Self { + Self::ValidationFailed { + message: message.into(), + details, + } + } + + /// Create a new agent not found error + /// + /// # Arguments + /// + /// * `uuid` - Agent UUID + /// * `service` - Service name (verifier/registrar) + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::error::KeylimectlError; + /// + /// let error = KeylimectlError::agent_not_found("12345", "verifier"); + /// ``` + #[cfg(test)] + pub fn agent_not_found, U: Into>( + uuid: T, + service: U, + ) -> Self { + Self::AgentNotFound { + uuid: uuid.into(), + service: service.into(), + } + } + + /// Create a new policy not found error + /// + /// # Arguments + /// + /// * `name` - Policy name + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::error::KeylimectlError; + /// + /// let error = KeylimectlError::policy_not_found("my_policy"); + /// ``` + #[cfg(test)] + pub fn policy_not_found>(name: T) -> Self { + Self::PolicyNotFound { name: name.into() } + } + + /// Get the error code for JSON output + /// + /// Returns a string constant that identifies the error type for programmatic use. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::error::KeylimectlError; + /// + /// let error = KeylimectlError::validation("test"); + /// assert_eq!(error.error_code(), "VALIDATION_ERROR"); + /// ``` + pub fn error_code(&self) -> &'static str { + match self { + Self::Config(_) => "CONFIG_ERROR", + Self::Network(_) => "NETWORK_ERROR", + Self::Api { .. } => "API_ERROR", + #[cfg(test)] + Self::AgentNotFound { .. } => "AGENT_NOT_FOUND", + #[cfg(test)] + Self::PolicyNotFound { .. } => "POLICY_NOT_FOUND", + Self::Validation(_) => "VALIDATION_ERROR", + Self::ValidationFailed { .. } => "VALIDATION_FAILED", + Self::Io(_) => "IO_ERROR", + Self::Json(_) => "JSON_ERROR", + Self::Uuid(_) => "UUID_ERROR", + Self::Client(_) => "CLIENT_ERROR", + Self::Command(_) => "COMMAND_ERROR", + Self::Generic(_) => "GENERIC_ERROR", + Self::RequestMiddleware(_) => "REQUEST_MIDDLEWARE_ERROR", + } + } + + /// Get the process exit code for this error. + /// + /// Returns 10 for `ValidationFailed` (the operation completed but the + /// result was negative — check JSON for details) and 1 for all other + /// errors (infrastructure/command failures). + pub fn exit_code(&self) -> i32 { + match self { + Self::ValidationFailed { .. } => 10, + _ => 1, + } + } + + /// Check if this error is retryable + /// + /// Returns true if the operation that caused this error should be retried. + /// Generally, network errors and 5xx server errors are retryable. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::error::KeylimectlError; + /// + /// let network_error = KeylimectlError::Network(reqwest::Error::from( + /// reqwest::Error::from(std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout")) + /// )); + /// assert!(network_error.is_retryable()); + /// + /// let validation_error = KeylimectlError::validation("bad input"); + /// assert!(!validation_error.is_retryable()); + /// ``` + #[cfg(test)] + pub fn is_retryable(&self) -> bool { + match self { + Self::Network(_) => true, + Self::Api { status, .. } => *status >= 500, + Self::Client(_) => false, // Client errors are generally not retryable + Self::Command(_) => false, // Command errors are generally not retryable + _ => false, + } + } + + /// Convert to JSON value for output + /// + /// Creates a structured JSON representation of the error suitable for CLI output. + /// + /// # Examples + /// + /// ```rust + /// use keylimectl::error::KeylimectlError; + /// + /// let error = KeylimectlError::validation("test error"); + /// let json = error.to_json(); + /// + /// assert_eq!(json["error"]["code"], "VALIDATION_ERROR"); + /// assert_eq!(json["error"]["message"], "Validation error: test error"); + /// ``` + pub fn to_json(&self) -> Value { + serde_json::json!({ + "error": { + "code": self.error_code(), + "message": self.to_string(), + "details": self.error_details() + } + }) + } + + /// Get additional error details for JSON output + fn error_details(&self) -> Value { + match self { + Self::Api { + status, response, .. + } => serde_json::json!({ + "http_status": status, + "response": response + }), + #[cfg(test)] + Self::AgentNotFound { uuid, service } => serde_json::json!({ + "agent_uuid": uuid, + "service": service + }), + #[cfg(test)] + Self::PolicyNotFound { name } => serde_json::json!({ + "policy_name": name + }), + Self::ValidationFailed { details, .. } => details.clone(), + _ => Value::Null, + } + } +} + +/// Helper trait for adding context to results +/// +/// This trait provides convenient methods for adding contextual information to errors, +/// making debugging easier by providing a chain of what went wrong. It leverages +/// `anyhow` for rich error context while preserving backtrace information. +/// +/// # Examples +/// +/// ```rust +/// use keylimectl::error::{KeylimectlError, ErrorContext}; +/// +/// fn read_file() -> Result { +/// std::fs::read_to_string("nonexistent.txt") +/// } +/// +/// let result = read_file() +/// .with_context(|| "Failed to read configuration file".to_string()); +/// ``` +pub trait ErrorContext { + /// Add context to an error with full backtrace preservation + /// + /// Uses `anyhow` to provide rich context while maintaining error chains. + /// This is the recommended way to add context for user-facing errors. + /// + /// # Arguments + /// + /// * `f` - Closure that returns the context message + fn with_context(self, f: F) -> Result + where + F: FnOnce() -> String; +} + +impl ErrorContext for Result +where + E: Into, +{ + fn with_context(self, f: F) -> Result + where + F: FnOnce() -> String, + { + self.map_err(|e| { + let base_error = e.into(); + // Use anyhow to maintain full error chain with backtrace + KeylimectlError::Generic( + anyhow::Error::new(base_error).context(f()), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_api_error_creation() { + let error = KeylimectlError::api_error( + 404, + "Not found".to_string(), + Some(json!({"error": "agent not found"})), + ); + + match error { + KeylimectlError::Api { + status, + message, + response, + } => { + assert_eq!(status, 404); + assert_eq!(message, "Not found"); + assert!(response.is_some()); + } + _ => panic!("Expected API error"), //#[allow_ci] + } + } + + #[test] + fn test_validation_error() { + let error = KeylimectlError::validation("Invalid input"); + assert_eq!(error.error_code(), "VALIDATION_ERROR"); + assert_eq!(error.to_string(), "Validation error: Invalid input"); + } + + #[test] + fn test_agent_not_found_error() { + let error = KeylimectlError::agent_not_found("12345", "verifier"); + + match &error { + KeylimectlError::AgentNotFound { uuid, service } => { + assert_eq!(uuid, "12345"); + assert_eq!(service, "verifier"); + } + _ => panic!("Expected AgentNotFound error"), //#[allow_ci] + } + + assert_eq!(error.error_code(), "AGENT_NOT_FOUND"); + } + + #[test] + fn test_policy_not_found_error() { + let error = KeylimectlError::policy_not_found("my_policy"); + + match &error { + KeylimectlError::PolicyNotFound { name } => { + assert_eq!(name, "my_policy"); + } + _ => panic!("Expected PolicyNotFound error"), //#[allow_ci] + } + + assert_eq!(error.error_code(), "POLICY_NOT_FOUND"); + } + + #[test] + fn test_error_codes() { + assert_eq!( + KeylimectlError::validation("test").error_code(), + "VALIDATION_ERROR" + ); + assert_eq!( + KeylimectlError::agent_not_found("test", "verifier").error_code(), + "AGENT_NOT_FOUND" + ); + assert_eq!( + KeylimectlError::policy_not_found("test").error_code(), + "POLICY_NOT_FOUND" + ); + } + + #[test] + fn test_is_retryable() { + // Test API errors + + // 5xx errors should be retryable + let server_error = KeylimectlError::api_error( + 500, + "Internal error".to_string(), + None, + ); + assert!(server_error.is_retryable()); + + let bad_gateway = + KeylimectlError::api_error(502, "Bad gateway".to_string(), None); + assert!(bad_gateway.is_retryable()); + + // 4xx errors should not be retryable + let client_error = + KeylimectlError::api_error(400, "Bad request".to_string(), None); + assert!(!client_error.is_retryable()); + + let not_found = + KeylimectlError::api_error(404, "Not found".to_string(), None); + assert!(!not_found.is_retryable()); + + // Validation errors should not be retryable + let validation_error = KeylimectlError::validation("Invalid input"); + assert!(!validation_error.is_retryable()); + + // IO errors should not be retryable + let io_error = KeylimectlError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "file not found", + )); + assert!(!io_error.is_retryable()); + } + + #[test] + fn test_to_json() { + let error = KeylimectlError::validation("test error"); + let json = error.to_json(); + + assert_eq!(json["error"]["code"], "VALIDATION_ERROR"); + assert_eq!(json["error"]["message"], "Validation error: test error"); + assert_eq!(json["error"]["details"], Value::Null); + } + + #[test] + fn test_api_error_to_json() { + let response = json!({"error": "not found"}); + let error = KeylimectlError::api_error( + 404, + "Not found".to_string(), + Some(response.clone()), + ); + let json = error.to_json(); + + assert_eq!(json["error"]["code"], "API_ERROR"); + assert_eq!(json["error"]["details"]["http_status"], 404); + assert_eq!(json["error"]["details"]["response"], response); + } + + #[test] + fn test_agent_not_found_to_json() { + let error = KeylimectlError::agent_not_found("12345", "verifier"); + let json = error.to_json(); + + assert_eq!(json["error"]["code"], "AGENT_NOT_FOUND"); + assert_eq!(json["error"]["details"]["agent_uuid"], "12345"); + assert_eq!(json["error"]["details"]["service"], "verifier"); + } + + #[test] + fn test_validation_failed() { + let details = json!({ + "valid": false, + "policy_type": "runtime", + "errors": [{"code": "bad_digest", "message": "Invalid digest"}], + "warnings": [] + }); + let error = KeylimectlError::validation_failed( + "Policy validation failed (runtime)", + details.clone(), + ); + assert_eq!(error.error_code(), "VALIDATION_FAILED"); + assert_eq!(error.to_string(), "Policy validation failed (runtime)"); + let json_output = error.to_json(); + assert_eq!(json_output["error"]["code"], "VALIDATION_FAILED"); + assert_eq!(json_output["error"]["details"], details); + assert_eq!(error.exit_code(), 10); + } + + #[test] + fn test_exit_codes() { + assert_eq!(KeylimectlError::validation("bad input").exit_code(), 1); + assert_eq!( + KeylimectlError::validation_failed( + "failed", + json!({"valid": false}) + ) + .exit_code(), + 10 + ); + } + + #[test] + fn test_with_context() { + let io_error: Result<(), std::io::Error> = Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "file not found", + )); + + let result = io_error + .with_context(|| "Failed to read config file".to_string()); + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.error_code(), "GENERIC_ERROR"); + assert!(error.to_string().contains("Failed to read config file")); + } +} diff --git a/keylimectl/src/main.rs b/keylimectl/src/main.rs new file mode 100644 index 000000000..b4916a18b --- /dev/null +++ b/keylimectl/src/main.rs @@ -0,0 +1,1178 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! # keylimectl +//! +//! A modern, user-friendly command-line tool for Keylime remote attestation. +//! This tool replaces the Python keylime_tenant with improved usability while +//! maintaining full API compatibility. + +#![deny( + nonstandard_style, + dead_code, + improper_ctypes, + non_shorthand_field_patterns, + no_mangle_generic_items, + overflowing_literals, + path_statements, + patterns_in_fns_without_body, + unconditional_recursion, + unused, + while_true, + missing_copy_implementations, + missing_debug_implementations, + missing_docs, + trivial_casts, + trivial_numeric_casts, + unused_allocation, + unused_comparisons, + unused_parens, + unused_extern_crates, + unused_import_braces, + unused_qualifications, + unused_results +)] + +// Ensure at least one API version feature is enabled +#[cfg(not(any(feature = "api-v2", feature = "api-v3")))] +compile_error!( + "At least one of the 'api-v2' or 'api-v3' features must be enabled. \ + Use '--features api-v2' or '--features api-v3' or both." +); + +mod api_versions; +mod client; +mod commands; +mod config; +mod error; +mod output; +mod policy_tools; + +use anyhow::Result; +use clap::{CommandFactory, Parser, Subcommand}; +use log::{debug, error, warn}; +use serde_json::Value; +use std::process; + +use crate::config::Config; +use crate::error::KeylimectlError; +use crate::output::OutputHandler; + +/// Modern command-line tool for Keylime remote attestation +#[derive(Parser)] +#[command( + name = "keylimectl", + version, + about = "A modern command-line tool for Keylime remote attestation", + long_about = "keylimectl provides an intuitive interface for managing Keylime agents, \ + policies, and attestation. It replaces keylime_tenant with improved \ + usability while maintaining full API compatibility.", + after_long_help = "CONFIGURATION SOURCES (highest to lowest priority):\n \ + 1. Command-line arguments (--verifier-ip, --timeout, etc.)\n \ + 2. Environment variables (KEYLIME_VERIFIER__IP, KEYLIME_CLIENT__TIMEOUT, etc.)\n \ + 3. Configuration files (keylimectl.toml, ~/.config/keylimectl/config.toml, etc.)\n \ + 4. Built-in defaults\n\n\ + Run `keylimectl configure` to create a configuration file interactively." +)] +struct Cli { + /// Configuration file path + #[arg(short, long, value_name = "FILE")] + config: Option, + + /// Verifier IP address [default: 127.0.0.1] + #[arg(long, value_name = "IP")] + verifier_ip: Option, + + /// Verifier port [default: 8881] + #[arg(long, value_name = "PORT")] + verifier_port: Option, + + /// Registrar IP address [default: 127.0.0.1] + #[arg(long, value_name = "IP")] + registrar_ip: Option, + + /// Registrar port [default: 8891] + #[arg(long, value_name = "PORT")] + registrar_port: Option, + + /// Request timeout in seconds [default: 60] + #[arg(long, value_name = "SECONDS")] + timeout: Option, + + /// Enable verbose logging + #[arg(short, long, action = clap::ArgAction::Count)] + verbose: u8, + + /// Suppress all output except JSON results + #[arg(short, long)] + quiet: bool, + + /// Output format + #[arg(long, value_enum, default_value = "json")] + format: OutputFormat, + + /// Color output mode + #[arg(long, value_enum, default_value = "auto")] + color: ColorMode, + + #[command(subcommand)] + command: Option, +} + +/// Available output formats +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +pub enum OutputFormat { + /// JSON output (default) + Json, + /// Human-readable table format + Table, + /// YAML output + Yaml, +} + +/// Color output mode +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +pub enum ColorMode { + /// Auto-detect based on terminal + Auto, + /// Always use colors + Always, + /// Never use colors + Never, +} + +/// Available commands +#[allow(clippy::large_enum_variant)] +#[derive(Subcommand)] +enum Commands { + /// Manage agents + Agent { + #[command(subcommand)] + action: AgentAction, + }, + /// Manage runtime policies + Policy { + #[command(subcommand)] + action: PolicyAction, + }, + /// Manage measured boot policies + #[command(alias = "mb")] + MeasuredBoot { + #[command(subcommand)] + action: MeasuredBootAction, + }, + /// Show diagnostic information + #[command(alias = "diag")] + Info { + #[command(subcommand)] + subcommand: Option, + }, + /// Verify attestation evidence against a verifier + Verify { + #[command(subcommand)] + action: VerifyAction, + }, + /// Create or update a configuration file + Configure { + /// Run without interactive prompts + #[arg(long)] + non_interactive: bool, + + /// Configuration scope + #[arg(long, value_enum, default_value = "user")] + scope: ConfigScope, + + /// Verifier IP for non-interactive mode + #[arg(long, value_name = "IP")] + verifier_ip: Option, + + /// Verifier port for non-interactive mode + #[arg(long, value_name = "PORT")] + verifier_port: Option, + + /// Registrar IP for non-interactive mode + #[arg(long, value_name = "IP")] + registrar_ip: Option, + + /// Registrar port for non-interactive mode + #[arg(long, value_name = "PORT")] + registrar_port: Option, + + /// Test connectivity after configuration + #[arg(long)] + test_connectivity: bool, + }, +} + +/// Agent management actions +#[derive(Subcommand)] +enum AgentAction { + /// Add an agent to the verifier + Add { + /// Agent identifier (can be any string, not necessarily a UUID) + #[arg(value_name = "AGENT_ID")] + uuid: String, + + /// Agent IP address (if not using push model) + #[arg(long, value_name = "IP")] + ip: Option, + + /// Agent port (if not using push model) + #[arg(long, value_name = "PORT")] + port: Option, + + /// Verifier IP for the agent to connect to + #[arg(long, value_name = "IP")] + verifier_ip: Option, + + /// Runtime policy to apply + #[arg(long, value_name = "POLICY")] + runtime_policy: Option, + + /// Name for the runtime policy in the verifier database + #[arg(long, value_name = "NAME")] + runtime_policy_name: Option, + + /// Public key file to verify the runtime policy DSSE signature + #[arg(long, value_name = "FILE")] + runtime_policy_sig_key: Option, + + /// Measured boot policy to apply + #[arg(long, value_name = "POLICY")] + mb_policy: Option, + + /// Payload file to deliver securely + #[arg(long, value_name = "FILE")] + payload: Option, + + /// Certificate directory for secure delivery + #[arg(long, value_name = "DIR")] + cert_dir: Option, + + /// Verify cryptographic key derivation + #[arg(long)] + verify: bool, + + /// Use push model (agent connects to verifier) + #[arg(long, conflicts_with = "pull_model")] + push_model: bool, + + /// Force pull model (legacy API 2.x behavior, overrides auto-detection) + #[arg(long, conflicts_with = "push_model")] + pull_model: bool, + + /// TPM policy in JSON format + #[arg(long, value_name = "POLICY")] + tpm_policy: Option, + + /// Allow attestation with unverified TPM quotes (INSECURE: for development only) + #[arg(long)] + allow_unverified_quote: bool, + + /// Wait for first attestation to complete after enrollment + #[arg(long)] + wait_for_attestation: bool, + + /// Timeout in seconds for --wait-for-attestation (default: 60) + #[arg(long, value_name = "SECONDS", default_value_t = 60)] + attestation_timeout: u64, + }, + + /// Remove an agent from the verifier + Remove { + /// Agent identifier + #[arg(value_name = "AGENT_ID")] + uuid: String, + + /// Also remove from registrar + #[arg(long = "registrar")] + registrar: bool, + + /// Skip verifier checks (force removal) + #[arg(long)] + force: bool, + }, + + /// Update an existing agent + Update { + /// Agent identifier + #[arg(value_name = "AGENT_ID")] + uuid: String, + + /// New runtime policy + #[arg(long, value_name = "POLICY")] + runtime_policy: Option, + + /// Name for the runtime policy in the verifier database + #[arg(long, value_name = "NAME")] + runtime_policy_name: Option, + + /// Public key file to verify the runtime policy DSSE signature + #[arg(long, value_name = "FILE")] + runtime_policy_sig_key: Option, + + /// New measured boot policy + #[arg(long, value_name = "POLICY")] + mb_policy: Option, + }, + + /// Show agent status + Status { + /// Agent identifier + #[arg(value_name = "AGENT_ID")] + uuid: String, + + /// Check verifier only + #[arg(long = "verifier")] + verifier: bool, + + /// Check registrar only + #[arg(long = "registrar")] + registrar_only: bool, + }, + + /// Reactivate a failed agent + Reactivate { + /// Agent identifier + #[arg(value_name = "AGENT_ID")] + uuid: String, + }, + + /// List all agents + List { + /// Show detailed information + #[arg(long)] + detailed: bool, + + /// List agents from registrar only + #[arg(long = "registrar")] + registrar_only: bool, + }, +} + +/// Policy management actions +#[derive(Subcommand)] +#[allow(clippy::large_enum_variant)] +enum PolicyAction { + /// Push a runtime policy to the verifier + Push { + /// Policy name + #[arg(value_name = "NAME")] + name: String, + + /// Policy file path + #[arg(long, value_name = "FILE")] + file: String, + }, + + /// Show a runtime policy from the verifier + Show { + /// Policy name + #[arg(value_name = "NAME")] + name: String, + }, + + /// Update an existing runtime policy on the verifier + Update { + /// Policy name + #[arg(value_name = "NAME")] + name: String, + + /// Policy file path + #[arg(long, value_name = "FILE")] + file: String, + }, + + /// Delete a runtime policy from the verifier + Delete { + /// Policy name + #[arg(value_name = "NAME")] + name: String, + }, + + /// List all runtime policies + List, + + /// Generate a policy locally from input sources + Generate { + #[command(subcommand)] + subcommand: GenerateSubcommand, + }, + + /// Sign a policy file using DSSE + Sign { + /// Policy file to sign + #[arg(value_name = "FILE")] + file: String, + + /// Private key file to sign with (generates new key if omitted) + #[arg(short, long, value_name = "FILE")] + keyfile: Option, + + /// Path to save generated private key + #[arg(short = 'p', long, value_name = "PATH")] + keypath: Option, + + /// Signing backend + #[arg(short, long, value_enum, default_value = "ecdsa")] + backend: SigningBackend, + + /// Output file for signed policy + #[arg(short, long, value_name = "FILE")] + output: Option, + + /// Input X.509 certificate file when using --keyfile (x509 backend only) + #[arg(short = 'C', long, value_name = "FILE")] + cert_file: Option, + + /// Output file for generated X.509 certificate (x509 backend only) + #[arg(short = 'c', long, value_name = "FILE")] + cert_outfile: Option, + }, + + /// Verify the DSSE signature on a signed policy + VerifySignature { + /// Signed policy file to verify + #[arg(value_name = "FILE")] + file: String, + + /// Public key or certificate file to verify against + #[arg(short, long, value_name = "FILE")] + key: String, + }, + + /// Validate a policy file structure and content + Validate { + /// Policy file to validate + #[arg(value_name = "FILE")] + file: String, + + /// Policy type (auto-detected if omitted) + #[arg( + short = 't', + long, + value_name = "TYPE", + value_parser = ["runtime", "measured-boot", "tpm"] + )] + policy_type: Option, + + /// Also verify DSSE signature using this key + #[arg(short = 's', long, value_name = "FILE")] + signature_key: Option, + }, + + /// Convert a legacy allowlist to the current policy format + Convert { + /// Input allowlist or policy file + #[arg(value_name = "FILE")] + file: String, + + /// Output file (required) + #[arg(short, long, value_name = "FILE")] + output: String, + + /// Exclude list file to merge + #[arg(short, long, value_name = "FILE")] + excludelist: Option, + + /// Verification key files to add + #[arg(short = 'v', long, value_name = "FILES")] + verification_keys: Option, + }, + + /// Merge two runtime policies into one (union of digests, excludes, keyrings) + Merge { + /// Base policy file + #[arg(value_name = "BASE")] + base: String, + + /// Policy file to merge into base + #[arg(value_name = "OTHER")] + other: String, + + /// Output file (stdout if omitted) + #[arg(short, long, value_name = "FILE")] + output: Option, + }, +} + +impl PolicyAction { + /// Returns true if this action operates entirely locally + /// (no network connectivity required). + fn is_local_only(&self) -> bool { + matches!( + self, + PolicyAction::Generate { .. } + | PolicyAction::Sign { .. } + | PolicyAction::VerifySignature { .. } + | PolicyAction::Validate { .. } + | PolicyAction::Convert { .. } + | PolicyAction::Merge { .. } + ) + } +} + +/// Policy generation subcommands +#[derive(Subcommand)] +#[allow(clippy::large_enum_variant)] +enum GenerateSubcommand { + /// Generate a runtime policy from IMA logs, allowlists, or filesystem + Runtime { + /// Run the interactive wizard to guide policy creation + #[cfg(feature = "wizard")] + #[arg(long, short = 'I')] + interactive: bool, + + /// IMA measurement list path. If -m is given without a value, uses the + /// default: /sys/kernel/security/ima/ascii_runtime_measurements + #[arg( + short = 'm', + long, + value_name = "FILE", + num_args = 0..=1, + default_missing_value = "/sys/kernel/security/ima/ascii_runtime_measurements", + )] + ima_measurement_list: Option, + + /// Plain-text allowlist file + #[arg(short, long, value_name = "FILE")] + allowlist: Option, + + /// Root filesystem path to scan + #[arg(long, value_name = "PATH")] + rootfs: Option, + + /// Paths to skip during filesystem scan (repeatable) + #[arg(long, value_name = "PATH")] + skip_path: Vec, + + /// Base policy to merge into + #[arg(short = 'B', long, value_name = "FILE")] + base_policy: Option, + + /// IMA exclude list file + #[arg(short, long, value_name = "FILE")] + excludelist: Option, + + /// Output file (stdout if omitted) + #[arg(short, long, value_name = "FILE")] + output: Option, + + /// Include keyrings entries + #[arg(short, long)] + keyrings: bool, + + /// Include ima-buf entries + #[arg(long)] + ima_buf: bool, + + /// Keyrings to ignore (repeatable) + #[arg(short, long, value_name = "KEYRING")] + ignored_keyrings: Vec, + + /// Add IMA signature verification key (repeatable) + #[arg(short = 'A', long, value_name = "FILE")] + add_ima_signature_verification_key: Vec, + + /// Hash algorithm (auto-detected if omitted) + #[arg(long, value_name = "ALG")] + hash_alg: Option, + + /// Directory containing initramfs files (e.g., /boot) + #[arg(long, value_name = "DIR")] + ramdisk_dir: Option, + + /// Local RPM repository directory + #[cfg(feature = "rpm-repo")] + #[arg(long, value_name = "DIR")] + local_rpm_repo: Option, + + /// Remote RPM repository URL + #[cfg(feature = "rpm-repo")] + #[arg(long, value_name = "URL")] + remote_rpm_repo: Option, + + /// GPG public key file for verifying RPM repository metadata signatures + #[cfg(feature = "rpm-repo")] + #[arg(long, value_name = "FILE")] + gpg_key: Option, + + /// Allow unsigned remote RPM repository metadata + #[cfg(feature = "rpm-repo")] + #[arg(long)] + allow_unsigned_repo: bool, + }, + + /// Generate a measured boot policy from a UEFI event log + MeasuredBoot { + /// Run the interactive wizard to guide policy creation + #[cfg(feature = "wizard")] + #[arg(long, short = 'I')] + interactive: bool, + + /// UEFI event log file + #[arg( + long, + value_name = "FILE", + default_value = "/sys/kernel/security/tpm0/binary_bios_measurements" + )] + eventlog_file: String, + + /// Generate policy without Secure Boot variables + #[arg(long)] + without_secureboot: bool, + + /// Output file (stdout if omitted) + #[arg(short, long, value_name = "FILE")] + output: Option, + }, + + /// Generate a TPM policy from PCR values + Tpm { + /// Run the interactive wizard to guide policy creation + #[cfg(feature = "wizard")] + #[arg(long, short = 'I')] + interactive: bool, + + /// Read PCR values from file (one per line) + #[arg(long, value_name = "FILE", group = "pcr_source")] + pcr_file: Option, + + /// Read PCR values from local TPM + #[cfg(any(feature = "tpm-local", feature = "tpm-quote-validation"))] + #[arg(long, group = "pcr_source")] + from_tpm: bool, + + /// PCR indices to include (comma-separated, e.g., "0,1,2,7") + #[arg( + long, + value_name = "INDICES", + default_value = "0,1,2,3,4,5,6,7" + )] + pcrs: String, + + /// PCR mask (overrides --pcrs, e.g., "0x408000") + #[arg(long, value_name = "MASK")] + mask: Option, + + /// Hash algorithm + #[arg(long, value_name = "ALG", default_value = "sha256")] + hash_alg: String, + + /// Output file (stdout if omitted) + #[arg(short, long, value_name = "FILE")] + output: Option, + }, +} + +/// Signing backend for policy signing +#[derive(Clone, Debug, clap::ValueEnum)] +enum SigningBackend { + /// ECDSA P-256 signing (default) + Ecdsa, + /// X.509 certificate-based signing + X509, +} + +/// Measured boot policy actions +#[derive(Subcommand)] +enum MeasuredBootAction { + /// List all measured boot policies + List, + + /// Push a measured boot policy to the verifier + Push { + /// Policy name + #[arg(value_name = "NAME")] + name: String, + + /// Policy file path + #[arg(long, value_name = "FILE")] + file: String, + }, + + /// Show a measured boot policy + Show { + /// Policy name + #[arg(value_name = "NAME")] + name: String, + }, + + /// Update an existing measured boot policy + Update { + /// Policy name + #[arg(value_name = "NAME")] + name: String, + + /// Policy file path + #[arg(long, value_name = "FILE")] + file: String, + }, + + /// Delete a measured boot policy + Delete { + /// Policy name + #[arg(value_name = "NAME")] + name: String, + }, +} + +/// Configuration scope for the `configure` command +#[derive(Clone, Debug, clap::ValueEnum)] +enum ConfigScope { + /// Local directory: ./.keylimectl/config.toml + Local, + /// User home: ~/.config/keylimectl/config.toml + User, + /// System-wide: /etc/keylime/keylimectl.conf + System, +} + +/// Info subcommands for diagnostic inspection +#[derive(Subcommand)] +enum InfoSubcommand { + /// Show verifier status and API version + Verifier, + /// Show registrar status and API version + Registrar, + /// Show detailed information for a specific agent + Agent { + /// Agent identifier + #[arg(value_name = "AGENT_ID")] + agent_id: String, + }, + /// Validate TLS certificates and test connectivity + Tls, +} + +/// Evidence verification actions +#[derive(Subcommand)] +enum VerifyAction { + /// Verify TPM or TEE attestation evidence + Evidence { + /// Run the interactive wizard to guide evidence verification + #[cfg(feature = "wizard")] + #[arg(long, short = 'I')] + interactive: bool, + + /// Nonce used for the quote + #[arg( + long, + value_name = "NONCE", + required_unless_present = "interactive" + )] + nonce: Option, + + /// TPM quote file + #[arg( + long, + value_name = "FILE", + required_unless_present = "interactive" + )] + quote: Option, + + /// Hash algorithm + #[arg(long, value_name = "ALG", default_value = "sha256")] + hash_alg: String, + + /// TPM Attestation Key (AK) file + #[arg( + long, + value_name = "FILE", + required_unless_present = "interactive" + )] + tpm_ak: Option, + + /// TPM Endorsement Key (EK) file + #[arg( + long, + value_name = "FILE", + required_unless_present = "interactive" + )] + tpm_ek: Option, + + /// Runtime policy file + #[arg(long, value_name = "FILE")] + runtime_policy: Option, + + /// IMA measurement list file + #[arg(long, value_name = "FILE")] + ima_measurement_list: Option, + + /// Measured boot policy file + #[arg(long, value_name = "FILE")] + mb_policy: Option, + + /// Measured boot log file + #[arg(long, value_name = "FILE")] + mb_log: Option, + + /// TPM policy file + #[arg(long, value_name = "FILE")] + tpm_policy: Option, + + /// Evidence type + #[arg( + long, + value_name = "TYPE", + default_value = "tpm", + value_parser = ["tpm", "tee"] + )] + evidence_type: String, + }, +} + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + // Initialize logging based on verbosity + init_logging(cli.verbose, cli.quiet, &cli.color); + + // Load configuration + let config = match Config::load(cli.config.as_deref()) { + Ok(config) => { + debug!("Loaded configuration with TLS settings: client_cert={:?}, client_key={:?}, trusted_ca={:?}", + config.tls.client_cert, config.tls.client_key, config.tls.trusted_ca); + config + } + Err(e) => { + error!("Failed to load configuration: {e}"); + process::exit(1); + } + }; + + // Override config with CLI arguments + let config = config.with_cli_overrides(&cli); + debug!("Final configuration after CLI overrides: client_cert={:?}, client_key={:?}, trusted_ca={:?}", + config.tls.client_cert, config.tls.client_key, config.tls.trusted_ca); + + match cli.command { + Some(ref command @ Commands::Configure { .. }) => { + // Configure command does not require config validation + // or the singleton — it creates/updates configuration. + let output = OutputHandler::new(cli.format, cli.quiet, cli.color); + + let result = execute_command(command, &output).await; + + match result { + Ok(response) => { + output.success(response); + } + Err(e) => { + let code = e.exit_code(); + error!("Command failed: {e}"); + output.error(e); + process::exit(code); + } + } + } + Some( + ref command @ Commands::Policy { + action: + ref action @ PolicyAction::Generate { .. } + | ref action @ PolicyAction::Sign { .. } + | ref action @ PolicyAction::VerifySignature { .. } + | ref action @ PolicyAction::Validate { .. } + | ref action @ PolicyAction::Convert { .. } + | ref action @ PolicyAction::Merge { .. }, + }, + ) if action.is_local_only() => { + // Local-only policy commands do not require network + // connectivity or valid TLS configuration. + if let Err(e) = config.validate() { + warn!("Configuration validation: {e}"); + } + + if let Err(e) = config::singleton::initialize_config(config) { + error!("Failed to initialize config singleton: {e}"); + process::exit(1); + } + + let output = OutputHandler::new(cli.format, cli.quiet, cli.color); + + let result = execute_command(command, &output).await; + + match result { + Ok(response) => { + output.success(response); + } + Err(e) => { + let code = e.exit_code(); + error!("Command failed: {e}"); + output.error(e); + process::exit(code); + } + } + } + Some( + ref command @ Commands::Policy { + action: + ref action @ PolicyAction::Generate { .. } + | ref action @ PolicyAction::Sign { .. } + | ref action @ PolicyAction::VerifySignature { .. } + | ref action @ PolicyAction::Validate { .. } + | ref action @ PolicyAction::Convert { .. }, + }, + ) if action.is_local_only() => { + // Local-only policy commands do not require network + // connectivity or valid TLS configuration. + if let Err(e) = config.validate() { + warn!("Configuration validation: {e}"); + } + + if let Err(e) = config::singleton::initialize_config(config) { + error!("Failed to initialize config singleton: {e}"); + process::exit(1); + } + + let output = OutputHandler::new(cli.format, cli.quiet, cli.color); + + let result = execute_command(command, &output).await; + + match result { + Ok(response) => { + output.success(response); + } + Err(e) => { + let code = e.exit_code(); + error!("Command failed: {e}"); + output.error(e); + process::exit(code); + } + } + } + Some(ref command @ Commands::Info { .. }) => { + // Info commands should work even with incomplete config. + // Warn on validation failures instead of exiting. + if let Err(e) = config.validate() { + warn!("Configuration validation: {e}"); + } + + // Always initialize singleton so info subcommands can + // use get_config() uniformly. + if let Err(e) = config::singleton::initialize_config(config) { + error!("Failed to initialize config singleton: {e}"); + process::exit(1); + } + + let output = OutputHandler::new(cli.format, cli.quiet, cli.color); + + let result = execute_command(command, &output).await; + + match result { + Ok(response) => { + output.success(response); + } + Err(e) => { + let code = e.exit_code(); + error!("Command failed: {e}"); + output.error(e); + process::exit(code); + } + } + } + Some(ref command) => { + // Validate the final configuration strictly for commands + if let Err(e) = config.validate() { + error!("Configuration validation failed: {e}"); + process::exit(1); + } + debug!("Configuration validation passed"); + + // Initialize config singleton + if let Err(e) = config::singleton::initialize_config(config) { + error!("Failed to initialize config singleton: {e}"); + process::exit(1); + } + + // Initialize output handler + let output = OutputHandler::new(cli.format, cli.quiet, cli.color); + + // Execute command + let result = execute_command(command, &output).await; + + match result { + Ok(response) => { + output.success(response); + } + Err(e) => { + let code = e.exit_code(); + error!("Command failed: {e}"); + output.error(e); + process::exit(code); + } + } + } + None => { + // Warn about validation issues but don't exit + if let Err(e) = config.validate() { + warn!("Configuration validation: {e}"); + } + + handle_no_command(&config); + } + } +} + +/// Initialize logging based on verbosity level and color mode +/// +/// Wraps the logger in a [`SpinnerAwareLogger`] so that log messages +/// suspend active progress bars before writing to stderr, preventing +/// garbled output. +fn init_logging(verbose: u8, quiet: bool, color: &ColorMode) { + if quiet { + return; + } + + let log_level = match verbose { + 0 => log::LevelFilter::Warn, + 1 => log::LevelFilter::Info, + 2 => log::LevelFilter::Debug, + _ => log::LevelFilter::Trace, + }; + + let write_style = match color { + ColorMode::Never => pretty_env_logger::env_logger::WriteStyle::Never, + ColorMode::Always => { + pretty_env_logger::env_logger::WriteStyle::Always + } + ColorMode::Auto => pretty_env_logger::env_logger::WriteStyle::Auto, + }; + + let logger = pretty_env_logger::formatted_builder() + .filter_level(log_level) + .target(pretty_env_logger::env_logger::Target::Stderr) + .write_style(write_style) + .build(); + + let max_level = logger.filter(); + + // Wrap the logger so log output suspends any active spinners + log::set_boxed_logger(Box::new(SpinnerAwareLogger { inner: logger })) + .expect("failed to set logger"); //#[allow_ci] + log::set_max_level(max_level); +} + +/// Logger wrapper that suspends progress bar rendering during log writes. +/// +/// Without this, `log::warn!()` and other log macros write directly to +/// stderr, which collides with indicatif's spinner rendering and produces +/// garbled output. +struct SpinnerAwareLogger { + inner: pretty_env_logger::env_logger::Logger, +} + +impl log::Log for SpinnerAwareLogger { + fn enabled(&self, metadata: &log::Metadata) -> bool { + self.inner.enabled(metadata) + } + + fn log(&self, record: &log::Record) { + if self.inner.enabled(record.metadata()) { + output::get_multi_progress().suspend(|| self.inner.log(record)); + } + } + + fn flush(&self) { + self.inner.flush(); + } +} + +/// Handle the case when no subcommand is provided. +/// +/// Shows a configuration summary followed by clap's auto-generated help text. +fn handle_no_command(config: &Config) { + use std::io::IsTerminal; + + print_config_summary(config); + + if !config.has_config_file() { + eprintln!("No configuration file found."); + if std::io::stdin().is_terminal() { + eprintln!(" Tip: Run `keylimectl configure` to create one."); + } + eprintln!(); + } + + // Print clap's auto-generated help (subcommands, options, etc.) + // This stays in sync automatically as commands are added/removed. + let mut cmd = Cli::command(); + let _ = cmd.print_help(); +} + +/// Print a summary of the current configuration to stderr. +fn print_config_summary(config: &Config) { + if let Some(ref path) = config.loaded_from { + eprintln!("Configuration: {}", path.display()); + } else { + eprintln!("Configuration: (defaults)"); + } + eprintln!( + "Verifier: {}:{}", + config.verifier.ip, config.verifier.port + ); + eprintln!( + "Registrar: {}:{}", + config.registrar.ip, config.registrar.port + ); + eprintln!("TLS: {}", tls_summary(&config.tls)); + eprintln!(); +} + +/// Generate a short summary of the TLS configuration. +fn tls_summary(tls: &config::TlsConfig) -> &'static str { + if tls.client_cert.is_some() && tls.verify_server_cert { + "mTLS enabled, server verification on" + } else if tls.client_cert.is_some() { + "mTLS enabled, server verification off" + } else if tls.verify_server_cert { + "server verification on" + } else { + "disabled" + } +} + +/// Execute the given command +async fn execute_command( + command: &Commands, + output: &OutputHandler, +) -> Result { + match command { + Commands::Agent { action } => { + commands::agent::execute(action, output).await + } + Commands::Policy { action } => { + commands::policy::execute(action, output).await + } + Commands::MeasuredBoot { action } => { + commands::measured_boot::execute(action, output).await + } + Commands::Info { subcommand } => { + commands::info::execute(subcommand, output).await + } + Commands::Verify { action } => { + commands::verify::execute(action, output).await + } + Commands::Configure { + non_interactive, + scope, + verifier_ip, + verifier_port, + registrar_ip, + registrar_port, + test_connectivity, + } => { + let params = commands::configure::ConfigureParams { + non_interactive: *non_interactive, + scope, + verifier_ip: verifier_ip.as_deref(), + verifier_port: *verifier_port, + registrar_ip: registrar_ip.as_deref(), + registrar_port: *registrar_port, + test_connectivity: *test_connectivity, + }; + commands::configure::execute(¶ms, output).await + } + } +} diff --git a/keylimectl/src/output.rs b/keylimectl/src/output.rs new file mode 100644 index 000000000..d78d71afd --- /dev/null +++ b/keylimectl/src/output.rs @@ -0,0 +1,795 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Output formatting and handling for keylimectl +//! +//! This module provides flexible output formatting capabilities for the keylimectl CLI tool. +//! It supports multiple output formats, animated progress spinners, and optional colors. +//! +//! # Features +//! +//! - **Multiple formats**: JSON, human-readable tables, and YAML-like output +//! - **Structured output**: JSON to stdout, logs to stderr for scriptability +//! - **Progress spinners**: Animated spinners for long-running operations (TTY only) +//! - **Optional colors**: `--color=auto|always|never` for stderr messages +//! - **Wait handles**: RAII spinners for polling loops with auto-cleanup + +use crate::error::KeylimectlError; +use console::Style; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use serde_json::Value; +use std::cell::RefCell; +use std::sync::OnceLock; +use std::time::Duration; + +/// Global `MultiProgress` that coordinates all spinner rendering with log output. +/// +/// All progress bars are created through this instance so that `log` messages +/// (routed via [`SpinnerAwareLogger`](crate::SpinnerAwareLogger)) can suspend +/// rendering before writing, preventing garbled output. +static MULTI_PROGRESS: OnceLock = OnceLock::new(); + +/// Get the global `MultiProgress` instance (lazily initialized). +pub fn get_multi_progress() -> &'static MultiProgress { + MULTI_PROGRESS.get_or_init(MultiProgress::new) +} + +/// Output format options +/// +/// Determines how the output will be formatted and displayed to the user. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + /// JSON output - structured data suitable for machine processing + Json, + /// Human-readable table format - formatted for easy reading + Table, + /// YAML output - human-readable structured format + Yaml, +} + +impl From for Format { + fn from(format: crate::OutputFormat) -> Self { + match format { + crate::OutputFormat::Json => Format::Json, + crate::OutputFormat::Table => Format::Table, + crate::OutputFormat::Yaml => Format::Yaml, + } + } +} + +/// Output handler for formatting and displaying results +/// +/// The OutputHandler manages all output formatting, progress spinners, and +/// color styling for keylimectl. Spinners are only shown when stderr is a +/// terminal; piped or redirected output falls back to plain text. +/// +/// # Design Principles +/// +/// - JSON output goes to stdout for machine processing +/// - Human-readable messages and spinners go to stderr +/// - Quiet mode suppresses non-essential output +/// - Spinners auto-detect TTY; plain text fallback when piped +/// - Colors apply to stderr only; stdout stays plain +#[derive(Debug)] +pub struct OutputHandler { + format: Format, + quiet: bool, + use_spinner: bool, + use_color: bool, + active_spinner: RefCell>, + /// Whether the active spinner should leave a permanent trace when finished. + /// `step()` spinners leave a trace; `progress()` spinners are transient. + spinner_is_step: RefCell, +} + +impl OutputHandler { + /// Create a new output handler + /// + /// Resolves spinner and color settings based on the color mode and + /// whether stderr is a terminal. + pub fn new( + format: crate::OutputFormat, + quiet: bool, + color: crate::ColorMode, + ) -> Self { + let is_tty = console::Term::stderr().is_term(); + let use_color = match color { + crate::ColorMode::Auto => is_tty, + crate::ColorMode::Always => true, + crate::ColorMode::Never => false, + }; + let use_spinner = is_tty && !quiet; + + if !use_color { + console::set_colors_enabled_stderr(false); + } + + Self { + format: format.into(), + quiet, + use_spinner, + use_color, + active_spinner: RefCell::new(None), + spinner_is_step: RefCell::new(false), + } + } + + /// Output a successful result + /// + /// Finishes any active spinner then formats and displays the result. + /// Output goes to stdout. + pub fn success(&self, value: Value) { + self.finish_spinner(); + + let output = match self.format { + Format::Json => self.format_json(value), + Format::Table => self.format_table(value), + Format::Yaml => self.format_yaml(value), + }; + + println!("{output}"); + } + + /// Output an error + /// + /// Finishes any active spinner then formats and displays the error. + pub fn error(&self, error: KeylimectlError) { + self.finish_spinner(); + + let error_json = error.to_json(); + + match self.format { + Format::Json => { + println!( + "{}", + serde_json::to_string_pretty(&error_json) + .unwrap_or_default() + ); + } + Format::Table | Format::Yaml => { + let prefix = if self.use_color { + format!("{}", Style::new().red().bold().apply_to("Error")) + } else { + "Error".to_string() + }; + eprintln!("{prefix}: {error}"); + if let Some(details) = + error_json.get("error").and_then(|e| e.get("details")) + { + if !details.is_null() { + eprintln!( + "Details: {}", + serde_json::to_string_pretty(details) + .unwrap_or_default() + ); + } + } + } + } + } + + /// Display informational message (only if not quiet) + /// + /// Prints directly to stderr (not through `log::info!`) so that + /// user-facing status messages always appear regardless of log level. + /// If a spinner is active, it is finished first. + pub fn info>(&self, message: T) { + if self.quiet { + return; + } + self.finish_spinner(); + let msg = message.as_ref(); + let _ = get_multi_progress().println(format!(" {msg}")); + } + + /// Display a progress message with animated spinner + /// + /// When stderr is a TTY, shows an animated spinner. When piped or + /// in quiet mode, falls back to plain text or suppresses output. + pub fn progress>(&self, message: T) { + if self.quiet { + return; + } + + let msg = message.into(); + + if self.use_spinner { + self.finish_spinner(); + let pb = get_multi_progress().add(ProgressBar::new_spinner()); + pb.set_style(self.spinner_style()); + pb.set_message(msg); + pb.enable_steady_tick(Duration::from_millis(80)); + *self.active_spinner.borrow_mut() = Some(pb); + *self.spinner_is_step.borrow_mut() = false; + } else { + let _ = get_multi_progress().println(format!(" {msg}")); + } + } + + /// Display a step in a multi-step operation with animated spinner + /// + /// Shows `[N/TOTAL] message` with a spinner when on a TTY. + pub fn step>(&self, step: u8, total: u8, message: T) { + if self.quiet { + return; + } + + let msg = format!("[{step}/{total}] {}", message.as_ref()); + + if self.use_spinner { + self.finish_spinner(); + let pb = get_multi_progress().add(ProgressBar::new_spinner()); + pb.set_style(self.spinner_style()); + pb.set_message(msg); + pb.enable_steady_tick(Duration::from_millis(80)); + *self.active_spinner.borrow_mut() = Some(pb); + *self.spinner_is_step.borrow_mut() = true; + } else { + let _ = get_multi_progress().println(format!(" {msg}")); + } + } + + /// Start a spinner for an indeterminate wait + /// + /// Returns a `WaitHandle` that keeps the spinner alive until dropped. + /// The spinner message can be updated via `WaitHandle::set_message()`. + /// Useful for polling loops where the wait duration is unknown. + pub fn start_wait>(&self, message: T) -> WaitHandle { + if self.quiet || !self.use_spinner { + if !self.quiet { + let _ = get_multi_progress() + .println(format!(" {}", message.into())); + } + return WaitHandle { spinner: None }; + } + + self.finish_spinner(); + let pb = get_multi_progress().add(ProgressBar::new_spinner()); + pb.set_style(self.spinner_style()); + pb.set_message(message.into()); + pb.enable_steady_tick(Duration::from_millis(80)); + + WaitHandle { spinner: Some(pb) } + } + + /// Finish any active spinner + /// + /// For step spinners, prints the message as a permanent line so that + /// completed steps always leave a trace even if they finish faster + /// than a single frame can render. Progress spinners are transient + /// and disappear silently when replaced. + pub fn finish_spinner(&self) { + if let Some(pb) = self.active_spinner.borrow_mut().take() { + let is_step = *self.spinner_is_step.borrow(); + let msg = pb.message(); + pb.finish_and_clear(); + if is_step && !msg.is_empty() { + let _ = get_multi_progress().println(format!(" {msg}")); + } + } + } + + /// Build the spinner progress style + fn spinner_style(&self) -> ProgressStyle { + let template = if self.use_color { + "{spinner:.cyan} {msg}" + } else { + "{spinner} {msg}" + }; + ProgressStyle::with_template(template) + .expect("valid spinner template") //#[allow_ci] + } + + /// Format value as JSON + fn format_json(&self, value: Value) -> String { + serde_json::to_string_pretty(&value) + .unwrap_or_else(|_| "{}".to_string()) + } + + /// Format value as human-readable table + fn format_table(&self, value: Value) -> String { + match value { + Value::Object(map) => { + let mut output = String::new(); + + if let Some(results) = map.get("results") { + match results { + Value::Object(results_map) => { + if results_map.len() == 1 { + let (uuid, agent_data) = + results_map.iter().next().unwrap(); //#[allow_ci] + output.push_str(&format!("Agent: {uuid}\n")); + output.push_str( + &self.format_agent_table(agent_data), + ); + } else { + output.push_str("Agents:\n"); + for (uuid, agent_data) in results_map { + output.push_str(&format!(" {uuid}:\n")); + output.push_str( + &self.format_agent_table_indented( + agent_data, + ), + ); + } + } + } + Value::Array(results_array) => { + if results_array.is_empty() { + output.push_str("(no results)\n"); + } else { + for (i, item) in + results_array.iter().enumerate() + { + if i > 0 { + output.push('\n'); + } + output.push_str( + &self.format_table_item(item), + ); + } + } + } + _ => { + output.push_str( + &serde_json::to_string_pretty(results) + .unwrap_or_default(), + ); + } + } + } else if map.is_empty() { + output.push_str("(empty)\n"); + } else { + for (key, value) in map { + output.push_str(&format!( + "{key}: {}\n", + self.format_value_brief(&value) + )); + } + } + + output + } + _ => serde_json::to_string_pretty(&value).unwrap_or_default(), + } + } + + /// Format value as YAML + fn format_yaml(&self, value: Value) -> String { + self.value_to_yaml(&value, 0) + } + + /// Format agent data as a table + fn format_agent_table(&self, agent_data: &Value) -> String { + let mut output = String::new(); + + if let Value::Object(map) = agent_data { + let important_fields = [ + "operational_state", + "ip", + "port", + "verifier_ip", + "verifier_port", + ]; + + for field in &important_fields { + if let Some(value) = map.get(*field) { + output.push_str(&format!( + " {field}: {}\n", + self.format_value_brief(value) + )); + } + } + + for (key, value) in map { + if !important_fields.contains(&key.as_str()) { + output.push_str(&format!( + " {key}: {}\n", + self.format_value_brief(value) + )); + } + } + } + + output + } + + /// Format agent data as indented table + fn format_agent_table_indented(&self, agent_data: &Value) -> String { + self.format_agent_table(agent_data) + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n") + + "\n" + } + + /// Format a table item + fn format_table_item(&self, item: &Value) -> String { + match item { + Value::Object(map) => { + let mut output = String::new(); + for (key, value) in map { + output.push_str(&format!( + "{key}: {}\n", + self.format_value_brief(value) + )); + } + output + } + _ => format!("{}\n", self.format_value_brief(item)), + } + } + + /// Format a value briefly for table display + #[allow(clippy::only_used_in_recursion)] + fn format_value_brief(&self, value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + Value::Array(arr) => { + if arr.is_empty() { + "[]".to_string() + } else if arr.len() == 1 { + self.format_value_brief(&arr[0]) + } else { + format!("[{} items]", arr.len()) + } + } + Value::Object(map) => { + if map.is_empty() { + "{}".to_string() + } else { + format!("{{{} fields}}", map.len()) + } + } + } + } + + /// Convert value to YAML-like format + fn value_to_yaml(&self, value: &Value, indent: usize) -> String { + let indent_str = " ".repeat(indent); + + match value { + Value::Object(map) => { + let mut output = String::new(); + for (key, value) in map { + match value { + Value::Object(_) | Value::Array(_) => { + output.push_str(&format!("{indent_str}{key}:\n")); + output.push_str( + &self.value_to_yaml(value, indent + 1), + ); + } + _ => { + output.push_str(&format!( + "{}{}: {}\n", + indent_str, + key, + self.format_value_brief(value) + )); + } + } + } + output + } + Value::Array(arr) => { + let mut output = String::new(); + for item in arr { + output.push_str(&format!("{} - ", " ".repeat(indent))); + match item { + Value::Object(_) | Value::Array(_) => { + output.push('\n'); + output.push_str( + &self.value_to_yaml(item, indent + 1), + ); + } + _ => { + output.push_str(&format!( + "{}\n", + self.format_value_brief(item) + )); + } + } + } + output + } + _ => { + format!("{}{}\n", indent_str, self.format_value_brief(value)) + } + } + } +} + +/// Handle for a long-lived wait spinner +/// +/// Created by [`OutputHandler::start_wait()`]. The spinner runs until +/// the handle is dropped (RAII). Use `set_message()` to update the +/// spinner text during polling loops. +pub struct WaitHandle { + spinner: Option, +} + +impl WaitHandle { + /// Update the spinner message + pub fn set_message(&self, message: impl Into) { + if let Some(pb) = &self.spinner { + pb.set_message(message.into()); + } + } +} + +impl Drop for WaitHandle { + fn drop(&mut self) { + if let Some(pb) = self.spinner.take() { + pb.finish_and_clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// Create a test handler (no TTY, no spinners, no color) + fn test_handler(format: crate::OutputFormat) -> OutputHandler { + OutputHandler { + format: format.into(), + quiet: false, + use_spinner: false, + use_color: false, + active_spinner: RefCell::new(None), + spinner_is_step: RefCell::new(false), + } + } + + #[test] + fn test_format_conversion() { + assert_eq!(Format::from(crate::OutputFormat::Json), Format::Json); + assert_eq!(Format::from(crate::OutputFormat::Table), Format::Table); + assert_eq!(Format::from(crate::OutputFormat::Yaml), Format::Yaml); + } + + #[test] + fn test_output_handler_creation() { + let handler = OutputHandler::new( + crate::OutputFormat::Json, + false, + crate::ColorMode::Never, + ); + assert_eq!(handler.format, Format::Json); + assert!(!handler.quiet); + + let quiet_handler = OutputHandler::new( + crate::OutputFormat::Table, + true, + crate::ColorMode::Never, + ); + assert_eq!(quiet_handler.format, Format::Table); + assert!(quiet_handler.quiet); + } + + #[test] + fn test_format_json() { + let handler = test_handler(crate::OutputFormat::Json); + let value = json!({"status": "success", "count": 42}); + let result = handler.format_json(value); + + assert!(result.contains("\"status\": \"success\"")); + assert!(result.contains("\"count\": 42")); + } + + #[test] + fn test_format_value_brief() { + let handler = test_handler(crate::OutputFormat::Table); + + assert_eq!(handler.format_value_brief(&json!("test")), "test"); + assert_eq!(handler.format_value_brief(&json!(42)), "42"); + assert_eq!(handler.format_value_brief(&json!(true)), "true"); + assert_eq!(handler.format_value_brief(&json!(null)), "null"); + assert_eq!(handler.format_value_brief(&json!([])), "[]"); + assert_eq!(handler.format_value_brief(&json!({})), "{}"); + assert_eq!( + handler.format_value_brief(&json!([1, 2, 3])), + "[3 items]" + ); + assert_eq!( + handler.format_value_brief(&json!({"a": 1, "b": 2})), + "{2 fields}" + ); + } + + #[test] + fn test_format_agent_table() { + let handler = test_handler(crate::OutputFormat::Table); + let agent_data = json!({ + "operational_state": "active", + "ip": "192.168.1.100", + "port": 9002, + "verifier_ip": "127.0.0.1", + "verifier_port": 8881, + "uuid": "12345-67890", + "additional_field": "some_value" + }); + + let result = handler.format_agent_table(&agent_data); + + let lines: Vec<&str> = result.lines().collect(); + assert!(lines[0].contains("operational_state: active")); + assert!(lines[1].contains("ip: 192.168.1.100")); + assert!(lines[2].contains("port: 9002")); + + assert!(result.contains("uuid: 12345-67890")); + assert!(result.contains("additional_field: some_value")); + } + + #[test] + fn test_format_table_single_agent() { + let handler = test_handler(crate::OutputFormat::Table); + let value = json!({ + "results": { + "12345": { + "operational_state": "active", + "ip": "192.168.1.100" + } + } + }); + + let result = handler.format_table(value); + assert!(result.starts_with("Agent: 12345")); + assert!(result.contains("operational_state: active")); + } + + #[test] + fn test_format_table_multiple_agents() { + let handler = test_handler(crate::OutputFormat::Table); + let value = json!({ + "results": { + "12345": {"operational_state": "active"}, + "67890": {"operational_state": "failed"} + } + }); + + let result = handler.format_table(value); + assert!(result.starts_with("Agents:")); + assert!(result.contains("12345:")); + assert!(result.contains("67890:")); + } + + #[test] + fn test_format_table_generic_object() { + let handler = test_handler(crate::OutputFormat::Table); + let value = json!({ + "status": "success", + "message": "Operation completed", + "count": 5 + }); + + let result = handler.format_table(value); + assert!(result.contains("status: success")); + assert!(result.contains("message: Operation completed")); + assert!(result.contains("count: 5")); + } + + #[test] + fn test_value_to_yaml() { + let handler = test_handler(crate::OutputFormat::Yaml); + let value = json!({ + "simple": "value", + "nested": { + "inner": "data" + }, + "array": ["item1", "item2"] + }); + + let result = handler.value_to_yaml(&value, 0); + + assert!(result.contains("simple: value")); + assert!(result.contains("nested:")); + assert!(result.contains(" inner: data")); + assert!(result.contains("array:")); + assert!(result.contains(" - item1")); + assert!(result.contains(" - item2")); + } + + #[test] + fn test_format_yaml() { + let handler = test_handler(crate::OutputFormat::Yaml); + let value = json!({"key": "value", "number": 42}); + let result = handler.format_yaml(value); + + assert!(result.contains("key: value")); + assert!(result.contains("number: 42")); + } + + #[test] + fn test_format_table_item() { + let handler = test_handler(crate::OutputFormat::Table); + + let obj_item = json!({"name": "test", "value": 123}); + let result = handler.format_table_item(&obj_item); + assert!(result.contains("name: test")); + assert!(result.contains("value: 123")); + + let simple_item = json!("simple_value"); + let result = handler.format_table_item(&simple_item); + assert_eq!(result, "simple_value\n"); + } + + #[test] + fn test_format_agent_table_indented() { + let handler = test_handler(crate::OutputFormat::Table); + let agent_data = json!({ + "operational_state": "active", + "ip": "192.168.1.100" + }); + + let result = handler.format_agent_table_indented(&agent_data); + + for line in result.lines() { + if !line.is_empty() { + assert!(line.starts_with(" ")); + } + } + } + + #[test] + fn test_format_json_error_handling() { + let handler = test_handler(crate::OutputFormat::Json); + + let valid_json = json!({"test": "value"}); + let result = handler.format_json(valid_json); + assert!(result.contains("\"test\": \"value\"")); + } + + #[test] + fn test_edge_cases() { + let handler = test_handler(crate::OutputFormat::Table); + + let empty_obj = json!({}); + let result = handler.format_table(empty_obj); + assert!(!result.is_empty()); + + let empty_results = json!({"results": []}); + let result = handler.format_table(empty_results); + assert!(!result.is_empty()); + + let simple_value = json!("simple"); + let result = handler.format_table(simple_value); + assert_eq!(result, "\"simple\""); + } + + #[test] + fn test_wait_handle_drop() { + // WaitHandle with no spinner should not panic on drop + let handle = WaitHandle { spinner: None }; + drop(handle); + } + + #[test] + fn test_wait_handle_set_message_no_spinner() { + // set_message with no spinner should not panic + let handle = WaitHandle { spinner: None }; + handle.set_message("test"); + } + + #[test] + fn test_quiet_mode_suppresses_output() { + let handler = OutputHandler { + format: Format::Json, + quiet: true, + use_spinner: false, + use_color: false, + active_spinner: RefCell::new(None), + spinner_is_step: RefCell::new(false), + }; + + // These should not panic in quiet mode + handler.progress("test"); + handler.step(1, 3, "test"); + handler.info("test"); + } +} diff --git a/keylimectl/src/policy_tools/conversion.rs b/keylimectl/src/policy_tools/conversion.rs new file mode 100644 index 000000000..827bbd7cf --- /dev/null +++ b/keylimectl/src/policy_tools/conversion.rs @@ -0,0 +1,676 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Legacy allowlist format conversion to v1 runtime policy. +//! +//! Converts JSON and flat-text allowlists (from the older Python +//! `keylime_create_allowlist` tool) into the v1 runtime policy format +//! used by `keylimectl`. + +use crate::commands::error::PolicyGenerationError; +use crate::policy_tools::ima_parser; +use crate::policy_tools::runtime_policy::RuntimePolicy; +use base64::Engine; +use openssl::hash::{Hasher, MessageDigest}; +use openssl::pkey::{PKey, Public}; +use openssl::x509::X509; +use std::path::Path; + +/// Auto-detect the allowlist format and convert to a runtime policy. +/// +/// Tries JSON first, then falls back to flat-text format. +pub fn convert_allowlist( + input: &[u8], +) -> Result { + // Try JSON first + if let Ok(json_val) = serde_json::from_slice::(input) { + return convert_json_allowlist(&json_val); + } + + // Fall back to flat-text + let text = std::str::from_utf8(input).map_err(|e| { + PolicyGenerationError::AllowlistParse { + path: "".into(), + reason: format!("Input is not valid UTF-8: {e}"), + } + })?; + + convert_flat_allowlist(text) +} + +/// Convert a JSON allowlist to a runtime policy. +/// +/// Accepts the legacy format: `{"hashes": {"/path": ["digest"]}}` +/// or the newer: `{"digests": {"/path": ["algorithm:hex"]}}`. +pub fn convert_json_allowlist( + json: &serde_json::Value, +) -> Result { + let digests = ima_parser::parse_json_allowlist_value(json)?; + + let mut policy = RuntimePolicy::new(); + for (path, digest_list) in &digests { + for digest in digest_list { + policy.add_digest(path.clone(), digest.clone()); + } + } + + Ok(policy) +} + +/// Convert a flat-text allowlist to a runtime policy. +/// +/// Format: one entry per line, each line is `hex_digestpath`. +pub fn convert_flat_allowlist( + text: &str, +) -> Result { + let digests = ima_parser::parse_flat_allowlist_str(text)?; + + let mut policy = RuntimePolicy::new(); + for (path, digest_list) in &digests { + for digest in digest_list { + policy.add_digest(path.clone(), digest.clone()); + } + } + + Ok(policy) +} + +/// Merge an exclude list into a policy. +pub fn merge_excludelist(policy: &mut RuntimePolicy, excludes: &[String]) { + for exclude in excludes { + policy.add_exclude(exclude.clone()); + } +} + +/// Try parsing file data as a public key using multiple format strategies. +/// +/// Attempts (in order): DER x509 cert, PEM x509 cert, DER public key, +/// PEM public key, DER private key, PEM private key. +/// +/// Returns the public key and an optional keyidv2 extracted from a +/// certificate's Subject Key Identifier extension. +fn extract_pubkey( + data: &[u8], +) -> Result<(PKey, Option), PolicyGenerationError> { + // DER x509 certificate + if let Ok(cert) = X509::from_der(data) { + let keyidv2 = keyidv2_from_cert(&cert); + return Ok(( + cert.public_key() + .map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!( + "Failed to extract public key from certificate: {e}" + ), + })?, + keyidv2, + )); + } + + // PEM x509 certificate + if let Ok(cert) = X509::from_pem(data) { + let keyidv2 = keyidv2_from_cert(&cert); + return Ok(( + cert.public_key() + .map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!( + "Failed to extract public key from certificate: {e}" + ), + })?, + keyidv2, + )); + } + + // DER public key + if let Ok(pkey) = PKey::public_key_from_der(data) { + return Ok((pkey, None)); + } + + // PEM public key + if let Ok(pkey) = PKey::public_key_from_pem(data) { + return Ok((pkey, None)); + } + + // DER private key — extract public part + if let Ok(pkey) = PKey::private_key_from_der(data) { + let pub_der = pkey.public_key_to_der().map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!( + "Failed to extract public key from private key: {e}" + ), + } + })?; + return Ok(( + PKey::public_key_from_der(&pub_der).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to load public key: {e}"), + } + })?, + None, + )); + } + + // PEM private key — extract public part + if let Ok(pkey) = PKey::private_key_from_pem(data) { + let pub_der = pkey.public_key_to_der().map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!( + "Failed to extract public key from private key: {e}" + ), + } + })?; + return Ok(( + PKey::public_key_from_der(&pub_der).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to load public key: {e}"), + } + })?, + None, + )); + } + + Err(PolicyGenerationError::Output { + path: "".into(), + reason: "Could not parse file as any supported key or certificate format (DER/PEM x509, public key, or private key)".into(), + }) +} + +/// Extract keyidv2 from a certificate's Subject Key Identifier extension. +fn keyidv2_from_cert(cert: &X509) -> Option { + let skid = cert.subject_key_id()?; + let digest = skid.as_slice(); + if digest.len() >= 4 { + let last4 = &digest[digest.len() - 4..]; + Some(u32::from_be_bytes([last4[0], last4[1], last4[2], last4[3]])) + } else { + None + } +} + +/// Compute keyidv2 from a public key. +/// +/// For RSA keys: SHA-1 of DER PKCS#1 public key bytes, last 4 bytes as +/// big-endian u32. +/// For EC keys: SHA-1 of the uncompressed point encoding, last 4 bytes +/// as big-endian u32. +fn compute_keyidv2( + pkey: &PKey, +) -> Result { + let pub_bytes = if pkey.id() == openssl::pkey::Id::RSA { + let rsa = pkey.rsa().map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to extract RSA key: {e}"), + })?; + rsa.public_key_to_der_pkcs1().map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to serialize RSA key to PKCS1: {e}"), + } + })? + } else if pkey.id() == openssl::pkey::Id::EC { + let ec = + pkey.ec_key().map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to extract EC key: {e}"), + })?; + let group = ec.group(); + let point = ec.public_key(); + let mut ctx = openssl::bn::BigNumContext::new().map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to create BigNum context: {e}"), + } + })?; + point + .to_bytes( + group, + openssl::ec::PointConversionForm::UNCOMPRESSED, + &mut ctx, + ) + .map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to serialize EC point: {e}"), + })? + } else { + return Err(PolicyGenerationError::Output { + path: "".into(), + reason: format!( + "Unsupported key type for keyidv2 computation: {:?}", + pkey.id() + ), + }); + }; + + let mut hasher = Hasher::new(MessageDigest::sha1()).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to create SHA-1 hasher: {e}"), + } + })?; + hasher + .update(&pub_bytes) + .map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to update SHA-1 hash: {e}"), + })?; + let digest = + hasher.finish().map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to finalize SHA-1 hash: {e}"), + })?; + + let len = digest.len(); + let last4 = &digest[len - 4..]; + Ok(u32::from_be_bytes([last4[0], last4[1], last4[2], last4[3]])) +} + +/// Add verification keys from a file to a policy. +/// +/// Reads the file (binary-safe), auto-detects the format (DER/PEM × +/// certificate/public key/private key), extracts the public key, and +/// stores it in the policy's `verification-keys` JSON structure: +/// +/// ```json +/// {"pubkeys": ["base64-DER-SubjectPublicKeyInfo", ...], "keyids": [keyidv2, ...]} +/// ``` +pub fn add_verification_keys( + policy: &mut RuntimePolicy, + key_path: &str, +) -> Result<(), PolicyGenerationError> { + let data = std::fs::read(key_path).map_err(|e| { + PolicyGenerationError::Output { + path: key_path.into(), + reason: format!("Failed to read verification key file: {e}"), + } + })?; + + let (pkey, cert_keyidv2) = + extract_pubkey(&data).map_err(|e| PolicyGenerationError::Output { + path: key_path.into(), + reason: format!("Failed to parse key file '{key_path}': {e}"), + })?; + + // Serialize the public key as DER SubjectPublicKeyInfo and + // base64-encode. + let spki_der = pkey.public_key_to_der().map_err(|e| { + PolicyGenerationError::Output { + path: key_path.into(), + reason: format!("Failed to serialize public key to DER: {e}"), + } + })?; + let pubkey_b64 = + base64::engine::general_purpose::STANDARD.encode(&spki_der); + + // Determine keyidv2: prefer the value from a certificate's SKID, + // fall back to computing from the raw public key bytes. + let keyidv2 = match cert_keyidv2 { + Some(id) => id, + None => compute_keyidv2(&pkey).map_err(|e| { + PolicyGenerationError::Output { + path: key_path.into(), + reason: format!( + "Failed to compute keyidv2 for '{key_path}': {e}" + ), + } + })?, + }; + + // Parse existing verification-keys JSON or start fresh. + let mut keyring: serde_json::Value = + if policy.verification_keys.is_empty() { + serde_json::json!({"pubkeys": [], "keyids": []}) + } else { + serde_json::from_str(&policy.verification_keys).map_err(|e| { + PolicyGenerationError::Output { + path: key_path.into(), + reason: format!( + "Failed to parse existing verification-keys JSON: {e}" + ), + } + })? + }; + + keyring["pubkeys"] + .as_array_mut() + .expect("pubkeys must be an array") //#[allow_ci] + .push(serde_json::Value::String(pubkey_b64)); + keyring["keyids"] + .as_array_mut() + .expect("keyids must be an array") //#[allow_ci] + .push(serde_json::Value::Number(keyidv2.into())); + + policy.verification_keys = + serde_json::to_string(&keyring).map_err(|e| { + PolicyGenerationError::Output { + path: key_path.into(), + reason: format!( + "Failed to serialize verification-keys JSON: {e}" + ), + } + })?; + + Ok(()) +} + +/// Convert an allowlist file (auto-detect format) to a runtime policy. +pub fn convert_allowlist_file( + path: &Path, +) -> Result { + let content = std::fs::read(path).map_err(|e| { + PolicyGenerationError::AllowlistParse { + path: path.to_path_buf(), + reason: format!("Failed to read file: {e}"), + } + })?; + + convert_allowlist(&content) +} + +#[cfg(test)] +mod tests { + use super::*; + use openssl::ec::EcKey; + use openssl::nid::Nid; + use openssl::rsa::Rsa; + use serde_json::json; + + #[test] + fn test_convert_json_allowlist_hashes_key() { + let json = json!({ + "hashes": { + "/usr/bin/bash": ["sha256:aabbccdd"], + "/usr/bin/ls": ["sha256:eeff0011", "sha1:aabb"] + } + }); + + let policy = convert_json_allowlist(&json).unwrap(); //#[allow_ci] + assert_eq!(policy.digest_count(), 2); + // Algorithm prefix is stripped during conversion + assert_eq!(policy.digests["/usr/bin/bash"], vec!["aabbccdd"]); + assert_eq!(policy.digests["/usr/bin/ls"].len(), 2); + } + + #[test] + fn test_convert_json_allowlist_digests_key() { + let json = json!({ + "digests": { + "/usr/bin/test": ["sha256:1234"] + } + }); + + let policy = convert_json_allowlist(&json).unwrap(); //#[allow_ci] + assert_eq!(policy.digest_count(), 1); + } + + #[test] + fn test_convert_flat_allowlist() { + let text = + "sha256:aabb1122\t/usr/bin/bash\nsha256:ccdd3344\t/usr/bin/ls\n"; + + let policy = convert_flat_allowlist(text).unwrap(); //#[allow_ci] + assert_eq!(policy.digest_count(), 2); + // Algorithm prefix is stripped during conversion + assert_eq!(policy.digests["/usr/bin/bash"], vec!["aabb1122"]); + } + + #[test] + fn test_auto_detect_json() { + let input = br#"{"hashes": {"/test": ["sha256:abcd"]}}"#; + + let policy = convert_allowlist(input).unwrap(); //#[allow_ci] + assert_eq!(policy.digest_count(), 1); + } + + #[test] + fn test_auto_detect_flat() { + let input = b"sha256:abcd\t/test\n"; + + let policy = convert_allowlist(input).unwrap(); //#[allow_ci] + assert_eq!(policy.digest_count(), 1); + } + + #[test] + fn test_merge_excludelist() { + let mut policy = RuntimePolicy::new(); + merge_excludelist( + &mut policy, + &["/tmp/*".to_string(), "/proc/*".to_string()], + ); + assert_eq!(policy.exclude_count(), 2); + } + + #[test] + fn test_merge_excludelist_dedup() { + let mut policy = RuntimePolicy::new(); + policy.add_exclude("/tmp/*".to_string()); + merge_excludelist( + &mut policy, + &["/tmp/*".to_string(), "/proc/*".to_string()], + ); + assert_eq!(policy.exclude_count(), 2); + } + + fn generate_rsa_pem_keypair() -> (Vec, Vec) { + let rsa = Rsa::generate(2048).expect("RSA key generation"); //#[allow_ci] + let pkey = PKey::from_rsa(rsa).expect("PKey from RSA"); //#[allow_ci] + let pub_pem = pkey.public_key_to_pem().expect("public PEM"); //#[allow_ci] + let priv_pem = pkey.private_key_to_pem_pkcs8().expect("private PEM"); //#[allow_ci] + (pub_pem, priv_pem) + } + + fn generate_ec_pem_keypair() -> (Vec, Vec) { + let group = + openssl::ec::EcGroup::from_curve_name(Nid::X9_62_PRIME256V1) + .expect("EC group"); //#[allow_ci] + let ec = EcKey::generate(&group).expect("EC key gen"); //#[allow_ci] + let pkey = PKey::from_ec_key(ec).expect("PKey from EC"); //#[allow_ci] + let pub_pem = pkey.public_key_to_pem().expect("public PEM"); //#[allow_ci] + let priv_pem = pkey.private_key_to_pem_pkcs8().expect("private PEM"); //#[allow_ci] + (pub_pem, priv_pem) + } + + fn generate_self_signed_cert_pem() -> Vec { + let rsa = Rsa::generate(2048).expect("RSA key gen"); //#[allow_ci] + let pkey = PKey::from_rsa(rsa).expect("PKey from RSA"); //#[allow_ci] + + let mut builder = X509::builder().expect("X509 builder"); //#[allow_ci] + builder.set_pubkey(&pkey).expect("set pubkey"); //#[allow_ci] + let mut name = + openssl::x509::X509Name::builder().expect("name builder"); //#[allow_ci] + name.append_entry_by_nid(Nid::COMMONNAME, "test") + .expect("CN"); //#[allow_ci] + let name = name.build(); + builder.set_subject_name(&name).expect("subject"); //#[allow_ci] + builder.set_issuer_name(&name).expect("issuer"); //#[allow_ci] + builder + .set_not_before( + &openssl::asn1::Asn1Time::days_from_now(0) + .expect("not_before"), //#[allow_ci] + ) + .expect("set not_before"); //#[allow_ci] + builder + .set_not_after( + &openssl::asn1::Asn1Time::days_from_now(365) + .expect("not_after"), //#[allow_ci] + ) + .expect("set not_after"); //#[allow_ci] + + // Add Subject Key Identifier extension + let ctx = builder.x509v3_context(None, None); + let skid = openssl::x509::extension::SubjectKeyIdentifier::new() + .build(&ctx) + .expect("SKID"); //#[allow_ci] + builder.append_extension(skid).expect("append SKID"); //#[allow_ci] + + builder.sign(&pkey, MessageDigest::sha256()).expect("sign"); //#[allow_ci] + let cert = builder.build(); + cert.to_pem().expect("cert PEM") //#[allow_ci] + } + + fn assert_valid_keyring(json_str: &str, expected_count: usize) { + let v: serde_json::Value = + serde_json::from_str(json_str).expect("valid JSON"); //#[allow_ci] + let pubkeys = v["pubkeys"].as_array().expect("pubkeys array"); //#[allow_ci] + let keyids = v["keyids"].as_array().expect("keyids array"); //#[allow_ci] + assert_eq!(pubkeys.len(), expected_count); + assert_eq!(keyids.len(), expected_count); + for pk in pubkeys { + assert!(pk.is_string()); + // Verify it's valid base64 that decodes to a DER SPKI key + let decoded = base64::engine::general_purpose::STANDARD + .decode(pk.as_str().expect("string")) //#[allow_ci] + .expect("base64 decode"); //#[allow_ci] + let _key = + PKey::public_key_from_der(&decoded).expect("valid SPKI DER"); //#[allow_ci] + } + for kid in keyids { + assert!(kid.is_u64()); + } + } + + #[test] + fn test_add_verification_keys_rsa_pem_pubkey() { + let (pub_pem, _) = generate_rsa_pem_keypair(); + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), &pub_pem).unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let mut policy = RuntimePolicy::new(); + add_verification_keys(&mut policy, &path).unwrap(); //#[allow_ci] + assert_valid_keyring(&policy.verification_keys, 1); + } + + #[test] + fn test_add_verification_keys_rsa_der_pubkey() { + let (pub_pem, _) = generate_rsa_pem_keypair(); + let pkey = PKey::public_key_from_pem(&pub_pem).unwrap(); //#[allow_ci] + let der = pkey.public_key_to_der().unwrap(); //#[allow_ci] + + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), &der).unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let mut policy = RuntimePolicy::new(); + add_verification_keys(&mut policy, &path).unwrap(); //#[allow_ci] + assert_valid_keyring(&policy.verification_keys, 1); + } + + #[test] + fn test_add_verification_keys_rsa_pem_privkey() { + let (_, priv_pem) = generate_rsa_pem_keypair(); + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), &priv_pem).unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let mut policy = RuntimePolicy::new(); + add_verification_keys(&mut policy, &path).unwrap(); //#[allow_ci] + assert_valid_keyring(&policy.verification_keys, 1); + } + + #[test] + fn test_add_verification_keys_ec_pem_pubkey() { + let (pub_pem, _) = generate_ec_pem_keypair(); + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), &pub_pem).unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let mut policy = RuntimePolicy::new(); + add_verification_keys(&mut policy, &path).unwrap(); //#[allow_ci] + assert_valid_keyring(&policy.verification_keys, 1); + } + + #[test] + fn test_add_verification_keys_x509_cert_pem() { + let cert_pem = generate_self_signed_cert_pem(); + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), &cert_pem).unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let mut policy = RuntimePolicy::new(); + add_verification_keys(&mut policy, &path).unwrap(); //#[allow_ci] + assert_valid_keyring(&policy.verification_keys, 1); + } + + #[test] + fn test_add_verification_keys_x509_cert_der() { + let cert_pem = generate_self_signed_cert_pem(); + let cert = X509::from_pem(&cert_pem).unwrap(); //#[allow_ci] + let cert_der = cert.to_der().unwrap(); //#[allow_ci] + + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), &cert_der).unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let mut policy = RuntimePolicy::new(); + add_verification_keys(&mut policy, &path).unwrap(); //#[allow_ci] + assert_valid_keyring(&policy.verification_keys, 1); + } + + #[test] + fn test_add_verification_keys_multiple() { + let (pub_pem1, _) = generate_rsa_pem_keypair(); + let (pub_pem2, _) = generate_ec_pem_keypair(); + let cert_pem = generate_self_signed_cert_pem(); + + let tmp1 = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp1.path(), &pub_pem1).unwrap(); //#[allow_ci] + let tmp2 = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp2.path(), &pub_pem2).unwrap(); //#[allow_ci] + let tmp3 = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp3.path(), &cert_pem).unwrap(); //#[allow_ci] + + let mut policy = RuntimePolicy::new(); + add_verification_keys(&mut policy, &tmp1.path().to_string_lossy()) + .unwrap(); //#[allow_ci] + add_verification_keys(&mut policy, &tmp2.path().to_string_lossy()) + .unwrap(); //#[allow_ci] + add_verification_keys(&mut policy, &tmp3.path().to_string_lossy()) + .unwrap(); //#[allow_ci] + assert_valid_keyring(&policy.verification_keys, 3); + } + + #[test] + fn test_add_verification_keys_der_privkey() { + let (_, priv_pem) = generate_rsa_pem_keypair(); + let pkey = PKey::private_key_from_pem(&priv_pem).unwrap(); //#[allow_ci] + let der = pkey.private_key_to_der().unwrap(); //#[allow_ci] + + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), &der).unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let mut policy = RuntimePolicy::new(); + add_verification_keys(&mut policy, &path).unwrap(); //#[allow_ci] + assert_valid_keyring(&policy.verification_keys, 1); + } + + #[test] + fn test_add_verification_keys_nonexistent_file() { + let mut policy = RuntimePolicy::new(); + let result = + add_verification_keys(&mut policy, "/nonexistent/key.pem"); + assert!(result.is_err()); + } + + #[test] + fn test_add_verification_keys_invalid_data() { + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), b"not a key at all").unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let mut policy = RuntimePolicy::new(); + let result = add_verification_keys(&mut policy, &path); + assert!(result.is_err()); + } + + #[test] + fn test_convert_nonexistent_file() { + let result = convert_allowlist_file(Path::new("/nonexistent/file")); + assert!(result.is_err()); + } +} diff --git a/keylimectl/src/policy_tools/digest.rs b/keylimectl/src/policy_tools/digest.rs new file mode 100644 index 000000000..039c6dded --- /dev/null +++ b/keylimectl/src/policy_tools/digest.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! File digest calculation for policy generation. + +#![allow(dead_code)] // Used in later implementation steps + +use crate::commands::error::PolicyGenerationError; +use openssl::hash::{Hasher, MessageDigest}; +use std::io::Read; +use std::path::Path; + +/// Calculate the digest of a file using the specified algorithm. +/// +/// Returns the digest as bare lowercase hex (e.g., `"abcdef1234..."`). +pub fn calculate_file_digest( + path: &Path, + algorithm: &str, +) -> Result { + let md = algorithm_to_message_digest(algorithm)?; + + let mut file = std::fs::File::open(path).map_err(|e| { + PolicyGenerationError::Digest { + path: path.to_path_buf(), + reason: format!("Failed to open file: {e}"), + } + })?; + + let mut hasher = + Hasher::new(md).map_err(|e| PolicyGenerationError::Digest { + path: path.to_path_buf(), + reason: format!("Failed to create hasher: {e}"), + })?; + + let mut buf = [0u8; 8192]; + loop { + let n = file.read(&mut buf).map_err(|e| { + PolicyGenerationError::Digest { + path: path.to_path_buf(), + reason: format!("Failed to read file: {e}"), + } + })?; + if n == 0 { + break; + } + hasher.update(&buf[..n]).map_err(|e| { + PolicyGenerationError::Digest { + path: path.to_path_buf(), + reason: format!("Hash update failed: {e}"), + } + })?; + } + + let digest = + hasher.finish().map_err(|e| PolicyGenerationError::Digest { + path: path.to_path_buf(), + reason: format!("Hash finalize failed: {e}"), + })?; + + Ok(hex::encode(digest)) +} + +/// Map algorithm name string to OpenSSL MessageDigest. +pub fn algorithm_to_message_digest( + algorithm: &str, +) -> Result { + match algorithm { + "sha1" => Ok(MessageDigest::sha1()), + "sha256" => Ok(MessageDigest::sha256()), + "sha384" => Ok(MessageDigest::sha384()), + "sha512" => Ok(MessageDigest::sha512()), + "sm3_256" | "sm3" => Ok(MessageDigest::sm3()), + _ => Err(PolicyGenerationError::UnsupportedAlgorithm { + algorithm: algorithm.to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_calculate_file_digest_sha256() { + let mut f = NamedTempFile::new().unwrap(); //#[allow_ci] + f.write_all(b"hello world\n").unwrap(); //#[allow_ci] + f.flush().unwrap(); //#[allow_ci] + + let result = calculate_file_digest(f.path(), "sha256").unwrap(); //#[allow_ci] + + // sha256 of "hello world\n" — bare hex, 64 chars + assert_eq!(result.len(), 64); + assert!(result.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_calculate_file_digest_sha1() { + let mut f = NamedTempFile::new().unwrap(); //#[allow_ci] + f.write_all(b"test").unwrap(); //#[allow_ci] + f.flush().unwrap(); //#[allow_ci] + + let result = calculate_file_digest(f.path(), "sha1").unwrap(); //#[allow_ci] + + // sha1 of "test" — bare hex + assert_eq!(result, "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3"); + } + + #[test] + fn test_calculate_file_digest_empty_file() { + let f = NamedTempFile::new().unwrap(); //#[allow_ci] + + let result = calculate_file_digest(f.path(), "sha256").unwrap(); //#[allow_ci] + + // sha256 of empty string — bare hex + assert_eq!( + result, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn test_unsupported_algorithm() { + let f = NamedTempFile::new().unwrap(); //#[allow_ci] + + let result = calculate_file_digest(f.path(), "md5"); + + assert!(result.is_err()); + } + + #[test] + fn test_nonexistent_file() { + let result = + calculate_file_digest(Path::new("/nonexistent/file"), "sha256"); + + assert!(result.is_err()); + } + + #[test] + fn test_algorithm_to_message_digest() { + assert!(algorithm_to_message_digest("sha1").is_ok()); + assert!(algorithm_to_message_digest("sha256").is_ok()); + assert!(algorithm_to_message_digest("sha384").is_ok()); + assert!(algorithm_to_message_digest("sha512").is_ok()); + assert!(algorithm_to_message_digest("sm3_256").is_ok()); + assert!(algorithm_to_message_digest("sm3").is_ok()); + assert!(algorithm_to_message_digest("md5").is_err()); + } +} diff --git a/keylimectl/src/policy_tools/dsse/ecdsa_backend.rs b/keylimectl/src/policy_tools/dsse/ecdsa_backend.rs new file mode 100644 index 000000000..b0a5bd7ca --- /dev/null +++ b/keylimectl/src/policy_tools/dsse/ecdsa_backend.rs @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! ECDSA P-256 signing and verification backend for DSSE. + +use super::{DsseError, Signer, Verifier}; +use openssl::ec::{EcGroup, EcKey}; +use openssl::hash::MessageDigest; +use openssl::nid::Nid; +use openssl::pkey::{PKey, Private, Public}; +use openssl::sign; +use std::path::Path; + +/// ECDSA P-256 signer. +pub struct EcdsaSigner { + private_key: PKey, + public_key_pem: Vec, +} + +impl std::fmt::Debug for EcdsaSigner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EcdsaSigner") + .field("private_key", &"") + .finish() + } +} + +impl EcdsaSigner { + /// Generate a new ECDSA P-256 key pair. + pub fn generate() -> Result { + let group = + EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to create EC group: {e}"), + } + })?; + let ec_key = + EcKey::generate(&group).map_err(|e| DsseError::KeyError { + reason: format!("Failed to generate EC key: {e}"), + })?; + let private_key = + PKey::from_ec_key(ec_key).map_err(|e| DsseError::KeyError { + reason: format!("Failed to wrap EC key: {e}"), + })?; + + let public_key_pem = + private_key.public_key_to_pem().map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to encode public key: {e}"), + } + })?; + + Ok(Self { + private_key, + public_key_pem, + }) + } + + /// Load an ECDSA signer from a PEM-encoded private key file. + pub fn from_pem_file(path: &str) -> Result { + let pem = std::fs::read(path).map_err(|e| DsseError::KeyError { + reason: format!("Failed to read key file: {e}"), + })?; + + let private_key = PKey::private_key_from_pem(&pem).map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to parse private key: {e}"), + } + })?; + + // Verify it is a P-256 EC key — reject other curves. + let ec_key = + private_key.ec_key().map_err(|_| DsseError::KeyError { + reason: "Key is not an EC key".to_string(), + })?; + if ec_key.group().curve_name() != Some(Nid::X9_62_PRIME256V1) { + return Err(DsseError::KeyError { + reason: "EC key must use the P-256 (prime256v1) curve" + .to_string(), + }); + } + + let public_key_pem = + private_key.public_key_to_pem().map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to encode public key: {e}"), + } + })?; + + Ok(Self { + private_key, + public_key_pem, + }) + } + + /// Save the private key to a PEM file with restricted permissions (0o600). + /// + /// Uses [`crate::policy_tools::privilege::write_sensitive_file`] to create + /// the file with the correct permissions atomically, avoiding a TOCTOU race + /// between file creation and a subsequent `chmod` call. + pub fn save_private_key(&self, path: &str) -> Result<(), DsseError> { + let pem = + self.private_key.private_key_to_pem_pkcs8().map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to encode private key: {e}"), + } + })?; + + crate::policy_tools::privilege::write_sensitive_file( + Path::new(path), + &pem, + ) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to write key file: {e}"), + }) + } + + /// Get the public key in PEM format. + pub fn public_key_pem(&self) -> &[u8] { + &self.public_key_pem + } +} + +impl Signer for EcdsaSigner { + fn sign(&self, message: &[u8]) -> Result, DsseError> { + let mut signer = + sign::Signer::new(MessageDigest::sha256(), &self.private_key) + .map_err(|e| DsseError::SigningFailed { + reason: format!("Failed to create signer: {e}"), + })?; + + signer + .update(message) + .map_err(|e| DsseError::SigningFailed { + reason: format!("Failed to update signer: {e}"), + })?; + + signer.sign_to_vec().map_err(|e| DsseError::SigningFailed { + reason: format!("Failed to sign: {e}"), + }) + } + + fn keyid(&self) -> String { + // SHA-256 hash of the public key PEM + use openssl::hash::{hash, MessageDigest}; + match hash(MessageDigest::sha256(), &self.public_key_pem) { + Ok(digest) => hex::encode(digest), + Err(_) => String::new(), + } + } +} + +/// ECDSA P-256 verifier. +pub struct EcdsaVerifier { + public_key: PKey, + public_key_pem: Vec, +} + +impl EcdsaVerifier { + /// Create a verifier from a PEM-encoded public key. + pub fn from_pem(pem: &[u8]) -> Result { + let public_key = PKey::public_key_from_pem(pem).map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to parse public key: {e}"), + } + })?; + + Ok(Self { + public_key, + public_key_pem: pem.to_vec(), + }) + } + + /// Create a verifier from a PEM-encoded public key file. + pub fn from_pem_file(path: &str) -> Result { + let pem = std::fs::read(path).map_err(|e| DsseError::KeyError { + reason: format!("Failed to read key file: {e}"), + })?; + Self::from_pem(pem.as_slice()) + } + + /// Create a verifier from a signer's public key. + #[allow(dead_code)] // Used only in tests; see test_sign_and_verify_roundtrip in dsse/mod.rs + pub fn from_signer(signer: &EcdsaSigner) -> Result { + Self::from_pem(signer.public_key_pem()) + } +} + +impl Verifier for EcdsaVerifier { + fn verify( + &self, + message: &[u8], + signature: &[u8], + ) -> Result { + let mut verifier = + sign::Verifier::new(MessageDigest::sha256(), &self.public_key) + .map_err(|e| DsseError::VerificationFailed { + reason: format!("Failed to create verifier: {e}"), + })?; + + verifier.update(message).map_err(|e| { + DsseError::VerificationFailed { + reason: format!("Failed to update verifier: {e}"), + } + })?; + + verifier.verify(signature).map_err(|e| { + DsseError::VerificationFailed { + reason: format!("Verification operation failed: {e}"), + } + }) + } + + fn keyid(&self) -> String { + use openssl::hash::{hash, MessageDigest}; + match hash(MessageDigest::sha256(), &self.public_key_pem) { + Ok(digest) => hex::encode(digest), + Err(_) => String::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_key() { + let signer = EcdsaSigner::generate().unwrap(); //#[allow_ci] + assert!(!signer.keyid().is_empty()); + assert!(!signer.public_key_pem().is_empty()); + } + + #[test] + fn test_sign_verify_roundtrip() { + let signer = EcdsaSigner::generate().unwrap(); //#[allow_ci] + let verifier = EcdsaVerifier::from_signer(&signer).unwrap(); //#[allow_ci] + + let message = b"test message"; + let signature = signer.sign(message).unwrap(); //#[allow_ci] + + assert!(verifier.verify(message, &signature).unwrap()); //#[allow_ci] + } + + #[test] + fn test_verify_wrong_message() { + let signer = EcdsaSigner::generate().unwrap(); //#[allow_ci] + let verifier = EcdsaVerifier::from_signer(&signer).unwrap(); //#[allow_ci] + + let signature = signer.sign(b"original message").unwrap(); //#[allow_ci] + + assert!(!verifier.verify(b"different message", &signature).unwrap()); //#[allow_ci] + } + + #[test] + fn test_verify_wrong_key() { + let signer1 = EcdsaSigner::generate().unwrap(); //#[allow_ci] + let signer2 = EcdsaSigner::generate().unwrap(); //#[allow_ci] + let verifier2 = EcdsaVerifier::from_signer(&signer2).unwrap(); //#[allow_ci] + + let message = b"test message"; + let signature = signer1.sign(message).unwrap(); //#[allow_ci] + + assert!(!verifier2.verify(message, &signature).unwrap()); //#[allow_ci] + } + + #[test] + fn test_save_and_load_key() { + let signer = EcdsaSigner::generate().unwrap(); //#[allow_ci] + let message = b"test message"; + let original_sig = signer.sign(message).unwrap(); //#[allow_ci] + + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + signer.save_private_key(&path).unwrap(); //#[allow_ci] + + let loaded = EcdsaSigner::from_pem_file(&path).unwrap(); //#[allow_ci] + + // Verify with the loaded key's public key + let verifier = EcdsaVerifier::from_signer(&loaded).unwrap(); //#[allow_ci] + assert!(verifier.verify(message, &original_sig).unwrap()); //#[allow_ci] + + // Verify keyid is the same + assert_eq!(signer.keyid(), loaded.keyid()); + } + + #[test] + fn test_from_pem_file_rejects_non_p256_key() { + use openssl::ec::EcGroup; + use openssl::nid::Nid; + let group = EcGroup::from_curve_name(Nid::SECP384R1).unwrap(); //#[allow_ci] + let ec_key = EcKey::generate(&group).unwrap(); //#[allow_ci] + let pkey = PKey::from_ec_key(ec_key).unwrap(); //#[allow_ci] + let pem = pkey.private_key_to_pem_pkcs8().unwrap(); //#[allow_ci] + + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + std::fs::write(tmp.path(), &pem).unwrap(); //#[allow_ci] + let path = tmp.path().to_string_lossy().to_string(); + + let result = EcdsaSigner::from_pem_file(&path); + assert!(result.is_err()); + assert!(result + .unwrap_err() //#[allow_ci] + .to_string() + .contains("P-256")); + } + + #[test] + fn test_keyid_is_hex_sha256() { + let signer = EcdsaSigner::generate().unwrap(); //#[allow_ci] + let keyid = signer.keyid(); + + // SHA-256 hex digest is 64 chars + assert_eq!(keyid.len(), 64); + assert!(keyid.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/keylimectl/src/policy_tools/dsse/error.rs b/keylimectl/src/policy_tools/dsse/error.rs new file mode 100644 index 000000000..c91849433 --- /dev/null +++ b/keylimectl/src/policy_tools/dsse/error.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! DSSE (Dead Simple Signing Envelope) error types. + +use serde::Serialize; +use thiserror::Error; + +/// DSSE (Dead Simple Signing Envelope) errors +/// +/// These errors represent issues with policy signing and +/// signature verification using the DSSE protocol. +#[derive(Error, Debug, Serialize)] +pub enum DsseError { + /// Signing operation failed + #[error("Signing failed: {reason}")] + SigningFailed { reason: String }, + + /// Signature verification failed + #[error("Signature verification failed: {reason}")] + VerificationFailed { reason: String }, + + /// Invalid DSSE envelope structure + #[error("Invalid DSSE envelope: {reason}")] + InvalidEnvelope { reason: String }, + + /// Key loading or generation error + #[error("Key error: {reason}")] + KeyError { reason: String }, +} diff --git a/keylimectl/src/policy_tools/dsse/mod.rs b/keylimectl/src/policy_tools/dsse/mod.rs new file mode 100644 index 000000000..701e96271 --- /dev/null +++ b/keylimectl/src/policy_tools/dsse/mod.rs @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! DSSE (Dead Simple Signing Envelope) implementation. +//! +//! Implements the DSSE protocol for signing and verifying policy payloads. +//! Supports ECDSA P-256 and X.509 certificate signing backends. +//! +//! Reference: + +pub mod ecdsa_backend; +pub mod error; +pub mod x509_backend; + +use base64::{engine::general_purpose::STANDARD as Base64, Engine}; +pub use error::DsseError; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Keylime policy DSSE payload type. +pub const KEYLIME_PAYLOAD_TYPE: &str = "application/vnd.keylime+json"; + +/// DSSE envelope structure. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DsseEnvelope { + /// Base64-encoded payload. + pub payload: String, + + /// Content type of the payload. + pub payload_type: String, + + /// List of signatures. + pub signatures: Vec, +} + +/// A single signature within a DSSE envelope. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DsseSignature { + /// Key identifier (fingerprint or base64-encoded certificate). + pub keyid: String, + + /// Base64-encoded signature bytes. + pub sig: String, +} + +/// Result of successful verification. +pub struct VerifiedPayload { + /// Content type. + pub payload_type: String, + + /// Decoded payload bytes. + pub payload: Vec, + + /// Names of signers that validated successfully. + pub recognized_signers: Vec, +} + +/// Trait for signing DSSE payloads. +pub trait Signer { + /// Sign a message and return the raw signature bytes. + fn sign(&self, message: &[u8]) -> Result, DsseError>; + + /// Return the key identifier for this signer. + fn keyid(&self) -> String; +} + +/// Trait for verifying DSSE signatures. +pub trait Verifier { + /// Verify a signature against a message. + /// + /// Returns `Ok(true)` when the signature is valid, `Ok(false)` when the + /// signature does not match, and `Err` when the verification operation + /// itself fails (e.g., due to an internal cryptographic error). + fn verify( + &self, + message: &[u8], + signature: &[u8], + ) -> Result; + + /// Return the key identifier for this verifier. + /// + /// This must match the keyid produced by the corresponding [`Signer`] + /// so that [`verify_envelope`] can bind each signature to the correct + /// verifier without trying every verifier against every signature. + fn keyid(&self) -> String; +} + +/// Pre-Authentication Encoding per DSSE specification. +/// +/// Format: `DSSEv1 ` +/// +/// This is the exact byte sequence that gets signed/verified. +pub fn pae(payload_type: &str, payload: &[u8]) -> Vec { + let pt_bytes = payload_type.as_bytes(); + let mut result = Vec::new(); + + result.extend_from_slice(b"DSSEv1 "); + result.extend_from_slice(pt_bytes.len().to_string().as_bytes()); + result.push(b' '); + result.extend_from_slice(pt_bytes); + result.push(b' '); + result.extend_from_slice(payload.len().to_string().as_bytes()); + result.push(b' '); + result.extend_from_slice(payload); + + result +} + +/// Sign a payload and produce a DSSE envelope. +pub fn sign_payload( + payload: &[u8], + payload_type: &str, + signer: &dyn Signer, +) -> Result { + let pae_bytes = pae(payload_type, payload); + let signature = signer.sign(&pae_bytes)?; + + Ok(DsseEnvelope { + payload: Base64.encode(payload), + payload_type: payload_type.to_string(), + signatures: vec![DsseSignature { + keyid: signer.keyid(), + sig: Base64.encode(signature), + }], + }) +} + +/// Verify a DSSE envelope against a set of named verifiers. +/// +/// Returns the decoded payload and list of recognized signers if +/// at least one signature is valid. +/// +/// Each signature in the envelope is matched to verifiers by keyid. When +/// the signature's keyid is non-empty, only verifiers whose [`Verifier::keyid`] +/// matches are tried; this prevents a verifier from being used against a +/// signature it was never intended to verify. An empty keyid falls back to +/// trying every verifier for backward compatibility. +pub fn verify_envelope( + envelope: &DsseEnvelope, + verifiers: &[(&str, &dyn Verifier)], +) -> Result { + let payload = Base64.decode(&envelope.payload).map_err(|e| { + DsseError::InvalidEnvelope { + reason: format!("Invalid base64 payload: {e}"), + } + })?; + + let pae_bytes = pae(&envelope.payload_type, &payload); + + let mut recognized_signers = Vec::new(); + + let mut by_keyid: HashMap> = + HashMap::new(); + for (name, verifier) in verifiers { + by_keyid + .entry(verifier.keyid()) + .or_default() + .push((name, *verifier)); + } + + for sig_entry in &envelope.signatures { + let sig_bytes = Base64.decode(&sig_entry.sig).map_err(|e| { + DsseError::InvalidEnvelope { + reason: format!("Invalid base64 signature: {e}"), + } + })?; + + let candidates: &[(&str, &dyn Verifier)] = + if sig_entry.keyid.is_empty() { + verifiers + } else { + by_keyid + .get(&sig_entry.keyid) + .map(Vec::as_slice) + .unwrap_or(&[]) + }; + + for (name, verifier) in candidates { + if verifier.verify(&pae_bytes, &sig_bytes)? { + recognized_signers.push(name.to_string()); + } + } + } + + if recognized_signers.is_empty() { + return Err(DsseError::VerificationFailed { + reason: "No valid signature found".to_string(), + }); + } + + Ok(VerifiedPayload { + payload_type: envelope.payload_type.clone(), + payload, + recognized_signers, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pae_construction() { + let result = pae("application/vnd.keylime+json", b"hello world"); + let expected = + b"DSSEv1 28 application/vnd.keylime+json 11 hello world"; + assert_eq!(result, expected); + } + + #[test] + fn test_pae_empty_payload() { + let result = pae("text/plain", b""); + let expected = b"DSSEv1 10 text/plain 0 "; + assert_eq!(result, expected); + } + + #[test] + fn test_envelope_serialization() { + let envelope = DsseEnvelope { + payload: Base64.encode(b"test"), + payload_type: KEYLIME_PAYLOAD_TYPE.to_string(), + signatures: vec![DsseSignature { + keyid: "test-key".to_string(), + sig: Base64.encode(b"fake-sig"), + }], + }; + + let json = serde_json::to_string(&envelope).unwrap(); //#[allow_ci] + let parsed: DsseEnvelope = serde_json::from_str(&json).unwrap(); //#[allow_ci] + + assert_eq!(parsed.payload_type, KEYLIME_PAYLOAD_TYPE); + assert_eq!(parsed.signatures.len(), 1); + assert_eq!(parsed.signatures[0].keyid, "test-key"); + } + + #[test] + fn test_sign_and_verify_roundtrip() { + // Use the ECDSA backend for integration test + let signer = ecdsa_backend::EcdsaSigner::generate().unwrap(); //#[allow_ci] + let verifier = + ecdsa_backend::EcdsaVerifier::from_signer(&signer).unwrap(); //#[allow_ci] + + let payload = b"test policy content"; + let envelope = + sign_payload(payload, KEYLIME_PAYLOAD_TYPE, &signer).unwrap(); //#[allow_ci] + + let verifier_ref: &dyn Verifier = &verifier; + let result = + verify_envelope(&envelope, &[("test", verifier_ref)]).unwrap(); //#[allow_ci] + + assert_eq!(result.payload, payload); + assert_eq!(result.payload_type, KEYLIME_PAYLOAD_TYPE); + assert_eq!(result.recognized_signers, vec!["test"]); + } + + #[test] + fn test_verify_invalid_signature() { + let signer = ecdsa_backend::EcdsaSigner::generate().unwrap(); //#[allow_ci] + + // Create a different key for verification + let other_signer = ecdsa_backend::EcdsaSigner::generate().unwrap(); //#[allow_ci] + let other_verifier = + ecdsa_backend::EcdsaVerifier::from_signer(&other_signer).unwrap(); //#[allow_ci] + + let envelope = + sign_payload(b"test", KEYLIME_PAYLOAD_TYPE, &signer).unwrap(); //#[allow_ci] + + let verifier_ref: &dyn Verifier = &other_verifier; + let result = + verify_envelope(&envelope, &[("wrong-key", verifier_ref)]); + + assert!(result.is_err()); + } + + #[test] + fn test_sign_and_verify_x509_roundtrip() { + let signer = x509_backend::X509Signer::generate(None).unwrap(); //#[allow_ci] + let verifier = + x509_backend::X509Verifier::from_signer(&signer).unwrap(); //#[allow_ci] + + let payload = b"test policy content"; + let envelope = + sign_payload(payload, KEYLIME_PAYLOAD_TYPE, &signer).unwrap(); //#[allow_ci] + + let verifier_ref: &dyn Verifier = &verifier; + let result = + verify_envelope(&envelope, &[("x509", verifier_ref)]).unwrap(); //#[allow_ci] + + assert_eq!(result.payload, payload); + assert_eq!(result.payload_type, KEYLIME_PAYLOAD_TYPE); + assert_eq!(result.recognized_signers, vec!["x509"]); + } +} diff --git a/keylimectl/src/policy_tools/dsse/x509_backend.rs b/keylimectl/src/policy_tools/dsse/x509_backend.rs new file mode 100644 index 000000000..bacabf770 --- /dev/null +++ b/keylimectl/src/policy_tools/dsse/x509_backend.rs @@ -0,0 +1,522 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! X.509 certificate-based signing and verification backend for DSSE. +//! +//! Uses ECDSA P-256 with SHA-256 for signing, with the key identifier +//! being the base64-encoded X.509 certificate. + +use super::{DsseError, Signer, Verifier}; +use base64::{engine::general_purpose::STANDARD as Base64, Engine}; +use openssl::asn1::Asn1Time; +use openssl::bn::BigNum; +use openssl::ec::{EcGroup, EcKey}; +use openssl::hash::MessageDigest; +use openssl::nid::Nid; +use openssl::pkey::{PKey, Private, Public}; +use openssl::sign; +use openssl::x509::extension::{BasicConstraints, SubjectAlternativeName}; +use openssl::x509::{X509NameBuilder, X509}; +use std::path::Path; + +/// X.509 certificate-based signer. +pub struct X509Signer { + private_key: PKey, + certificate_pem: Vec, +} + +impl std::fmt::Debug for X509Signer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("X509Signer") + .field("private_key", &"") + .finish() + } +} + +impl X509Signer { + /// Generate a new key pair and self-signed certificate. + pub fn generate(cert_path: Option<&str>) -> Result { + let group = + EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to create EC group: {e}"), + } + })?; + let ec_key = + EcKey::generate(&group).map_err(|e| DsseError::KeyError { + reason: format!("Failed to generate EC key: {e}"), + })?; + let private_key = + PKey::from_ec_key(ec_key).map_err(|e| DsseError::KeyError { + reason: format!("Failed to wrap EC key: {e}"), + })?; + + let certificate_pem = + build_self_signed_cert(&private_key, "keylimectl", 365)?; + + if let Some(path) = cert_path { + std::fs::write(path, &certificate_pem).map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to write certificate: {e}"), + } + })?; + } + + Ok(Self { + private_key, + certificate_pem, + }) + } + + /// Load a signer from existing key and certificate files. + pub fn from_files( + key_path: &str, + cert_path: &str, + ) -> Result { + let key_pem = + std::fs::read(key_path).map_err(|e| DsseError::KeyError { + reason: format!("Failed to read key file: {e}"), + })?; + let cert_pem = + std::fs::read(cert_path).map_err(|e| DsseError::KeyError { + reason: format!("Failed to read certificate file: {e}"), + })?; + + let private_key = + PKey::private_key_from_pem(&key_pem).map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to parse private key: {e}"), + } + })?; + + // Parse the certificate and verify it matches the private key. + let cert = + X509::from_pem(&cert_pem).map_err(|e| DsseError::KeyError { + reason: format!("Failed to parse certificate: {e}"), + })?; + + let cert_public_key = + cert.public_key().map_err(|e| DsseError::KeyError { + reason: format!( + "Failed to extract public key from certificate: {e}" + ), + })?; + if !cert_public_key.public_eq(&private_key) { + return Err(DsseError::KeyError { + reason: + "Certificate public key does not match the private key" + .to_string(), + }); + } + + Ok(Self { + private_key, + certificate_pem: cert_pem, + }) + } + + /// Save the private key to a PEM file with restricted permissions (0o600). + /// + /// Uses [`crate::policy_tools::privilege::write_sensitive_file`] to create + /// the file with the correct permissions atomically, avoiding a TOCTOU race + /// between file creation and a subsequent `chmod` call. + pub fn save_private_key(&self, path: &str) -> Result<(), DsseError> { + let pem = + self.private_key.private_key_to_pem_pkcs8().map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to encode private key: {e}"), + } + })?; + + crate::policy_tools::privilege::write_sensitive_file( + Path::new(path), + &pem, + ) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to write key file: {e}"), + }) + } + + /// Get the certificate in PEM format. + pub fn certificate_pem(&self) -> &[u8] { + &self.certificate_pem + } +} + +impl Signer for X509Signer { + fn sign(&self, message: &[u8]) -> Result, DsseError> { + let mut signer = + sign::Signer::new(MessageDigest::sha256(), &self.private_key) + .map_err(|e| DsseError::SigningFailed { + reason: format!("Failed to create signer: {e}"), + })?; + + signer + .update(message) + .map_err(|e| DsseError::SigningFailed { + reason: format!("Failed to update signer: {e}"), + })?; + + signer.sign_to_vec().map_err(|e| DsseError::SigningFailed { + reason: format!("Failed to sign: {e}"), + }) + } + + fn keyid(&self) -> String { + // Base64-encode the certificate PEM + Base64.encode(&self.certificate_pem) + } +} + +/// X.509 certificate-based verifier. +pub struct X509Verifier { + public_key: PKey, + cert_pem: Vec, +} + +impl X509Verifier { + /// Create a verifier from a PEM-encoded X.509 certificate. + /// + /// Returns an error if the certificate is expired or not yet valid. + pub fn from_cert_pem(pem: &[u8]) -> Result { + let cert = X509::from_pem(pem).map_err(|e| DsseError::KeyError { + reason: format!("Failed to parse certificate: {e}"), + })?; + + use std::cmp::Ordering; + let now = + Asn1Time::days_from_now(0).map_err(|e| DsseError::KeyError { + reason: format!("Failed to obtain current time: {e}"), + })?; + if cert.not_before().compare(&now).map_err(|e| { + DsseError::KeyError { + reason: format!( + "Failed to compare certificate validity dates: {e}" + ), + } + })? == Ordering::Greater + { + return Err(DsseError::KeyError { + reason: "Certificate is not yet valid".to_string(), + }); + } + if cert.not_after().compare(&now).map_err(|e| { + DsseError::KeyError { + reason: format!( + "Failed to compare certificate validity dates: {e}" + ), + } + })? == Ordering::Less + { + return Err(DsseError::KeyError { + reason: "Certificate has expired".to_string(), + }); + } + + let public_key = + cert.public_key().map_err(|e| DsseError::KeyError { + reason: format!( + "Failed to extract public key from cert: {e}" + ), + })?; + + Ok(Self { + public_key, + cert_pem: pem.to_vec(), + }) + } + + /// Create a verifier from a PEM-encoded certificate file. + pub fn from_cert_file(path: &str) -> Result { + let pem = std::fs::read(path).map_err(|e| DsseError::KeyError { + reason: format!("Failed to read certificate file: {e}"), + })?; + Self::from_cert_pem(&pem) + } + + /// Create a verifier from a signer's certificate. + #[allow(dead_code)] // Used only in tests; see test_sign_and_verify_x509_roundtrip in dsse/mod.rs + pub fn from_signer(signer: &X509Signer) -> Result { + Self::from_cert_pem(signer.certificate_pem()) + } +} + +impl Verifier for X509Verifier { + fn verify( + &self, + message: &[u8], + signature: &[u8], + ) -> Result { + let mut verifier = + sign::Verifier::new(MessageDigest::sha256(), &self.public_key) + .map_err(|e| DsseError::VerificationFailed { + reason: format!("Failed to create verifier: {e}"), + })?; + + verifier.update(message).map_err(|e| { + DsseError::VerificationFailed { + reason: format!("Failed to update verifier: {e}"), + } + })?; + + verifier.verify(signature).map_err(|e| { + DsseError::VerificationFailed { + reason: format!("Verification operation failed: {e}"), + } + }) + } + + fn keyid(&self) -> String { + Base64.encode(&self.cert_pem) + } +} + +/// Build a self-signed X.509 v3 certificate. +fn build_self_signed_cert( + private_key: &PKey, + subject_name: &str, + expiration_days: u32, +) -> Result, DsseError> { + let mut builder = openssl::x509::X509Builder::new().map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to create X509 builder: {e}"), + } + })?; + + // Version 3 + builder.set_version(2).map_err(|e| DsseError::KeyError { + reason: format!("Failed to set version: {e}"), + })?; + + // Serial number: random 128-bit value, MSB cleared to ensure positive sign. + let mut serial_bytes = [0u8; 16]; + openssl::rand::rand_bytes(&mut serial_bytes).map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to generate random serial bytes: {e}"), + } + })?; + serial_bytes[0] &= 0x7F; + let serial = BigNum::from_slice(&serial_bytes) + .and_then(|bn| bn.to_asn1_integer()) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to create serial: {e}"), + })?; + builder + .set_serial_number(&serial) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to set serial: {e}"), + })?; + + // Subject and issuer (same for self-signed) + let mut name_builder = + X509NameBuilder::new().map_err(|e| DsseError::KeyError { + reason: format!("Failed to create name builder: {e}"), + })?; + name_builder + .append_entry_by_text("CN", subject_name) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to add CN: {e}"), + })?; + let name = name_builder.build(); + + builder + .set_subject_name(&name) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to set subject: {e}"), + })?; + builder + .set_issuer_name(&name) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to set issuer: {e}"), + })?; + + // Validity period + let not_before = + Asn1Time::days_from_now(0).map_err(|e| DsseError::KeyError { + reason: format!("Failed to create not_before: {e}"), + })?; + let not_after = + Asn1Time::days_from_now(expiration_days).map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to create not_after: {e}"), + } + })?; + builder + .set_not_before(¬_before) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to set not_before: {e}"), + })?; + builder + .set_not_after(¬_after) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to set not_after: {e}"), + })?; + + // Public key + builder + .set_pubkey(private_key) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to set public key: {e}"), + })?; + + // Extensions + let basic_constraints = + BasicConstraints::new() + .build() + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to build basic constraints: {e}"), + })?; + builder.append_extension(basic_constraints).map_err(|e| { + DsseError::KeyError { + reason: format!("Failed to append basic constraints: {e}"), + } + })?; + + let san = SubjectAlternativeName::new() + .dns(subject_name) + .build(&builder.x509v3_context(None, None)) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to build SAN: {e}"), + })?; + builder + .append_extension(san) + .map_err(|e| DsseError::KeyError { + reason: format!("Failed to append SAN: {e}"), + })?; + + // Sign + builder + .sign(private_key, MessageDigest::sha256()) + .map_err(|e| DsseError::SigningFailed { + reason: format!("Failed to sign certificate: {e}"), + })?; + + let cert = builder.build(); + cert.to_pem().map_err(|e| DsseError::KeyError { + reason: format!("Failed to encode certificate: {e}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_signer() { + let signer = X509Signer::generate(None).unwrap(); //#[allow_ci] + assert!(!signer.keyid().is_empty()); + assert!(!signer.certificate_pem().is_empty()); + } + + #[test] + fn test_sign_verify_roundtrip() { + let signer = X509Signer::generate(None).unwrap(); //#[allow_ci] + let verifier = X509Verifier::from_signer(&signer).unwrap(); //#[allow_ci] + + let message = b"test message"; + let signature = signer.sign(message).unwrap(); //#[allow_ci] + + assert!(verifier.verify(message, &signature).unwrap()); //#[allow_ci] + } + + #[test] + fn test_verify_wrong_message() { + let signer = X509Signer::generate(None).unwrap(); //#[allow_ci] + let verifier = X509Verifier::from_signer(&signer).unwrap(); //#[allow_ci] + + let signature = signer.sign(b"original").unwrap(); //#[allow_ci] + + assert!(!verifier.verify(b"different", &signature).unwrap()); //#[allow_ci] + } + + #[test] + fn test_save_and_load() { + let signer = X509Signer::generate(None).unwrap(); //#[allow_ci] + let message = b"test message"; + let original_sig = signer.sign(message).unwrap(); //#[allow_ci] + + let key_file = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + let cert_file = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + + let key_path = key_file.path().to_string_lossy().to_string(); + let cert_path = cert_file.path().to_string_lossy().to_string(); + + signer.save_private_key(&key_path).unwrap(); //#[allow_ci] + std::fs::write(&cert_path, signer.certificate_pem()).unwrap(); //#[allow_ci] + + let loaded = X509Signer::from_files(&key_path, &cert_path).unwrap(); //#[allow_ci] + + let verifier = X509Verifier::from_signer(&loaded).unwrap(); //#[allow_ci] + assert!(verifier.verify(message, &original_sig).unwrap()); //#[allow_ci] + } + + #[test] + fn test_keyid_is_base64_cert() { + let signer = X509Signer::generate(None).unwrap(); //#[allow_ci] + let keyid = signer.keyid(); + + // Should be valid base64 + let decoded = Base64.decode(&keyid); + assert!(decoded.is_ok()); + + // Decoded should be a valid PEM certificate + let pem = decoded.unwrap(); //#[allow_ci] + assert!(std::str::from_utf8(&pem) + .unwrap() //#[allow_ci] + .contains("BEGIN CERTIFICATE")); + } + + #[test] + fn test_from_files_rejects_mismatched_key_and_cert() { + let signer_a = X509Signer::generate(None).unwrap(); //#[allow_ci] + let signer_b = X509Signer::generate(None).unwrap(); //#[allow_ci] + + let key_file = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + let cert_file = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + let key_path = key_file.path().to_string_lossy().to_string(); + let cert_path = cert_file.path().to_string_lossy().to_string(); + + signer_a.save_private_key(&key_path).unwrap(); //#[allow_ci] + std::fs::write(&cert_path, signer_b.certificate_pem()).unwrap(); //#[allow_ci] + + let result = X509Signer::from_files(&key_path, &cert_path); + assert!(result.is_err()); + assert!(result + .unwrap_err() //#[allow_ci] + .to_string() + .contains("does not match")); + } + + #[test] + fn test_serial_is_random() { + let signer_a = X509Signer::generate(None).unwrap(); //#[allow_ci] + let signer_b = X509Signer::generate(None).unwrap(); //#[allow_ci] + + let cert_a = X509::from_pem(signer_a.certificate_pem()).unwrap(); //#[allow_ci] + let cert_b = X509::from_pem(signer_b.certificate_pem()).unwrap(); //#[allow_ci] + + let serial_a = cert_a + .serial_number() + .to_bn() + .unwrap() //#[allow_ci] + .to_vec(); + let serial_b = cert_b + .serial_number() + .to_bn() + .unwrap() //#[allow_ci] + .to_vec(); + + assert_ne!(serial_a, serial_b, "serial numbers must differ"); + } + + #[test] + fn test_certificate_saved_to_file() { + let cert_file = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + let cert_path = cert_file.path().to_string_lossy().to_string(); + + let _signer = X509Signer::generate(Some(&cert_path)).unwrap(); //#[allow_ci] + + let cert_contents = std::fs::read_to_string(&cert_path).unwrap(); //#[allow_ci] + assert!(cert_contents.contains("BEGIN CERTIFICATE")); + } +} diff --git a/keylimectl/src/policy_tools/filesystem.rs b/keylimectl/src/policy_tools/filesystem.rs new file mode 100644 index 000000000..6f5c5012c --- /dev/null +++ b/keylimectl/src/policy_tools/filesystem.rs @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Filesystem scanning for policy generation. +//! +//! Walks a filesystem tree to calculate file digests, skipping +//! symlinks, non-regular files, and excluded paths. Digest +//! calculation is parallelised with Rayon. + +use super::DigestMap; +use crate::commands::error::PolicyGenerationError; +use crate::policy_tools::digest::calculate_file_digest; +use rayon::prelude::*; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Directories excluded by default during root filesystem scans. +/// +/// These directories contain volatile, virtual, or temporary data +/// that is not expected to be stable across boots and therefore has +/// no meaningful integrity to verify. +/// +/// Matches the `BASE_EXCLUDE_DIRS` used by Python `keylime-policy`. +pub const BASE_EXCLUDE_DIRS: &[&str] = &[ + "/sys", + "/run", + "/proc", + "/lost+found", + "/dev", + "/media", + "/snap", + "/mnt", + "/var", + "/tmp", +]; + +/// Build the effective list of skip paths by merging user-provided +/// paths with the default excluded directories. +/// +/// Each default directory is resolved relative to `rootfs` so that +/// scanning `/mnt/image` correctly skips `/mnt/image/sys`, etc. +/// +/// Returns `(effective_paths, redundant_user_paths)` where +/// `redundant_user_paths` lists any user-supplied paths that are +/// already covered by the defaults. +pub fn build_effective_skip_paths( + rootfs: &Path, + user_paths: &[String], +) -> (Vec, Vec) { + // Build default paths relative to rootfs + let default_paths: Vec = BASE_EXCLUDE_DIRS + .iter() + .map(|d| { + // Strip leading '/' so join works correctly: + // rootfs=/mnt/img, d=/sys → /mnt/img/sys + // rootfs=/, d=/sys → /sys + let relative = d.strip_prefix('/').unwrap_or(d); + rootfs.join(relative) + }) + .collect(); + + // Detect user paths that are already covered by a default + let mut redundant = Vec::new(); + for user in user_paths { + let user_pb = PathBuf::from(user); + let is_covered = default_paths + .iter() + .any(|dp| user_pb == *dp || user_pb.starts_with(dp)); + if is_covered { + redundant.push(user.clone()); + } + } + + // Merge: defaults first, then user paths that add something new + let mut effective: Vec = default_paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + + for user in user_paths { + if !redundant.contains(user) { + effective.push(user.clone()); + } + } + + (effective, redundant) +} + +/// Scan a filesystem tree and calculate digests for all regular files. +/// +/// File discovery is sequential (I/O-bound) but digest calculation +/// is parallelised across available CPU cores using Rayon. +/// +/// # Arguments +/// +/// * `root` - Root directory to scan +/// * `skip_paths` - Absolute paths to skip (directories and their contents) +/// * `algorithm` - Hash algorithm name (e.g., "sha256") +/// +/// # Returns +/// +/// A `DigestMap` where keys are file paths relative to `root` +/// (prefixed with `/`), and values are lists of digest strings. +pub fn scan_filesystem( + root: &Path, + skip_paths: &[String], + algorithm: &str, +) -> Result { + let root = root.canonicalize().map_err(|e| { + PolicyGenerationError::FilesystemScan { + path: root.to_path_buf(), + reason: format!("Failed to resolve path: {e}"), + } + })?; + + let skip_set: Vec = + skip_paths.iter().map(PathBuf::from).collect(); + + // Phase 1: collect all file paths (sequential, I/O-bound) + let mut files: Vec = Vec::new(); + collect_files(&root, &skip_set, &mut files)?; + + // Phase 2: calculate digests in parallel (CPU-bound) + let skipped_count = AtomicUsize::new(0); + let results: Vec<_> = files + .par_iter() + .filter_map(|path| match calculate_file_digest(path, algorithm) { + Ok(digest) => { + let relative = make_policy_path(path, &root); + Some((relative, digest)) + } + Err(e) => { + log::warn!("Skipping {}: {e}", path.display()); + let _ = skipped_count.fetch_add(1, Ordering::Relaxed); + None + } + }) + .collect(); + + let skipped = skipped_count.load(Ordering::Relaxed); + if skipped > 0 { + log::warn!( + "{skipped} file(s) skipped due to digest calculation errors" + ); + } + + // Phase 3: merge into DigestMap (sequential, fast) + let mut digests: DigestMap = HashMap::new(); + for (path, digest) in results { + let entry = digests.entry(path).or_default(); + if !entry.contains(&digest) { + entry.push(digest); + } + } + + Ok(digests) +} + +/// Recursively collect all regular file paths, skipping symlinks +/// and excluded directories. +fn collect_files( + dir: &Path, + skip_paths: &[PathBuf], + files: &mut Vec, +) -> Result<(), PolicyGenerationError> { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + // Permission denied on a subdirectory is not fatal + if e.kind() == std::io::ErrorKind::PermissionDenied { + log::warn!("Skipping directory {}: {}", dir.display(), e); + return Ok(()); + } + return Err(PolicyGenerationError::FilesystemScan { + path: dir.to_path_buf(), + reason: format!("Failed to read directory: {e}"), + }); + } + }; + + for entry in entries { + let entry = + entry.map_err(|e| PolicyGenerationError::FilesystemScan { + path: dir.to_path_buf(), + reason: format!("Failed to read entry: {e}"), + })?; + + let path = entry.path(); + + // Skip symlinks + if path.is_symlink() { + continue; + } + + // Check if path should be skipped + if should_skip(&path, skip_paths) { + log::debug!("Skipping: {}", path.display()); + continue; + } + + if path.is_dir() { + collect_files(&path, skip_paths, files)?; + } else if path.is_file() { + files.push(path); + } + } + + Ok(()) +} + +/// Check if a path should be skipped based on the skip list. +fn should_skip(path: &Path, skip_paths: &[PathBuf]) -> bool { + skip_paths.iter().any(|skip| path.starts_with(skip)) +} + +/// Convert an absolute file path to a policy-relative path. +/// +/// If the root is `/mnt/rootfs`, then `/mnt/rootfs/usr/bin/bash` +/// becomes `/usr/bin/bash`. +fn make_policy_path(path: &Path, root: &Path) -> String { + match path.strip_prefix(root) { + Ok(relative) => format!("/{}", relative.display()), + Err(_) => path.display().to_string(), + } +} + +/// Read `/proc/mounts` to detect non-root mount points that should +/// typically be excluded from filesystem scanning. +pub fn detect_non_root_mounts() -> Result, PolicyGenerationError> +{ + let content = std::fs::read_to_string("/proc/mounts").map_err(|e| { + PolicyGenerationError::FilesystemScan { + path: PathBuf::from("/proc/mounts"), + reason: format!("Failed to read /proc/mounts: {e}"), + } + })?; + + let mut mounts = Vec::new(); + + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let mount_point = parts[1]; + // Skip root and common virtual filesystems + if mount_point != "/" + && !mount_point.starts_with("/proc") + && !mount_point.starts_with("/sys") + && !mount_point.starts_with("/dev") + && !mount_point.starts_with("/run") + { + mounts.push(mount_point.to_string()); + } + } + } + + Ok(mounts) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_scan_filesystem_basic() { + let dir = TempDir::new().unwrap(); //#[allow_ci] + let root = dir.path(); + + // Create test files + fs::write(root.join("file1.txt"), "hello").unwrap(); //#[allow_ci] + fs::write(root.join("file2.txt"), "world").unwrap(); //#[allow_ci] + + let result = scan_filesystem(root, &[], "sha256").unwrap(); //#[allow_ci] + + assert_eq!(result.len(), 2); + assert!(result.contains_key("/file1.txt")); + assert!(result.contains_key("/file2.txt")); + // Each file should have exactly one bare hex digest (sha256 = 64 chars) + assert_eq!(result["/file1.txt"].len(), 1); + assert_eq!(result["/file1.txt"][0].len(), 64); + assert!(result["/file1.txt"][0] + .chars() + .all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_scan_filesystem_with_subdirs() { + let dir = TempDir::new().unwrap(); //#[allow_ci] + let root = dir.path(); + + fs::create_dir_all(root.join("usr/bin")).unwrap(); //#[allow_ci] + fs::write(root.join("usr/bin/bash"), "bash content").unwrap(); //#[allow_ci] + fs::write(root.join("usr/bin/ls"), "ls content").unwrap(); //#[allow_ci] + + let result = scan_filesystem(root, &[], "sha256").unwrap(); //#[allow_ci] + + assert_eq!(result.len(), 2); + assert!(result.contains_key("/usr/bin/bash")); + assert!(result.contains_key("/usr/bin/ls")); + } + + #[test] + fn test_scan_filesystem_skip_paths() { + let dir = TempDir::new().unwrap(); //#[allow_ci] + let root = dir.path(); + + fs::create_dir_all(root.join("include")).unwrap(); //#[allow_ci] + fs::create_dir_all(root.join("exclude")).unwrap(); //#[allow_ci] + fs::write(root.join("include/file.txt"), "include").unwrap(); //#[allow_ci] + fs::write(root.join("exclude/file.txt"), "exclude").unwrap(); //#[allow_ci] + + let skip = vec![root.join("exclude").to_string_lossy().to_string()]; + let result = scan_filesystem(root, &skip, "sha256").unwrap(); //#[allow_ci] + + assert_eq!(result.len(), 1); + assert!(result.contains_key("/include/file.txt")); + assert!(!result.contains_key("/exclude/file.txt")); + } + + #[test] + fn test_scan_filesystem_empty_dir() { + let dir = TempDir::new().unwrap(); //#[allow_ci] + + let result = scan_filesystem(dir.path(), &[], "sha256").unwrap(); //#[allow_ci] + + assert!(result.is_empty()); + } + + #[test] + fn test_make_policy_path() { + assert_eq!( + make_policy_path( + Path::new("/mnt/rootfs/usr/bin/bash"), + Path::new("/mnt/rootfs") + ), + "/usr/bin/bash" + ); + assert_eq!( + make_policy_path(Path::new("/usr/bin/bash"), Path::new("/")), + "/usr/bin/bash" + ); + } + + #[test] + fn test_should_skip() { + let skip = vec![PathBuf::from("/tmp"), PathBuf::from("/proc")]; + + assert!(should_skip(Path::new("/tmp/foo"), &skip)); + assert!(should_skip(Path::new("/proc/1"), &skip)); + assert!(!should_skip(Path::new("/usr/bin/bash"), &skip)); + } + + #[test] + fn test_build_effective_skip_paths_root() { + let (effective, redundant) = + build_effective_skip_paths(Path::new("/"), &[]); + + // All base dirs should be present + assert!(effective.contains(&"/sys".to_string())); + assert!(effective.contains(&"/run".to_string())); + assert!(effective.contains(&"/proc".to_string())); + assert!(effective.contains(&"/tmp".to_string())); + assert!(effective.contains(&"/var".to_string())); + assert!(redundant.is_empty()); + } + + #[test] + fn test_build_effective_skip_paths_custom_rootfs() { + let (effective, _) = + build_effective_skip_paths(Path::new("/mnt/rootfs"), &[]); + + assert!(effective.contains(&"/mnt/rootfs/sys".to_string())); + assert!(effective.contains(&"/mnt/rootfs/run".to_string())); + assert!(effective.contains(&"/mnt/rootfs/tmp".to_string())); + } + + #[test] + fn test_build_effective_skip_paths_redundant() { + let user = vec!["/var/log".to_string(), "/home".to_string()]; + let (effective, redundant) = + build_effective_skip_paths(Path::new("/"), &user); + + // /var/log is under /var (a default) so it's redundant + assert_eq!(redundant, vec!["/var/log".to_string()]); + // /home is NOT a default so it should be added + assert!(effective.contains(&"/home".to_string())); + // /var/log should NOT be in effective (it's redundant) + assert!(!effective.contains(&"/var/log".to_string())); + } + + #[test] + fn test_build_effective_skip_paths_exact_match() { + let user = vec!["/tmp".to_string()]; + let (_, redundant) = + build_effective_skip_paths(Path::new("/"), &user); + + // /tmp exactly matches a default + assert_eq!(redundant, vec!["/tmp".to_string()]); + } + + #[test] + fn test_scan_filesystem_parallel_produces_correct_results() { + let dir = TempDir::new().unwrap(); //#[allow_ci] + let root = dir.path(); + + // Create many files to exercise parallel paths + for i in 0..50 { + fs::write( + root.join(format!("file_{i}.txt")), + format!("content {i}"), + ) + .unwrap(); //#[allow_ci] + } + + let result = scan_filesystem(root, &[], "sha256").unwrap(); //#[allow_ci] + assert_eq!(result.len(), 50); + } +} diff --git a/keylimectl/src/policy_tools/gpg_verify.rs b/keylimectl/src/policy_tools/gpg_verify.rs new file mode 100644 index 000000000..db768cbe4 --- /dev/null +++ b/keylimectl/src/policy_tools/gpg_verify.rs @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! PGP detached signature verification for RPM repository metadata. + +use sequoia_openpgp::parse::stream::{ + DetachedVerifierBuilder, MessageLayer, MessageStructure, + VerificationHelper, +}; +use sequoia_openpgp::parse::Parse; +use sequoia_openpgp::policy::StandardPolicy; +use sequoia_openpgp::KeyHandle; + +use crate::commands::error::PolicyGenerationError; + +struct Helper { + cert: sequoia_openpgp::Cert, +} + +impl VerificationHelper for Helper { + fn get_certs( + &mut self, + _ids: &[KeyHandle], + ) -> sequoia_openpgp::Result> { + Ok(vec![self.cert.clone()]) + } + + fn check( + &mut self, + structure: MessageStructure, + ) -> sequoia_openpgp::Result<()> { + for layer in structure { + if let MessageLayer::SignatureGroup { results } = layer { + for result in results { + if result.is_ok() { + return Ok(()); + } + } + } + } + Err(anyhow::anyhow!("No valid signature found")) + } +} + +/// Verify a PGP detached signature. +/// +/// `key_data` — ASCII-armored or binary PGP public key. +/// `sig_data` — ASCII-armored or binary detached signature. +/// `body` — The signed content. +/// `path` — Used in error messages to identify the signed file. +pub fn verify_detached_signature( + key_data: &[u8], + sig_data: &[u8], + body: &[u8], + path: &std::path::Path, +) -> Result<(), PolicyGenerationError> { + let policy = &StandardPolicy::new(); + + let cert = sequoia_openpgp::Cert::from_reader(key_data).map_err(|e| { + PolicyGenerationError::GpgVerification { + path: path.to_path_buf(), + reason: format!("Failed to parse PGP key: {e}"), + } + })?; + + let helper = Helper { cert }; + + let mut verifier = DetachedVerifierBuilder::from_reader(sig_data) + .map_err(|e| PolicyGenerationError::GpgVerification { + path: path.to_path_buf(), + reason: format!("Failed to parse PGP signature: {e}"), + })? + .with_policy(policy, None, helper) + .map_err(|e| PolicyGenerationError::GpgVerification { + path: path.to_path_buf(), + reason: format!("Verification setup failed: {e}"), + })?; + + verifier.verify_bytes(body).map_err(|e| { + PolicyGenerationError::GpgVerification { + path: path.to_path_buf(), + reason: format!("Signature verification failed: {e}"), + } + })?; + + Ok(()) +} + +/// Load GPG key bytes from a file, mapping IO errors to PolicyGenerationError. +pub fn load_key_file( + key_path: &std::path::Path, +) -> Result, PolicyGenerationError> { + std::fs::read(key_path).map_err(|e| { + PolicyGenerationError::GpgVerification { + path: key_path.to_path_buf(), + reason: format!("Failed to read GPG key file: {e}"), + } + }) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use sequoia_openpgp::cert::CertBuilder; + use sequoia_openpgp::policy::StandardPolicy; + use sequoia_openpgp::serialize::stream::{Message, Signer}; + use sequoia_openpgp::serialize::SerializeInto; + + use super::*; + + fn make_test_cert_and_sign(data: &[u8]) -> (Vec, Vec) { + let policy = StandardPolicy::new(); + + let (cert, _rev) = CertBuilder::new() + .add_signing_subkey() + .generate() + .expect("key generation failed"); + + let pub_key = cert.armored().to_vec().expect("key export failed"); + + let keypair = cert + .keys() + .with_policy(&policy, None) + .alive() + .revoked(false) + .for_signing() + .next() + .expect("no signing key") + .key() + .clone() + .parts_into_secret() + .expect("secret key unavailable") + .into_keypair() + .expect("keypair construction failed"); + + let mut sig_bytes: Vec = Vec::new(); + { + let message = Message::new(&mut sig_bytes); + let mut signer = Signer::new(message, keypair) + .expect("signer construction failed") + .detached() + .build() + .expect("signer build failed"); + signer.write_all(data).expect("write failed"); + signer.finalize().expect("finalize failed"); + } + + (pub_key, sig_bytes) + } + + #[test] + fn test_valid_signature() { + let body = b"repomd.xml content"; + let (pub_key, sig) = make_test_cert_and_sign(body); + let path = std::path::Path::new("repomd.xml"); + + assert!(verify_detached_signature(&pub_key, &sig, body, path).is_ok()); + } + + #[test] + fn test_tampered_body_fails() { + let body = b"repomd.xml content"; + let (pub_key, sig) = make_test_cert_and_sign(body); + let path = std::path::Path::new("repomd.xml"); + + let tampered = b"tampered repomd.xml content"; + assert!(verify_detached_signature(&pub_key, &sig, tampered, path) + .is_err()); + } + + #[test] + fn test_wrong_key_fails() { + let body = b"repomd.xml content"; + let (_, sig) = make_test_cert_and_sign(body); + let (other_pub_key, _) = make_test_cert_and_sign(b"other"); + let path = std::path::Path::new("repomd.xml"); + + assert!(verify_detached_signature(&other_pub_key, &sig, body, path) + .is_err()); + } + + #[test] + fn test_bad_key_data_fails() { + let path = std::path::Path::new("repomd.xml"); + let result = verify_detached_signature( + b"not a pgp key", + b"not a signature", + b"body", + path, + ); + assert!(result.is_err()); + } + + #[test] + fn test_load_key_file_missing() { + let result = load_key_file(std::path::Path::new("/nonexistent/key")); + assert!(result.is_err()); + } +} diff --git a/keylimectl/src/policy_tools/ima_parser.rs b/keylimectl/src/policy_tools/ima_parser.rs new file mode 100644 index 000000000..2f98473df --- /dev/null +++ b/keylimectl/src/policy_tools/ima_parser.rs @@ -0,0 +1,740 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! IMA measurement list and allowlist parsing for policy generation. +//! +//! Parses IMA ASCII runtime measurement lists to extract file digests, +//! keyring entries, and ima-buf entries for building runtime policies. +//! Also handles legacy flat-text and JSON allowlist formats. + +use crate::commands::error::PolicyGenerationError; +use std::collections::HashMap; +use std::path::Path; + +pub use super::DigestMap; + +/// Parsed data from an IMA measurement list. +pub struct ParsedImaData { + /// File path -> list of digest strings (bare hex, e.g., `"abcdef1234..."`) + pub digests: DigestMap, + + /// Keyring name -> list of digest strings + pub keyrings: DigestMap, + + /// ima-buf entry name -> list of digest strings + pub ima_buf: DigestMap, + + /// Detected hash algorithm name (from the first valid entry's file digest) + pub detected_algorithm: Option, + + /// Detected IMA log template hash algorithm (from the template hash field length). + /// + /// This is the algorithm used by IMA to hash template data (the 2nd field + /// in each IMA log line). It may differ from `detected_algorithm` which is + /// the algorithm used for individual file content digests. + pub detected_log_hash_alg: Option, +} + +/// Parse an IMA ASCII measurement list file. +/// +/// Reads the file line by line and extracts digests for entries +/// with `ima`, `ima-ng`, and `ima-sig` templates. Also extracts +/// `ima-buf` entries into separate maps based on whether they +/// appear to be keyring entries. +/// +/// # Arguments +/// +/// * `path` - Path to the IMA measurement list file +/// * `get_keyrings` - Whether to extract keyring entries from ima-buf +/// * `get_ima_buf` - Whether to extract non-keyring ima-buf entries +/// * `ignored_keyrings` - Keyring names to skip +pub fn parse_ima_measurement_list( + path: &Path, + get_keyrings: bool, + get_ima_buf: bool, + ignored_keyrings: &[String], +) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| { + PolicyGenerationError::ImaParse { + path: path.to_path_buf(), + reason: format!("Failed to read file: {e}"), + } + })?; + + let mut digests: DigestMap = HashMap::new(); + let mut keyrings: DigestMap = HashMap::new(); + let mut ima_buf: DigestMap = HashMap::new(); + let mut detected_algorithm: Option = None; + let mut detected_log_hash_alg: Option = None; + + for (line_num, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // IMA log format: + let tokens: Vec<&str> = line.splitn(4, ' ').collect(); + if tokens.len() < 4 { + // Skip malformed lines + log::debug!( + "Skipping malformed IMA line {}: too few fields", + line_num + 1 + ); + continue; + } + + // Detect the template hash algorithm from the hash length + // (this is different from the file digest algorithm) + if detected_log_hash_alg.is_none() { + detected_log_hash_alg = detect_algorithm_from_hex(tokens[1]); + } + + let template_name = tokens[2]; + let template_data = tokens[3]; + + match template_name { + "ima" => { + // Legacy template: + if let Some((digest_hex, file_path)) = + parse_ima_template(template_data) + { + // Detect algorithm from hex length + if detected_algorithm.is_none() { + detected_algorithm = + detect_algorithm_from_hex(digest_hex); + } + add_digest( + &mut digests, + file_path.to_string(), + digest_hex.to_string(), + ); + } + } + "ima-ng" | "ima-sig" => { + // Modern templates: [signature] + if let Some((digest_str, file_path)) = + parse_ima_ng_template(template_data) + { + // Split "sha256:hex" into algorithm and bare hex + if let Some((alg, hex_value)) = digest_str.split_once(':') + { + if detected_algorithm.is_none() { + detected_algorithm = Some(alg.to_string()); + } + add_digest( + &mut digests, + file_path.to_string(), + hex_value.to_string(), + ); + } + } + } + "ima-buf" => { + if get_keyrings || get_ima_buf { + if let Some((digest_str, name, data_hex)) = + parse_ima_buf_template(template_data) + { + // Strip algorithm prefix from "sha256:hex" + let hex_value = digest_str + .split_once(':') + .map(|(_, h)| h) + .unwrap_or(digest_str); + + // Check if this is a keyring entry by attempting + // to detect ASN.1 DER structure in the data + let is_keyring = is_asn1_data(data_hex); + + if is_keyring && get_keyrings { + if !ignored_keyrings.contains(&name.to_string()) { + add_digest( + &mut keyrings, + name.to_string(), + hex_value.to_string(), + ); + } + } else if !is_keyring && get_ima_buf { + add_digest( + &mut ima_buf, + name.to_string(), + hex_value.to_string(), + ); + } + } + } + } + _ => { + // Skip unrecognized templates + log::debug!( + "Skipping unrecognized IMA template '{}' at line {}", + template_name, + line_num + 1 + ); + } + } + } + + Ok(ParsedImaData { + digests, + keyrings, + ima_buf, + detected_algorithm, + detected_log_hash_alg, + }) +} + +/// Parse a flat-text allowlist file (hash whitespace path format). +/// +/// Each line contains a digest value and a file path separated by whitespace. +/// Lines starting with `#` are treated as comments. Empty lines are skipped. +pub fn parse_flat_allowlist( + path: &Path, +) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| { + PolicyGenerationError::AllowlistParse { + path: path.to_path_buf(), + reason: format!("Failed to read file: {e}"), + } + })?; + + let mut digests: DigestMap = HashMap::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Split into hash and path + let parts: Vec<&str> = + line.splitn(2, |c: char| c.is_whitespace()).collect(); + if parts.len() != 2 { + continue; + } + + let hash = parts[0].trim(); + let file_path = parts[1].trim().replace(' ', "_"); + + if !hash.is_empty() && !file_path.is_empty() { + // Strip algorithm prefix if present (e.g., "sha256:hex" → "hex") + let bare_hex = + hash.split_once(':').map(|(_, h)| h).unwrap_or(hash); + add_digest(&mut digests, file_path, bare_hex.to_string()); + } + } + + Ok(digests) +} + +/// Parse a JSON allowlist file. +/// +/// Accepts either the legacy format with a `"hashes"` key or +/// the current format with a `"digests"` key. +pub fn parse_json_allowlist( + path: &Path, +) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| { + PolicyGenerationError::AllowlistParse { + path: path.to_path_buf(), + reason: format!("Failed to read file: {e}"), + } + })?; + + let value: serde_json::Value = + serde_json::from_str(&content).map_err(|e| { + PolicyGenerationError::AllowlistParse { + path: path.to_path_buf(), + reason: format!("Invalid JSON: {e}"), + } + })?; + + // Try "digests" key first, then "hashes" for legacy format + let hashes = value + .get("digests") + .or_else(|| value.get("hashes")) + .ok_or_else(|| PolicyGenerationError::AllowlistParse { + path: path.to_path_buf(), + reason: "Missing 'digests' or 'hashes' key".to_string(), + })?; + + let hashes_map = hashes.as_object().ok_or_else(|| { + PolicyGenerationError::AllowlistParse { + path: path.to_path_buf(), + reason: "'digests'/'hashes' is not an object".to_string(), + } + })?; + + let mut digests: DigestMap = HashMap::new(); + + for (file_path, digest_list) in hashes_map { + if let Some(arr) = digest_list.as_array() { + for digest_val in arr { + if let Some(digest_str) = digest_val.as_str() { + // Strip algorithm prefix if present (e.g., "sha256:hex" → "hex") + let bare_hex = digest_str + .split_once(':') + .map(|(_, h)| h) + .unwrap_or(digest_str); + add_digest( + &mut digests, + file_path.clone(), + bare_hex.to_string(), + ); + } + } + } + } + + Ok(digests) +} + +/// Parse an exclude list file (one glob pattern per line). +pub fn parse_excludelist( + path: &Path, +) -> Result, PolicyGenerationError> { + let content = std::fs::read_to_string(path).map_err(|e| { + PolicyGenerationError::AllowlistParse { + path: path.to_path_buf(), + reason: format!("Failed to read exclude list: {e}"), + } + })?; + + let mut excludes = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + if !line.is_empty() && !line.starts_with('#') { + excludes.push(line.to_string()); + } + } + + Ok(excludes) +} + +/// Detect hash algorithm name from hex digest length. +/// +/// Returns `None` for ambiguous lengths (SHA-256 and SM3-256 both produce +/// 64 hex characters). +pub fn detect_algorithm_from_hex(hex_digest: &str) -> Option { + match hex_digest.len() { + 40 => Some("sha1".to_string()), + 64 => Some("sha256".to_string()), // Could also be sm3_256 + 96 => Some("sha384".to_string()), + 128 => Some("sha512".to_string()), + _ => None, + } +} + +/// Parse a JSON allowlist from a `serde_json::Value`. +/// +/// Accepts either the legacy format with a `"hashes"` key or +/// the current format with a `"digests"` key. +pub fn parse_json_allowlist_value( + value: &serde_json::Value, +) -> Result { + let hashes = value + .get("digests") + .or_else(|| value.get("hashes")) + .ok_or_else(|| PolicyGenerationError::AllowlistParse { + path: "".into(), + reason: "Missing 'digests' or 'hashes' key".to_string(), + })?; + + let hashes_map = hashes.as_object().ok_or_else(|| { + PolicyGenerationError::AllowlistParse { + path: "".into(), + reason: "'digests'/'hashes' is not an object".to_string(), + } + })?; + + let mut digests: DigestMap = HashMap::new(); + + for (file_path, digest_list) in hashes_map { + if let Some(arr) = digest_list.as_array() { + for digest_val in arr { + if let Some(digest_str) = digest_val.as_str() { + // Strip algorithm prefix if present (e.g., "sha256:hex" → "hex") + let bare_hex = digest_str + .split_once(':') + .map(|(_, h)| h) + .unwrap_or(digest_str); + add_digest( + &mut digests, + file_path.clone(), + bare_hex.to_string(), + ); + } + } + } + } + + Ok(digests) +} + +/// Parse a flat-text allowlist from a string. +/// +/// Each line contains a digest value and a file path separated by whitespace. +/// Lines starting with `#` are treated as comments. Empty lines are skipped. +pub fn parse_flat_allowlist_str( + text: &str, +) -> Result { + let mut digests: DigestMap = HashMap::new(); + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let parts: Vec<&str> = + line.splitn(2, |c: char| c.is_whitespace()).collect(); + if parts.len() != 2 { + continue; + } + + let hash = parts[0].trim(); + let file_path = parts[1].trim().replace(' ', "_"); + + if !hash.is_empty() && !file_path.is_empty() { + // Strip algorithm prefix if present (e.g., "sha256:hex" → "hex") + let bare_hex = + hash.split_once(':').map(|(_, h)| h).unwrap_or(hash); + add_digest(&mut digests, file_path, bare_hex.to_string()); + } + } + + Ok(digests) +} + +// --- Internal parsing helpers --- + +/// Parse legacy `ima` template data: ` ` +fn parse_ima_template(data: &str) -> Option<(&str, &str)> { + let parts: Vec<&str> = data.splitn(2, ' ').collect(); + if parts.len() == 2 { + Some((parts[0], parts[1])) + } else { + None + } +} + +/// Parse `ima-ng` or `ima-sig` template data: ` [signature]` +fn parse_ima_ng_template(data: &str) -> Option<(&str, &str)> { + let parts: Vec<&str> = data.splitn(3, ' ').collect(); + if parts.len() >= 2 { + Some((parts[0], parts[1])) + } else { + None + } +} + +/// Parse `ima-buf` template data: ` ` +fn parse_ima_buf_template(data: &str) -> Option<(&str, &str, &str)> { + let parts: Vec<&str> = data.splitn(3, ' ').collect(); + if parts.len() == 3 { + Some((parts[0], parts[1], parts[2])) + } else { + None + } +} + +/// Check if hex-encoded data starts with an ASN.1 DER structure. +/// +/// A simple heuristic: ASN.1 DER sequences start with tag 0x30 (SEQUENCE). +/// This is used to distinguish keyring entries (certificates/keys) from +/// other ima-buf entries. +fn is_asn1_data(hex_data: &str) -> bool { + // ASN.1 SEQUENCE tag + hex_data.starts_with("30") +} + +/// Add a digest to a digest map, avoiding duplicates. +fn add_digest(map: &mut DigestMap, path: String, digest: String) { + let entry = map.entry(path).or_default(); + if !entry.contains(&digest) { + entry.push(digest); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_temp_file(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); //#[allow_ci] + f.write_all(content.as_bytes()).unwrap(); //#[allow_ci] + f + } + + #[test] + fn test_parse_ima_ng_line() { + let data = "sha256:f1125b940480d20ad841d26d5ea253edc0704b5ec1548c891edf212cb1a9365e /usr/bin/bash"; + let result = parse_ima_ng_template(data); + assert!(result.is_some()); + let (digest, path) = result.unwrap(); //#[allow_ci] + assert_eq!(digest, "sha256:f1125b940480d20ad841d26d5ea253edc0704b5ec1548c891edf212cb1a9365e"); + assert_eq!(path, "/usr/bin/bash"); + } + + #[test] + fn test_parse_ima_sig_line() { + let data = "sha256:abcdef1234567890 /usr/bin/foo 030202531f40250048"; + let result = parse_ima_ng_template(data); + assert!(result.is_some()); + let (digest, path) = result.unwrap(); //#[allow_ci] + assert_eq!(digest, "sha256:abcdef1234567890"); + assert_eq!(path, "/usr/bin/foo"); + } + + #[test] + fn test_parse_ima_buf_line() { + let data = "sha256:abcdef1234567890 device_resume 6e616d653d54455354"; + let result = parse_ima_buf_template(data); + assert!(result.is_some()); + let (digest, name, buf) = result.unwrap(); //#[allow_ci] + assert_eq!(digest, "sha256:abcdef1234567890"); + assert_eq!(name, "device_resume"); + assert_eq!(buf, "6e616d653d54455354"); + } + + #[test] + fn test_parse_legacy_ima_line() { + let data = "6f66d1d8e2fffcc12dfcb78c04b81fe5b8bbae4e /usr/bin/kmod"; + let result = parse_ima_template(data); + assert!(result.is_some()); + let (digest, path) = result.unwrap(); //#[allow_ci] + assert_eq!(digest, "6f66d1d8e2fffcc12dfcb78c04b81fe5b8bbae4e"); + assert_eq!(path, "/usr/bin/kmod"); + } + + #[test] + fn test_detect_algorithm_from_hex() { + assert_eq!( + detect_algorithm_from_hex( + "6f66d1d8e2fffcc12dfcb78c04b81fe5b8bbae4e" + ), + Some("sha1".to_string()) + ); + assert_eq!( + detect_algorithm_from_hex( + "f1125b940480d20ad841d26d5ea253edc0704b5ec1548c891edf212cb1a9365e" + ), + Some("sha256".to_string()) + ); + assert_eq!(detect_algorithm_from_hex("abcd"), None); + } + + #[test] + fn test_parse_ima_measurement_list() { + // Template hashes are sha1 (40 hex chars), file digests are sha256 + let content = "\ +10 d7026dc672344d3ee372217bdbc7395947788671 ima 6f66d1d8e2fffcc12dfcb78c04b81fe5b8bbae4e /usr/bin/kmod +10 7936eb315fb4e74b99e7d461bc5c96049e1ee092 ima-ng sha256:f1125b940480d20ad841d26d5ea253edc0704b5ec1548c891edf212cb1a9365e /usr/bin/bash +10 06e804489a77ddab51b9ef27e17053c0e5d503bd ima-sig sha256:1cb84b12db45d7da8de58ba6744187db84082f0e1cb84b12db45d7da8de58ba6 /usr/bin/ls 030202531f402500 +"; + let f = write_temp_file(content); + let result = + parse_ima_measurement_list(f.path(), false, false, &[]).unwrap(); //#[allow_ci] + + assert_eq!(result.digests.len(), 3); + assert!(result.digests.contains_key("/usr/bin/kmod")); + assert!(result.digests.contains_key("/usr/bin/bash")); + assert!(result.digests.contains_key("/usr/bin/ls")); + // Digests are stored as bare hex (algorithm prefix stripped) + assert_eq!( + result.digests["/usr/bin/bash"], + vec!["f1125b940480d20ad841d26d5ea253edc0704b5ec1548c891edf212cb1a9365e"] + ); + // Template hash algorithm (sha1) differs from file digest algorithm (sha256) + assert_eq!(result.detected_log_hash_alg, Some("sha1".to_string())); + assert_eq!(result.detected_algorithm, Some("sha1".to_string())); + } + + #[test] + fn test_parse_ima_measurement_list_with_ima_buf() { + let content = "\ +10 aaaa ima-ng sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 /usr/bin/foo +10 bbbb ima-buf sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef device_resume 6e616d653d54455354 +10 cccc ima-buf sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321 .builtin_trusted_keys 308201a2 +"; + let f = write_temp_file(content); + let result = + parse_ima_measurement_list(f.path(), true, true, &[]).unwrap(); //#[allow_ci] + + assert_eq!(result.digests.len(), 1); + // device_resume is not ASN.1, so it goes to ima_buf + assert_eq!(result.ima_buf.len(), 1); + assert!(result.ima_buf.contains_key("device_resume")); + // .builtin_trusted_keys starts with 0x30 (ASN.1 SEQUENCE), so it goes to keyrings + assert_eq!(result.keyrings.len(), 1); + assert!(result.keyrings.contains_key(".builtin_trusted_keys")); + } + + #[test] + fn test_parse_ima_measurement_list_ignored_keyrings() { + let content = "\ +10 cccc ima-buf sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321 .builtin_trusted_keys 308201a2 +10 dddd ima-buf sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 _ima 308201b3 +"; + let f = write_temp_file(content); + let result = parse_ima_measurement_list( + f.path(), + true, + false, + &["_ima".to_string()], + ) + .unwrap(); //#[allow_ci] + + assert_eq!(result.keyrings.len(), 1); + assert!(result.keyrings.contains_key(".builtin_trusted_keys")); + assert!(!result.keyrings.contains_key("_ima")); + } + + #[test] + fn test_parse_flat_allowlist() { + let content = "\ +# Comment line +6f66d1d8e2fffcc12dfcb78c04b81fe5b8bbae4e /usr/bin/kmod +abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 /usr/bin/bash + +"; + let f = write_temp_file(content); + let result = parse_flat_allowlist(f.path()).unwrap(); //#[allow_ci] + + assert_eq!(result.len(), 2); + assert_eq!( + result["/usr/bin/kmod"], + vec!["6f66d1d8e2fffcc12dfcb78c04b81fe5b8bbae4e"] + ); + } + + #[test] + fn test_parse_json_allowlist_digests_key() { + let content = r#"{ + "digests": { + "/usr/bin/bash": ["sha256:abcdef1234567890"], + "/usr/bin/ls": ["sha256:1234567890abcdef", "sha1:aabbccddee"] + } + }"#; + let f = write_temp_file(content); + let result = parse_json_allowlist(f.path()).unwrap(); //#[allow_ci] + + assert_eq!(result.len(), 2); + // Algorithm prefix is stripped + assert_eq!(result["/usr/bin/bash"], vec!["abcdef1234567890"]); + assert_eq!(result["/usr/bin/ls"].len(), 2); + } + + #[test] + fn test_parse_json_allowlist_hashes_key() { + let content = r#"{ + "hashes": { + "/usr/bin/foo": ["sha256:deadbeef"] + } + }"#; + let f = write_temp_file(content); + let result = parse_json_allowlist(f.path()).unwrap(); //#[allow_ci] + + assert_eq!(result.len(), 1); + // Algorithm prefix is stripped + assert_eq!(result["/usr/bin/foo"], vec!["deadbeef"]); + } + + #[test] + fn test_parse_excludelist() { + let content = "\ +# Skip boot aggregate +boot_aggregate +/tmp/* +/proc/* +"; + let f = write_temp_file(content); + let result = parse_excludelist(f.path()).unwrap(); //#[allow_ci] + + assert_eq!(result.len(), 3); + assert_eq!(result[0], "boot_aggregate"); + assert_eq!(result[1], "/tmp/*"); + assert_eq!(result[2], "/proc/*"); + } + + #[test] + fn test_merge_digest_maps() { + let mut base: DigestMap = HashMap::new(); + let _ = base + .insert("/usr/bin/bash".to_string(), vec!["aaaa".to_string()]); + + let mut other: DigestMap = HashMap::new(); + let _ = other.insert( + "/usr/bin/bash".to_string(), + vec![ + "aaaa".to_string(), // duplicate + "bbbb".to_string(), // new + ], + ); + let _ = + other.insert("/usr/bin/ls".to_string(), vec!["cccc".to_string()]); + + crate::policy_tools::merge_digest_maps(&mut base, &other); + + assert_eq!(base.len(), 2); + // Duplicate should not be added + assert_eq!(base["/usr/bin/bash"].len(), 2); + assert_eq!(base["/usr/bin/bash"][0], "aaaa"); + assert_eq!(base["/usr/bin/bash"][1], "bbbb"); + assert_eq!(base["/usr/bin/ls"], vec!["cccc"]); + } + + #[test] + fn test_is_asn1_data() { + // ASN.1 SEQUENCE starts with 0x30 + assert!(is_asn1_data("308201a2")); + // Not ASN.1 + assert!(!is_asn1_data("6e616d653d54455354")); + assert!(!is_asn1_data("")); + } + + #[test] + fn test_flat_allowlist_space_in_path_replaced() { + // Flat allowlists may have paths with spaces; IMA uses underscores + let content = "\ +abcdef1234567890 /path/with space/file +"; + let f = write_temp_file(content); + let result = parse_flat_allowlist(f.path()).unwrap(); //#[allow_ci] + + // Spaces in paths should be replaced with underscores + assert!(result.contains_key("/path/with_space/file")); + } + + #[test] + fn test_detected_algorithm() { + let content = "\ +10 aaaa ima-ng sha384:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 /usr/bin/foo +"; + let f = write_temp_file(content); + let result = + parse_ima_measurement_list(f.path(), false, false, &[]).unwrap(); //#[allow_ci] + + assert_eq!(result.detected_algorithm, Some("sha384".to_string())); + // "aaaa" is only 4 hex chars, so log_hash_alg is not detected + assert_eq!(result.detected_log_hash_alg, None); + } + + #[test] + fn test_detected_log_hash_alg_differs_from_digest_alg() { + // Template hash is sha1 (40 hex chars), file digest is sha256 + let content = "\ +10 d7026dc672344d3ee372217bdbc7395947788671 ima-ng sha256:f1125b940480d20ad841d26d5ea253edc0704b5ec1548c891edf212cb1a9365e /usr/bin/foo +"; + let f = write_temp_file(content); + let result = + parse_ima_measurement_list(f.path(), false, false, &[]).unwrap(); //#[allow_ci] + + // File digest algorithm is sha256 + assert_eq!(result.detected_algorithm, Some("sha256".to_string())); + // Template hash algorithm is sha1 (detected from 40-char hex hash) + assert_eq!(result.detected_log_hash_alg, Some("sha1".to_string())); + } +} diff --git a/keylimectl/src/policy_tools/initrd.rs b/keylimectl/src/policy_tools/initrd.rs new file mode 100644 index 000000000..f4e74be88 --- /dev/null +++ b/keylimectl/src/policy_tools/initrd.rs @@ -0,0 +1,757 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Initramfs extraction and hashing. +//! +//! Extracts files from initramfs/initrd images (CPIO archives with +//! optional compression) and computes digests for policy generation. +//! Supports gzip, zstd, xz, and bzip2 compression. + +use crate::commands::error::PolicyGenerationError; +use crate::policy_tools::digest::algorithm_to_message_digest; +use openssl::hash::Hasher; +use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; + +/// Map of file paths to their digest strings (e.g., "sha256:abcd..."). +pub type DigestMap = HashMap>; + +// --- CPIO new-ASCII constants --- +const CPIO_MAGIC: &[u8] = b"070701"; +const CPIO_MAGIC_CRC: &[u8] = b"070702"; +const CPIO_HEADER_LEN: usize = 110; // 6 + 13*8 +const CPIO_FILESIZE_OFFSET: usize = 54; // 6 + 6*8 +const CPIO_NAMESIZE_OFFSET: usize = 94; // 6 + 11*8 +const CPIO_FIELD_LEN: usize = 8; +const CPIO_ALIGNMENT: usize = 4; +const CPIO_TRAILER: &[u8] = b"TRAILER!!!"; + +// --- Compression magic bytes --- +const MAGIC_GZIP: &[u8] = &[0x1f, 0x8b]; +const MAGIC_ZSTD: &[u8] = &[0x28, 0xb5, 0x2f, 0xfd]; +const MAGIC_XZ: &[u8] = &[0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]; +const MAGIC_BZIP2: &[u8] = b"BZh"; + +/// Regular file mode bit in CPIO. +const S_IFREG: u32 = 0o100000; + +/// Compression format detected from magic bytes. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Compression { + Gzip, + Zstd, + Xz, + Bzip2, + Uncompressed, +} + +/// Detect compression format from the first few bytes. +fn detect_compression(data: &[u8]) -> Compression { + if data.len() >= 6 + && (data.starts_with(CPIO_MAGIC) || data.starts_with(CPIO_MAGIC_CRC)) + { + return Compression::Uncompressed; + } + if data.len() >= 6 && data.starts_with(MAGIC_XZ) { + return Compression::Xz; + } + if data.len() >= 4 && data.starts_with(MAGIC_ZSTD) { + return Compression::Zstd; + } + if data.len() >= 3 && data.starts_with(MAGIC_BZIP2) { + return Compression::Bzip2; + } + if data.len() >= 2 && data.starts_with(MAGIC_GZIP) { + return Compression::Gzip; + } + Compression::Uncompressed +} + +/// Align `pos` up to the nearest `alignment` boundary. +fn align_up(pos: usize, alignment: usize) -> usize { + (pos + alignment - 1) & !(alignment - 1) +} + +/// Parse a hex field of `CPIO_FIELD_LEN` bytes from the CPIO header. +fn parse_hex_field(data: &[u8], offset: usize) -> Option { + if data.len() < offset + CPIO_FIELD_LEN { + return None; + } + let field = + std::str::from_utf8(&data[offset..offset + CPIO_FIELD_LEN]).ok()?; + u32::from_str_radix(field, 16).ok() +} + +/// Skip the early microcode CPIO archive (if present) and return the +/// offset where the main initramfs data begins. +fn skip_early_cpio(data: &[u8]) -> usize { + // Check if data starts with CPIO magic + if data.len() < CPIO_HEADER_LEN + || (!data.starts_with(CPIO_MAGIC) + && !data.starts_with(CPIO_MAGIC_CRC)) + { + return 0; + } + + let mut pos = 0; + + // Walk through the CPIO archive + loop { + if pos + CPIO_HEADER_LEN > data.len() { + return 0; + } + + // Verify magic + if !data[pos..].starts_with(CPIO_MAGIC) + && !data[pos..].starts_with(CPIO_MAGIC_CRC) + { + return 0; + } + + let namesize = match parse_hex_field(data, pos + CPIO_NAMESIZE_OFFSET) + { + Some(n) => n as usize, + None => return 0, + }; + + let filesize = match parse_hex_field(data, pos + CPIO_FILESIZE_OFFSET) + { + Some(n) => n as usize, + None => return 0, + }; + + // Extract filename + let name_start = pos + CPIO_HEADER_LEN; + let name_end = name_start + namesize; + if name_end > data.len() { + return 0; + } + let name = &data[name_start..name_end]; + + // Check for TRAILER!!! (end of archive) + let name_trimmed = if name.last() == Some(&0) { + &name[..name.len() - 1] + } else { + name + }; + + if name_trimmed == CPIO_TRAILER { + // Found end of archive — skip padding zeros + // to find the start of the next archive + let trailer_end = align_up(name_end, CPIO_ALIGNMENT); + let data_end = align_up(trailer_end + filesize, CPIO_ALIGNMENT); + + // Scan past zero padding + let mut next_start = data_end; + while next_start < data.len() && data[next_start] == 0 { + next_start += 1; + } + + // If we found more data, that's the main initrd + if next_start < data.len() { + return next_start; + } + + // No more data — the entire file was one CPIO + return 0; + } + + // Advance past this entry + let name_padded = align_up(name_end, CPIO_ALIGNMENT); + let data_padded = align_up(name_padded + filesize, CPIO_ALIGNMENT); + pos = data_padded; + } +} + +/// Decompress data using the detected format. +const MAX_INITRD_DECOMPRESSED_SIZE: u64 = 1_073_741_824; // 1 GB + +fn decompress( + data: &[u8], + format: Compression, +) -> Result, PolicyGenerationError> { + match format { + Compression::Gzip => { + let decoder = flate2::read::GzDecoder::new(data); + let mut limited = decoder.take(MAX_INITRD_DECOMPRESSED_SIZE); + let mut decompressed = Vec::new(); + let _ = limited.read_to_end(&mut decompressed).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Gzip decompression failed: {e}"), + } + })?; + Ok(decompressed) + } + Compression::Zstd => { + let decoder = zstd::Decoder::new(data).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Zstd decoder init failed: {e}"), + } + })?; + let mut limited = decoder.take(MAX_INITRD_DECOMPRESSED_SIZE); + let mut decompressed = Vec::new(); + let _ = limited.read_to_end(&mut decompressed).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Zstd decompression failed: {e}"), + } + })?; + Ok(decompressed) + } + Compression::Xz => { + let decoder = xz2::read::XzDecoder::new(data); + let mut limited = decoder.take(MAX_INITRD_DECOMPRESSED_SIZE); + let mut decompressed = Vec::new(); + let _ = limited.read_to_end(&mut decompressed).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("XZ decompression failed: {e}"), + } + })?; + Ok(decompressed) + } + Compression::Bzip2 => { + let decoder = bzip2::read::BzDecoder::new(data); + let mut limited = decoder.take(MAX_INITRD_DECOMPRESSED_SIZE); + let mut decompressed = Vec::new(); + let _ = limited.read_to_end(&mut decompressed).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Bzip2 decompression failed: {e}"), + } + })?; + Ok(decompressed) + } + Compression::Uncompressed => Ok(data.to_vec()), + } +} + +/// Parse a CPIO new-ASCII archive in memory and compute SHA-256 +/// digests for all regular files. +/// +/// Returns a map of file paths to digest strings. +fn extract_cpio_digests( + data: &[u8], + algorithm: &str, +) -> Result { + let mut digests = DigestMap::new(); + let mut pos = 0; + + loop { + if pos + CPIO_HEADER_LEN > data.len() { + break; + } + + // Verify magic + if !data[pos..].starts_with(CPIO_MAGIC) + && !data[pos..].starts_with(CPIO_MAGIC_CRC) + { + break; + } + + let mode = + parse_hex_field(data, pos + 6 + CPIO_FIELD_LEN).unwrap_or(0); + let filesize_raw = + parse_hex_field(data, pos + CPIO_FILESIZE_OFFSET).unwrap_or(0); + let namesize_raw = + parse_hex_field(data, pos + CPIO_NAMESIZE_OFFSET).unwrap_or(0); + let Some(filesize) = usize::try_from(filesize_raw).ok() else { + break; + }; + let Some(namesize) = usize::try_from(namesize_raw).ok() else { + break; + }; + + // Extract filename + let name_start = pos + CPIO_HEADER_LEN; + let Some(name_end) = name_start.checked_add(namesize) else { + break; + }; + if name_end > data.len() { + break; + } + let name_raw = &data[name_start..name_end]; + let name = std::str::from_utf8(name_raw) + .unwrap_or("") + .trim_end_matches('\0'); + + // Check for TRAILER + if name.as_bytes() == CPIO_TRAILER { + break; + } + + // Advance to file data (aligned) + let data_start = align_up(name_end, CPIO_ALIGNMENT); + let Some(data_end) = data_start.checked_add(filesize) else { + break; + }; + + // Process regular files only + if (mode & S_IFREG) == S_IFREG + && filesize > 0 + && data_end <= data.len() + { + let file_data = &data[data_start..data_end]; + + // Compute digest + let md = algorithm_to_message_digest(algorithm).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Unsupported algorithm: {e}"), + } + })?; + let mut hasher = Hasher::new(md).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to create hasher: {e}"), + } + })?; + hasher.update(file_data).map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to hash data: {e}"), + } + })?; + let digest_bytes = hasher.finish().map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to finalize hash: {e}"), + } + })?; + let digest_hex = hex::encode(digest_bytes); + + // Normalize filename: strip leading "./" or "/" + let normalized = name + .strip_prefix("./") + .or_else(|| name.strip_prefix('/')) + .unwrap_or(name); + + let file_path = if normalized.starts_with('/') { + normalized.to_string() + } else { + format!("/{normalized}") + }; + + digests.entry(file_path).or_default().push(digest_hex); + } + + // Advance to next entry (aligned) + pos = align_up(data_end, CPIO_ALIGNMENT); + } + + Ok(digests) +} + +/// Find initrd/initramfs files in a directory. +/// +/// Matches files whose name starts with "initr" (e.g., `initrd.img-5.15.0`, +/// `initramfs-5.15.0.img`). +fn list_initrds( + basedir: &Path, +) -> Result, PolicyGenerationError> { + let mut initrds = Vec::new(); + let entries = std::fs::read_dir(basedir).map_err(|e| { + PolicyGenerationError::Output { + path: basedir.to_path_buf(), + reason: format!("Failed to list directory: {e}"), + } + })?; + + for entry in entries { + let entry = entry.map_err(|e| PolicyGenerationError::Output { + path: basedir.to_path_buf(), + reason: format!("Failed to read directory entry: {e}"), + })?; + + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + + if name.starts_with("initr") + && entry.file_type().map(|t| t.is_file()).unwrap_or(false) + { + initrds.push(entry.path()); + } + } + + initrds.sort(); + Ok(initrds) +} + +/// Process all initramfs files in a directory and return merged digests. +/// +/// For each initrd/initramfs file found: +/// 1. Read the file into memory +/// 2. Skip early microcode CPIO archives +/// 3. Detect and decompress (gzip/zstd/xz/bzip2) +/// 4. Parse the CPIO archive and compute file digests +pub fn process_ramdisk_dir( + dir: &Path, + algorithm: &str, +) -> Result { + let initrd_files = list_initrds(dir)?; + + if initrd_files.is_empty() { + log::debug!("No initrd/initramfs files found in {}", dir.display()); + return Ok(DigestMap::new()); + } + + let mut merged_digests = DigestMap::new(); + + for initrd_path in &initrd_files { + log::debug!("Processing initrd: {}", initrd_path.display()); + + let raw_data = std::fs::read(initrd_path).map_err(|e| { + PolicyGenerationError::Output { + path: initrd_path.clone(), + reason: format!("Failed to read initrd: {e}"), + } + })?; + + // Skip early microcode CPIO + let offset = skip_early_cpio(&raw_data); + let data = &raw_data[offset..]; + + if data.is_empty() { + log::debug!("Skipping empty initrd: {}", initrd_path.display()); + continue; + } + + // Detect compression and decompress + let compression = detect_compression(data); + let cpio_data = decompress(data, compression)?; + + // Parse CPIO and extract digests + let digests = extract_cpio_digests(&cpio_data, algorithm)?; + + // Merge into results + for (path, file_digests) in digests { + merged_digests.entry(path).or_default().extend(file_digests); + } + + log::debug!( + "Extracted {} file digests from {}", + merged_digests.len(), + initrd_path.display() + ); + } + + Ok(merged_digests) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_compression_gzip() { + let data = [0x1f, 0x8b, 0x08, 0x00]; + assert_eq!(detect_compression(&data), Compression::Gzip); + } + + #[test] + fn test_detect_compression_zstd() { + let data = [0x28, 0xb5, 0x2f, 0xfd, 0x00]; + assert_eq!(detect_compression(&data), Compression::Zstd); + } + + #[test] + fn test_detect_compression_xz() { + let data = [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]; + assert_eq!(detect_compression(&data), Compression::Xz); + } + + #[test] + fn test_detect_compression_bzip2() { + let data = *b"BZh9"; + assert_eq!(detect_compression(&data), Compression::Bzip2); + } + + #[test] + fn test_detect_compression_cpio() { + let data = *b"070701"; + assert_eq!(detect_compression(&data), Compression::Uncompressed); + } + + #[test] + fn test_align_up() { + assert_eq!(align_up(0, 4), 0); + assert_eq!(align_up(1, 4), 4); + assert_eq!(align_up(3, 4), 4); + assert_eq!(align_up(4, 4), 4); + assert_eq!(align_up(5, 4), 8); + assert_eq!(align_up(110, 4), 112); + } + + #[test] + fn test_parse_hex_field() { + let data = b"00000042"; + assert_eq!(parse_hex_field(data, 0), Some(0x42)); + } + + /// Build a minimal CPIO new-ASCII archive containing + /// one regular file with the given name and content. + fn build_cpio_archive(name: &str, content: &[u8]) -> Vec { + let mut buf = Vec::new(); + + // File entry + let namesize = name.len() + 1; // include null + let filesize = content.len(); + let mode = S_IFREG | 0o644; + + // Header (110 bytes) + let header = format!( + "070701\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}", + 1, // ino + mode, // mode + 0, // uid + 0, // gid + 1, // nlink + 0, // mtime + filesize, // filesize + 0, // devmajor + 0, // devminor + 0, // rdevmajor + 0, // rdevminor + namesize, // namesize + 0, // check + ); + buf.extend_from_slice(header.as_bytes()); + + // Filename + null + buf.extend_from_slice(name.as_bytes()); + buf.push(0); + + // Pad to 4-byte boundary + while buf.len() % CPIO_ALIGNMENT != 0 { + buf.push(0); + } + + // File data + buf.extend_from_slice(content); + + // Pad to 4-byte boundary + while buf.len() % CPIO_ALIGNMENT != 0 { + buf.push(0); + } + + // TRAILER entry + let trailer_name = "TRAILER!!!"; + let trailer_namesize = trailer_name.len() + 1; + let trailer = format!( + "070701\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}\ + {:08x}", + 0, // ino + 0, // mode + 0, // uid + 0, // gid + 1, // nlink + 0, // mtime + 0, // filesize + 0, // devmajor + 0, // devminor + 0, // rdevmajor + 0, // rdevminor + trailer_namesize, // namesize + 0, // check + ); + buf.extend_from_slice(trailer.as_bytes()); + buf.extend_from_slice(trailer_name.as_bytes()); + buf.push(0); + + // Final padding + while buf.len() % CPIO_ALIGNMENT != 0 { + buf.push(0); + } + + buf + } + + #[test] + fn test_extract_cpio_digests_single_file() { + let content = b"Hello, world!"; + let archive = build_cpio_archive("usr/bin/hello", content); + + let digests = extract_cpio_digests(&archive, "sha256").unwrap(); //#[allow_ci] + + assert_eq!(digests.len(), 1); + assert!(digests.contains_key("/usr/bin/hello")); + let digest_list = &digests["/usr/bin/hello"]; + assert_eq!(digest_list.len(), 1); + // Bare hex sha256 digest = 64 chars + assert_eq!(digest_list[0].len(), 64); + assert!(digest_list[0].chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_extract_cpio_digests_empty_archive() { + // Just a TRAILER + let archive = build_cpio_archive("TRAILER!!!", &[]); + // This won't work since build_cpio_archive adds + // the file first. Build a standalone trailer. + let mut buf = Vec::new(); + let trailer_name = "TRAILER!!!"; + let trailer_namesize = trailer_name.len() + 1; + let header = format!( + "070701\ + {:08x}{:08x}{:08x}{:08x}\ + {:08x}{:08x}{:08x}{:08x}\ + {:08x}{:08x}{:08x}{:08x}\ + {:08x}", + 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, trailer_namesize, 0, + ); + buf.extend_from_slice(header.as_bytes()); + buf.extend_from_slice(trailer_name.as_bytes()); + buf.push(0); + while buf.len() % CPIO_ALIGNMENT != 0 { + buf.push(0); + } + // Ignore the archive from build_cpio_archive + let _ = archive; + + let digests = extract_cpio_digests(&buf, "sha256").unwrap(); //#[allow_ci] + assert!(digests.is_empty()); + } + + #[test] + fn test_skip_early_cpio_no_cpio() { + // Compressed data, no early CPIO + let data = [0x1f, 0x8b, 0x08, 0x00]; + assert_eq!(skip_early_cpio(&data), 0); + } + + #[test] + fn test_skip_early_cpio_single_archive() { + let archive = + build_cpio_archive("kernel/x86/microcode.bin", b"microcode"); + // Single archive — skip_early_cpio returns 0 + // because there's no second archive after it + assert_eq!(skip_early_cpio(&archive), 0); + } + + #[test] + fn test_skip_early_cpio_two_archives() { + let early = + build_cpio_archive("kernel/x86/microcode.bin", b"microcode"); + let mut data = early.clone(); + + // Add padding zeros + data.extend_from_slice(&[0u8; 12]); + + // Add a gzip-compressed main initrd + let gzip_magic = [0x1f, 0x8b, 0x08, 0x00, 0xAA, 0xBB]; + data.extend_from_slice(&gzip_magic); + + let offset = skip_early_cpio(&data); + // Should point to the gzip magic + assert!(offset > 0); + assert_eq!(data[offset], 0x1f); + assert_eq!(data[offset + 1], 0x8b); + } + + #[test] + fn test_list_initrds_with_temp_dir() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create files matching and not matching + std::fs::File::create(dir.path().join("initrd.img-5.15.0")) + .unwrap() //#[allow_ci] + .write_all(b"data") + .unwrap(); //#[allow_ci] + std::fs::File::create(dir.path().join("initramfs-5.15.0.img")) + .unwrap() //#[allow_ci] + .write_all(b"data") + .unwrap(); //#[allow_ci] + std::fs::File::create(dir.path().join("vmlinuz-5.15.0")) + .unwrap() //#[allow_ci] + .write_all(b"data") + .unwrap(); //#[allow_ci] + std::fs::File::create(dir.path().join("config-5.15.0")) + .unwrap() //#[allow_ci] + .write_all(b"data") + .unwrap(); //#[allow_ci] + + let initrds = list_initrds(dir.path()).unwrap(); //#[allow_ci] + + assert_eq!(initrds.len(), 2); + let names: Vec = initrds + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().to_string()) //#[allow_ci] + .collect(); + assert!(names.contains(&"initrd.img-5.15.0".to_string())); + assert!(names.contains(&"initramfs-5.15.0.img".to_string())); + } + + #[test] + fn test_process_ramdisk_dir_with_cpio() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a small uncompressed CPIO initrd + let archive = build_cpio_archive("etc/test.conf", b"key=val"); + let initrd_path = dir.path().join("initrd.img-test"); + std::fs::File::create(&initrd_path) + .unwrap() //#[allow_ci] + .write_all(&archive) + .unwrap(); //#[allow_ci] + + let digests = process_ramdisk_dir(dir.path(), "sha256").unwrap(); //#[allow_ci] + + assert_eq!(digests.len(), 1); + assert!(digests.contains_key("/etc/test.conf")); + } + + #[test] + fn test_process_ramdisk_dir_with_gzip_cpio() { + use flate2::write::GzEncoder; + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a CPIO archive + let archive = + build_cpio_archive("usr/lib/test.so", b"ELF binary data here"); + + // Gzip compress it + let mut encoder = + GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(&archive).unwrap(); //#[allow_ci] + let compressed = encoder.finish().unwrap(); //#[allow_ci] + + let initrd_path = dir.path().join("initramfs-test.img"); + std::fs::write(&initrd_path, &compressed).unwrap(); //#[allow_ci] + + let digests = process_ramdisk_dir(dir.path(), "sha256").unwrap(); //#[allow_ci] + + assert_eq!(digests.len(), 1); + assert!(digests.contains_key("/usr/lib/test.so")); + } +} diff --git a/keylimectl/src/policy_tools/measured_boot_gen.rs b/keylimectl/src/policy_tools/measured_boot_gen.rs new file mode 100644 index 000000000..d416da623 --- /dev/null +++ b/keylimectl/src/policy_tools/measured_boot_gen.rs @@ -0,0 +1,479 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Measured boot policy generation from UEFI event logs. +//! +//! Uses the shared `keylime::uefi::UefiLogHandler` to parse binary +//! event logs and extract Secure Boot variables, firmware measurements, +//! and kernel boot chain entries into a measured boot policy. + +use crate::commands::error::PolicyGenerationError; +use crate::policy_tools::measured_boot_policy::{ + KernelEntry, MeasuredBootPolicy, ScrtmBiosEntry, SecureBootSignature, +}; +use crate::policy_tools::uefi_event_data; +use keylime::uefi::UefiLogHandler; +use std::collections::HashMap; +use std::path::Path; + +/// Select the strongest available digest from an event's digest map. +/// Prefers SHA-512 > SHA-384 > SHA-256 > SHA-1, falling back to the +/// first available if none of the preferred algorithms are present. +fn select_strongest_digest( + digests: &HashMap>, +) -> Option { + const PREFERRED_ALGS: &[&str] = &["sha512", "sha384", "sha256"]; + for alg in PREFERRED_ALGS { + if let Some(d) = digests.get(*alg) { + return Some(hex::encode(d)); + } + } + digests.values().next().map(hex::encode) +} + +/// Generate a measured boot policy from a UEFI event log file. +pub fn generate_from_eventlog( + path: &Path, + include_secureboot: bool, +) -> Result { + let path_str = path.to_str().ok_or_else(|| { + PolicyGenerationError::EventLogParse { + path: path.to_path_buf(), + reason: "Invalid path encoding".to_string(), + } + })?; + + let handler = UefiLogHandler::new(path_str).map_err(|e| { + PolicyGenerationError::EventLogParse { + path: path.to_path_buf(), + reason: format!("{e}"), + } + })?; + + let mut policy = MeasuredBootPolicy::new(include_secureboot); + + // Extract S-CRTM and BIOS measurements (PCR 0) + extract_scrtm_bios(&handler, &mut policy); + + // Extract Secure Boot variables (PCR 7) if requested + if include_secureboot { + extract_secureboot_events(&handler, &mut policy); + } + + // Extract kernel boot chain (PCR 4, 8, 9) + extract_kernel_entries(&handler, &mut policy, include_secureboot); + + // Extract MOK (Machine Owner Key) digests + extract_mok(&handler, &mut policy); + + // Extract vendor_db from EV_EFI_VARIABLE_AUTHORITY events + extract_vendor_db(&handler, &mut policy); + + Ok(policy) +} + +/// Extract S-CRTM and platform firmware measurements. +fn extract_scrtm_bios( + handler: &UefiLogHandler, + policy: &mut MeasuredBootPolicy, +) { + let pcr0_events = handler.get_events_for_pcr_index(0); + + let mut scrtm: HashMap = HashMap::new(); + let mut platform_firmware: Vec> = Vec::new(); + + for event in &pcr0_events { + match event.event_type.as_str() { + "EV_S_CRTM_VERSION" => { + for (alg, digest) in &event.digests { + let _ = scrtm.insert( + alg.clone(), + format!("0x{}", hex::encode(digest)), + ); + } + } + "EV_S_CRTM_CONTENTS" + | "EV_EFI_PLATFORM_FIRMWARE_BLOB" + | "EV_EFI_PLATFORM_FIRMWARE_BLOB2" + | "EV_POST_CODE" => { + let mut fw_entry: HashMap = HashMap::new(); + for (alg, digest) in &event.digests { + let _ = fw_entry.insert( + alg.clone(), + format!("0x{}", hex::encode(digest)), + ); + } + if !fw_entry.is_empty() { + platform_firmware.push(fw_entry); + } + } + _ => {} + } + } + + if !scrtm.is_empty() || !platform_firmware.is_empty() { + policy.scrtm_and_bios.push(ScrtmBiosEntry { + scrtm, + platform_firmware, + }); + } +} + +/// Extract Secure Boot variable events from PCR 7. +fn extract_secureboot_events( + handler: &UefiLogHandler, + policy: &mut MeasuredBootPolicy, +) { + let pcr7_events = handler.get_events_for_pcr_index(7); + + for event in &pcr7_events { + if event.event_type != "EV_EFI_VARIABLE_DRIVER_CONFIG" + && event.event_type != "EV_EFI_VARIABLE_BOOT" + { + continue; + } + + let var_data = + uefi_event_data::parse_efi_variable_data(&event.event_data); + + let var_name = var_data.as_ref().map(|v| v.variable_name.as_str()); + + // Get the digest for the first available algorithm + let digest = + select_strongest_digest(&event.digests).unwrap_or_default(); + + let sig = SecureBootSignature { + signature_owner: "uefi-var".to_string(), + signature_data: format!("0x{digest}"), + }; + + match var_name { + Some("PK") => policy.pk.push(sig), + Some("KEK") => policy.kek.push(sig), + Some("db") => policy.db.push(sig), + Some("dbx") => policy.dbx.push(sig), + _ => { + log::debug!("Skipping EFI variable event: {:?}", var_name); + } + } + } +} + +/// Extract kernel boot chain entries from PCRs 4, 8, and 9. +/// +/// Following the Python `create_mb_policy.get_kernel()` logic: +/// - PCR 4 `EV_EFI_BOOT_SERVICES_APPLICATION` events: shim (0), grub (1), kernel (2) +/// - PCR 8 `EV_IPL` events: kernel command line +/// - PCR 9 `EV_IPL` events: initrd/initramfs and vmlinuz digests +fn extract_kernel_entries( + handler: &UefiLogHandler, + policy: &mut MeasuredBootPolicy, + has_secureboot: bool, +) { + let mut entry = KernelEntry { + shim_authcode_sha256: None, + grub_authcode_sha256: None, + kernel_authcode_sha256: None, + initrd_plain_sha256: None, + vmlinuz_plain_sha256: None, + kernel_cmdline: None, + }; + + // --- PCR 4: Boot services applications (shim, grub, kernel) --- + let pcr4_boot_apps: Vec<_> = handler + .get_events_for_pcr_index(4) + .into_iter() + .filter(|e| e.event_type == "EV_EFI_BOOT_SERVICES_APPLICATION") + .collect(); + + // Extract sha256 digests in order: [0]=shim, [1]=grub, [2]=kernel + for (idx, event) in pcr4_boot_apps.iter().enumerate() { + let sha256_digest = event + .digests + .get("sha256") + .map(|d| format!("0x{}", hex::encode(d))); + + match idx { + 0 => { + entry.shim_authcode_sha256 = sha256_digest; + } + 1 => { + entry.grub_authcode_sha256 = sha256_digest; + } + 2 if has_secureboot => { + entry.kernel_authcode_sha256 = sha256_digest; + } + _ => break, + } + } + + // --- PCR 8: Kernel command line --- + let pcr8_ipl: Vec<_> = handler + .get_events_for_pcr_index(8) + .into_iter() + .filter(|e| e.event_type == "EV_IPL") + .collect(); + + for event in &pcr8_ipl { + if let Some(s) = uefi_event_data::parse_ipl_string(&event.event_data) + { + // GRUB prefixes the command line with "kernel_cmdline: " + // or the string itself IS the command line + if s.contains("kernel_cmdline") { + // Extract the actual command line after the prefix + let cmdline = s + .strip_prefix("kernel_cmdline: ") + .or_else(|| s.strip_prefix("kernel_cmdline:")) + .unwrap_or(&s); + entry.kernel_cmdline = Some(cmdline.to_string()); + break; + } + } + } + + // If no "kernel_cmdline" prefix found, try the last PCR 8 EV_IPL event + if entry.kernel_cmdline.is_none() { + if let Some(event) = pcr8_ipl.last() { + if let Some(s) = + uefi_event_data::parse_ipl_string(&event.event_data) + { + if !s.is_empty() { + entry.kernel_cmdline = Some(s); + } + } + } + } + + // --- PCR 9: initrd/initramfs and vmlinuz --- + let pcr9_ipl: Vec<_> = handler + .get_events_for_pcr_index(9) + .into_iter() + .filter(|e| e.event_type == "EV_IPL") + .collect(); + + for event in &pcr9_ipl { + let event_str = uefi_event_data::parse_ipl_string(&event.event_data); + + let sha256_digest = event + .digests + .get("sha256") + .map(|d| format!("0x{}", hex::encode(d))); + + if let Some(ref s) = event_str { + let s_lower = s.to_lowercase(); + if s_lower.contains("initrd") || s_lower.contains("initramfs") { + if entry.initrd_plain_sha256.is_none() { + entry.initrd_plain_sha256 = sha256_digest; + } + } else if !has_secureboot + && s_lower.contains("vmlinuz") + && entry.vmlinuz_plain_sha256.is_none() + { + entry.vmlinuz_plain_sha256 = sha256_digest; + } + } + } + + // Only add the entry if we extracted something meaningful + if entry.shim_authcode_sha256.is_some() + || entry.grub_authcode_sha256.is_some() + || entry.kernel_authcode_sha256.is_some() + || entry.initrd_plain_sha256.is_some() + || entry.vmlinuz_plain_sha256.is_some() + || entry.kernel_cmdline.is_some() + { + policy.kernels.push(entry); + } +} + +/// Extract MOK (Machine Owner Key) digests from EV_IPL events. +/// +/// Shim measures MokList and MokListX as EV_IPL events. +/// The event_data contains the string "MokList" or "MokListX". +fn extract_mok(handler: &UefiLogHandler, policy: &mut MeasuredBootPolicy) { + let ipl_events = handler.get_events_by_type("EV_IPL"); + + for event in &ipl_events { + let event_str = uefi_event_data::parse_ipl_string(&event.event_data); + + if let Some(ref s) = event_str { + let sha256_digest = event + .digests + .get("sha256") + .map(|d| format!("0x{}", hex::encode(d))); + + if s == "MokList" || s == "MokListRT" { + if let Some(digest) = sha256_digest { + let mut entry = serde_json::Map::new(); + let _ = entry.insert( + "sha256".to_string(), + serde_json::Value::String(digest), + ); + policy.mokdig.push(serde_json::Value::Object(entry)); + } + } else if s == "MokListX" || s == "MokListXRT" { + if let Some(digest) = sha256_digest { + let mut entry = serde_json::Map::new(); + let _ = entry.insert( + "sha256".to_string(), + serde_json::Value::String(digest), + ); + policy.mokxdig.push(serde_json::Value::Object(entry)); + } + } + } + } +} + +/// Extract vendor_db signatures from `EV_EFI_VARIABLE_AUTHORITY` events. +/// +/// These events on PCR 7 contain the variable name and signature data +/// used to verify boot components against vendor-provided databases. +fn extract_vendor_db( + handler: &UefiLogHandler, + policy: &mut MeasuredBootPolicy, +) { + let authority_events = + handler.get_events_by_type("EV_EFI_VARIABLE_AUTHORITY"); + + for event in &authority_events { + if let Some(var_data) = + uefi_event_data::parse_efi_variable_data(&event.event_data) + { + if var_data.variable_name == "vendor_db" { + let digest = select_strongest_digest(&event.digests) + .unwrap_or_default(); + + policy.vendor_db.push(SecureBootSignature { + signature_owner: "vendor".to_string(), + signature_data: format!("0x{digest}"), + }); + } + } + } +} + +/// Summary statistics for a generated measured boot policy. +#[cfg_attr(not(feature = "wizard"), allow(dead_code))] +pub struct MeasuredBootStats { + /// Total number of events processed. + pub total_events: usize, + /// Number of S-CRTM/BIOS entries. + pub scrtm_entries: usize, + /// Number of Secure Boot variable entries. + pub secureboot_entries: usize, + /// Active hash algorithms. + pub algorithms: Vec, +} + +/// Get statistics from the UEFI event log. +#[cfg_attr(not(feature = "wizard"), allow(dead_code))] +pub fn get_eventlog_stats( + path: &Path, +) -> Result { + let path_str = path.to_str().ok_or_else(|| { + PolicyGenerationError::EventLogParse { + path: path.to_path_buf(), + reason: "Invalid path encoding".to_string(), + } + })?; + + let handler = UefiLogHandler::new(path_str).map_err(|e| { + PolicyGenerationError::EventLogParse { + path: path.to_path_buf(), + reason: format!("{e}"), + } + })?; + + Ok(MeasuredBootStats { + total_events: handler.get_entry_count(), + scrtm_entries: handler.get_events_for_pcr_index(0).len(), + secureboot_entries: handler.get_events_for_pcr_index(7).len(), + algorithms: handler.get_active_algorithms().clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy_tools::uefi_event_data::{ + parse_efi_variable_data, parse_ipl_string, + }; + + /// Helper: build a UEFI_VARIABLE_DATA byte buffer for testing. + fn build_variable_data(name: &str, data: &[u8]) -> Vec { + let mut buf = Vec::new(); + // GUID (16 bytes) + buf.extend_from_slice(&[ + 0x61, 0xdf, 0xe4, 0x8b, 0xca, 0x93, 0xd2, 0x11, 0xaa, 0x0d, 0x00, + 0xe0, 0x98, 0x03, 0x2b, 0x8c, + ]); + // UnicodeNameLength + buf.extend_from_slice(&(name.len() as u64).to_le_bytes()); + // VariableDataLength + buf.extend_from_slice(&(data.len() as u64).to_le_bytes()); + // UnicodeName in UTF-16LE + for c in name.chars() { + buf.push(c as u8); + buf.push(0); + } + // VariableData + buf.extend_from_slice(data); + buf + } + + #[test] + fn test_parse_efi_variable_name_pk() { + let data = build_variable_data("PK", &[]); + let parsed = parse_efi_variable_data(&data).unwrap(); //#[allow_ci] + assert_eq!(parsed.variable_name, "PK"); + } + + #[test] + fn test_parse_efi_variable_name_secureboot() { + let data = build_variable_data("SecureBoot", &[0x01]); + let parsed = parse_efi_variable_data(&data).unwrap(); //#[allow_ci] + assert_eq!(parsed.variable_name, "SecureBoot"); + assert_eq!(parsed.variable_data, vec![0x01]); + } + + #[test] + fn test_parse_efi_variable_too_short() { + let data = vec![0u8; 16]; + assert!(parse_efi_variable_data(&data).is_none()); + } + + #[test] + fn test_parse_efi_variable_empty_name() { + let mut data = Vec::new(); + data.extend_from_slice(&[0u8; 16]); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + assert!(parse_efi_variable_data(&data).is_none()); + } + + #[test] + fn test_parse_ipl_string_cmdline() { + let data = b"kernel_cmdline: root=/dev/sda1 ro quiet"; + let result = parse_ipl_string(data); + assert_eq!( + result.as_deref(), + Some("kernel_cmdline: root=/dev/sda1 ro quiet") + ); + } + + #[test] + fn test_parse_ipl_string_moklist() { + let mut data = b"MokList".to_vec(); + data.push(0); + let result = parse_ipl_string(&data); + assert_eq!(result, Some("MokList".to_string())); + } + + #[test] + fn test_nonexistent_eventlog() { + let result = + generate_from_eventlog(Path::new("/nonexistent/event.log"), true); + assert!(result.is_err()); + } +} diff --git a/keylimectl/src/policy_tools/measured_boot_policy.rs b/keylimectl/src/policy_tools/measured_boot_policy.rs new file mode 100644 index 000000000..29fba6d88 --- /dev/null +++ b/keylimectl/src/policy_tools/measured_boot_policy.rs @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Measured boot policy reference state types. +//! +//! These types represent the measured boot reference state structure +//! generated from UEFI event logs, matching the Python +//! `create_mb_policy.py` output format. + +#![allow(dead_code)] // Types used in later implementation steps + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A measured boot policy reference state. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MeasuredBootPolicy { + /// Whether Secure Boot was detected as enabled. + pub has_secureboot: bool, + + /// S-CRTM and BIOS firmware measurements. + #[serde(default)] + pub scrtm_and_bios: Vec, + + /// Platform Key (PK) signatures. + #[serde(default, rename = "pk")] + pub pk: Vec, + + /// Key Exchange Key (KEK) signatures. + #[serde(default, rename = "kek")] + pub kek: Vec, + + /// Authorized signature database (db). + #[serde(default, rename = "db")] + pub db: Vec, + + /// Forbidden signature database (dbx). + #[serde(default, rename = "dbx")] + pub dbx: Vec, + + /// Vendor-provided authorized signature database. + #[serde(default)] + pub vendor_db: Vec, + + /// Kernel boot chain entries (shim, grub, kernel, initrd). + #[serde(default)] + pub kernels: Vec, + + /// Machine Owner Key digests. + #[serde(default)] + pub mokdig: Vec, + + /// Machine Owner Key exclusion digests. + #[serde(default)] + pub mokxdig: Vec, +} + +/// S-CRTM and platform firmware measurement entry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ScrtmBiosEntry { + /// S-CRTM version measurement. + #[serde(default)] + pub scrtm: HashMap, + + /// Platform firmware blob measurements. + #[serde(default)] + pub platform_firmware: Vec>, +} + +/// UEFI Secure Boot signature entry (PK, KEK, db, dbx). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "PascalCase")] +pub struct SecureBootSignature { + /// Signature owner GUID. + pub signature_owner: String, + + /// Hex-encoded signature data. + pub signature_data: String, +} + +/// Kernel boot chain entry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct KernelEntry { + /// SHIM bootloader authenticode SHA-256 digest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shim_authcode_sha256: Option, + + /// GRUB bootloader authenticode SHA-256 digest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grub_authcode_sha256: Option, + + /// Kernel authenticode SHA-256 digest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kernel_authcode_sha256: Option, + + /// Initrd plain SHA-256 digest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initrd_plain_sha256: Option, + + /// Kernel vmlinuz plain SHA-256 digest (used when Secure Boot is disabled). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vmlinuz_plain_sha256: Option, + + /// Kernel command line. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kernel_cmdline: Option, +} + +impl MeasuredBootPolicy { + /// Create a new empty measured boot policy. + pub fn new(has_secureboot: bool) -> Self { + Self { + has_secureboot, + scrtm_and_bios: Vec::new(), + pk: Vec::new(), + kek: Vec::new(), + db: Vec::new(), + dbx: Vec::new(), + vendor_db: Vec::new(), + kernels: Vec::new(), + mokdig: Vec::new(), + mokxdig: Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_new_policy() { + let policy = MeasuredBootPolicy::new(true); + assert!(policy.has_secureboot); + assert!(policy.kernels.is_empty()); + assert!(policy.pk.is_empty()); + } + + #[test] + fn test_serialization_roundtrip() { + let mut policy = MeasuredBootPolicy::new(true); + policy.pk.push(SecureBootSignature { + signature_owner: "owner-guid".to_string(), + signature_data: "0xaabbccdd".to_string(), + }); + policy.kernels.push(KernelEntry { + shim_authcode_sha256: Some("0xabcdef".to_string()), + grub_authcode_sha256: None, + kernel_authcode_sha256: Some("0x123456".to_string()), + initrd_plain_sha256: None, + vmlinuz_plain_sha256: None, + kernel_cmdline: Some("root=/dev/sda1".to_string()), + }); + + let json_str = serde_json::to_string(&policy).unwrap(); //#[allow_ci] + let deserialized: MeasuredBootPolicy = + serde_json::from_str(&json_str).unwrap(); //#[allow_ci] + + assert_eq!(policy, deserialized); + } + + #[test] + fn test_deserialize_reference_format() { + let reference = json!({ + "has_secureboot": true, + "scrtm_and_bios": [{ + "scrtm": {"sha256": "0xaabb"}, + "platform_firmware": [{"sha256": "0xccdd"}] + }], + "pk": [{"SignatureOwner": "guid1", "SignatureData": "0x1234"}], + "kek": [], + "db": [{"SignatureOwner": "guid2", "SignatureData": "0x5678"}], + "dbx": [], + "vendor_db": [], + "kernels": [{ + "shim_authcode_sha256": "0xshim", + "grub_authcode_sha256": "0xgrub", + "kernel_authcode_sha256": "0xkernel", + "initrd_plain_sha256": "0xinitrd", + "vmlinuz_plain_sha256": "0xvmlinuz", + "kernel_cmdline": "root=/dev/sda1 quiet" + }], + "mokdig": [], + "mokxdig": [] + }); + + let policy: MeasuredBootPolicy = + serde_json::from_value(reference).unwrap(); //#[allow_ci] + + assert!(policy.has_secureboot); + assert_eq!(policy.pk.len(), 1); + assert_eq!(policy.pk[0].signature_owner, "guid1"); + assert_eq!(policy.kernels.len(), 1); + assert_eq!( + policy.kernels[0].kernel_cmdline.as_deref(), + Some("root=/dev/sda1 quiet") + ); + } +} diff --git a/keylimectl/src/policy_tools/merge.rs b/keylimectl/src/policy_tools/merge.rs new file mode 100644 index 000000000..50b7e4732 --- /dev/null +++ b/keylimectl/src/policy_tools/merge.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Policy merging utilities. +//! +//! Merges two runtime policies by taking the union of their digests, +//! excludes, keyrings, and ima-buf entries. + +use crate::policy_tools::runtime_policy::RuntimePolicy; + +/// Merge two runtime policies. +/// +/// The resulting policy contains the union of all digests, excludes, +/// keyrings, and ima-buf entries from both policies. The metadata +/// from the `base` policy is preserved. +pub fn merge_policies( + base: &RuntimePolicy, + other: &RuntimePolicy, +) -> RuntimePolicy { + let mut merged = base.clone(); + + // Merge digests + for (path, other_digests) in &other.digests { + for digest in other_digests { + merged.add_digest(path.clone(), digest.clone()); + } + } + + // Merge excludes + for pattern in &other.excludes { + merged.add_exclude(pattern.clone()); + } + + // Merge keyrings + for (keyring, other_digests) in &other.keyrings { + for digest in other_digests { + merged.add_keyring(keyring.clone(), digest.clone()); + } + } + + // Merge ima-buf + for (name, other_digests) in &other.ima_buf { + for digest in other_digests { + merged.add_ima_buf(name.clone(), digest.clone()); + } + } + + // Merge ignored keyrings + for keyring in &other.ima.ignored_keyrings { + merged.add_ignored_keyring(keyring.clone()); + } + + merged +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_merge_empty_policies() { + let base = RuntimePolicy::new(); + let other = RuntimePolicy::new(); + let merged = merge_policies(&base, &other); + + assert!(merged.digests.is_empty()); + assert!(merged.excludes.is_empty()); + assert!(merged.keyrings.is_empty()); + assert!(merged.ima_buf.is_empty()); + } + + #[test] + fn test_merge_non_overlapping() { + let mut base = RuntimePolicy::new(); + base.add_digest( + "/usr/bin/bash".to_string(), + "sha256:aaa".to_string(), + ); + + let mut other = RuntimePolicy::new(); + other.add_digest("/usr/bin/ls".to_string(), "sha256:bbb".to_string()); + + let merged = merge_policies(&base, &other); + + assert_eq!(merged.digest_count(), 2); + assert_eq!(merged.digests["/usr/bin/bash"], vec!["sha256:aaa"]); + assert_eq!(merged.digests["/usr/bin/ls"], vec!["sha256:bbb"]); + } + + #[test] + fn test_merge_overlapping_digests() { + let mut base = RuntimePolicy::new(); + base.add_digest( + "/usr/bin/bash".to_string(), + "sha256:aaa".to_string(), + ); + + let mut other = RuntimePolicy::new(); + other.add_digest( + "/usr/bin/bash".to_string(), + "sha256:aaa".to_string(), // duplicate + ); + other.add_digest( + "/usr/bin/bash".to_string(), + "sha256:bbb".to_string(), // new + ); + + let merged = merge_policies(&base, &other); + + assert_eq!(merged.digest_count(), 1); + // Should have both digests, no duplicates + assert_eq!(merged.digests["/usr/bin/bash"].len(), 2); + } + + #[test] + fn test_merge_excludes() { + let mut base = RuntimePolicy::new(); + base.add_exclude("/tmp/*".to_string()); + + let mut other = RuntimePolicy::new(); + other.add_exclude("/tmp/*".to_string()); // duplicate + other.add_exclude("/proc/*".to_string()); + + let merged = merge_policies(&base, &other); + + assert_eq!(merged.exclude_count(), 2); + } + + #[test] + fn test_merge_keyrings_and_ima_buf() { + let mut base = RuntimePolicy::new(); + base.add_keyring("_ima".to_string(), "sha256:key1".to_string()); + + let mut other = RuntimePolicy::new(); + other.add_keyring("_ima".to_string(), "sha256:key2".to_string()); + other.add_ima_buf("dm_table".to_string(), "sha256:buf1".to_string()); + + let merged = merge_policies(&base, &other); + + assert_eq!(merged.keyrings.len(), 1); + assert_eq!(merged.keyrings["_ima"].len(), 2); + assert_eq!(merged.ima_buf.len(), 1); + } + + #[test] + fn test_merge_preserves_base_metadata() { + let base = RuntimePolicy::new(); + let other = RuntimePolicy::new(); + let merged = merge_policies(&base, &other); + + // Metadata should come from base + assert_eq!(merged.meta.version, base.meta.version); + } +} diff --git a/keylimectl/src/policy_tools/mod.rs b/keylimectl/src/policy_tools/mod.rs new file mode 100644 index 000000000..040106c13 --- /dev/null +++ b/keylimectl/src/policy_tools/mod.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Policy tools library for local policy operations. +//! +//! This module provides the core logic for policy generation, signing, +//! validation, and conversion. It is used by the CLI command handlers +//! in `commands::policy` and `commands::verify` but contains no CLI +//! concerns itself. + +use std::collections::HashMap; + +/// Map from file path (or entry name) to list of digest strings. +pub type DigestMap = HashMap>; + +/// Merge `src` into `dst`, appending new digests without duplicates. +#[cfg_attr(not(feature = "rpm-repo"), allow(dead_code))] +pub fn merge_digest_maps(dst: &mut DigestMap, src: &DigestMap) { + for (path, digests) in src { + let entry = dst.entry(path.clone()).or_default(); + for digest in digests { + if !entry.contains(digest) { + entry.push(digest.clone()); + } + } + } +} + +pub mod conversion; +pub mod digest; +pub mod dsse; +pub mod filesystem; +#[cfg(feature = "rpm-repo")] +pub mod gpg_verify; +pub mod ima_parser; +pub mod initrd; +pub mod measured_boot_gen; +pub mod measured_boot_policy; +pub mod merge; +pub mod privilege; +#[cfg(feature = "rpm-repo")] +pub mod rpm_repo; +pub mod runtime_policy; +pub mod tpm_policy; +pub mod tpm_policy_gen; +pub mod uefi_event_data; +pub mod validation; diff --git a/keylimectl/src/policy_tools/privilege.rs b/keylimectl/src/policy_tools/privilege.rs new file mode 100644 index 000000000..87399688a --- /dev/null +++ b/keylimectl/src/policy_tools/privilege.rs @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Privilege detection utilities. +//! +//! Provides helpers for detecting permission errors and suggesting +//! that the user retry the command with `sudo`. + +use crate::commands::error::PolicyGenerationError; +use std::path::Path; + +/// Check if an I/O error is a permission error (`EACCES` or `EPERM`). +pub fn is_permission_error(err: &std::io::Error) -> bool { + matches!(err.kind(), std::io::ErrorKind::PermissionDenied) +} + +/// Format a suggestion to retry a command with `sudo`. +/// +/// Returns a string like: +/// `"Insufficient privileges. Try: sudo keylimectl "` +pub fn suggest_sudo(operation: &str) -> String { + format!("Insufficient privileges. Try: sudo keylimectl {operation}") +} + +/// Check that `path` is readable, returning a +/// [`PolicyGenerationError::PrivilegeRequired`] on permission errors. +/// +/// Other I/O errors (e.g. file not found) are returned as +/// [`PolicyGenerationError::Output`]. +pub fn check_file_readable( + path: &Path, + operation: &str, +) -> Result<(), PolicyGenerationError> { + match std::fs::metadata(path) { + Ok(_) => { + // metadata() succeeded, but we may still fail to read. + // Try opening the file to confirm read access. + match std::fs::File::open(path) { + Ok(_) => Ok(()), + Err(e) if is_permission_error(&e) => { + Err(PolicyGenerationError::PrivilegeRequired { + operation: operation.to_string(), + path: path.to_path_buf(), + hint: suggest_sudo(operation), + }) + } + Err(e) => Err(PolicyGenerationError::Output { + path: path.to_path_buf(), + reason: format!("Failed to read file: {e}"), + }), + } + } + Err(e) if is_permission_error(&e) => { + Err(PolicyGenerationError::PrivilegeRequired { + operation: operation.to_string(), + path: path.to_path_buf(), + hint: suggest_sudo(operation), + }) + } + Err(e) => Err(PolicyGenerationError::Output { + path: path.to_path_buf(), + reason: format!("Failed to access file: {e}"), + }), + } +} + +/// Write sensitive data to a file with restricted permissions (0o600). +/// +/// On Unix, the file is created atomically with `0o600` mode using +/// [`OpenOptions::mode`], preventing a TOCTOU race where the file would be +/// world-readable between creation and a subsequent `chmod` call. +/// +/// On non-Unix platforms, falls back to a standard write. +pub fn write_sensitive_file(path: &Path, data: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .mode(0o600) + .create(true) + .write(true) + .truncate(true) + .open(path)?; + file.write_all(data)?; + Ok(()) + } + #[cfg(not(unix))] + { + std::fs::write(path, data) + } +} + +/// Check that `path` (a directory) is readable and listable. +/// +/// Returns [`PolicyGenerationError::PrivilegeRequired`] on permission errors. +pub fn check_dir_readable( + path: &Path, + operation: &str, +) -> Result<(), PolicyGenerationError> { + match std::fs::read_dir(path) { + Ok(_) => Ok(()), + Err(e) if is_permission_error(&e) => { + Err(PolicyGenerationError::PrivilegeRequired { + operation: operation.to_string(), + path: path.to_path_buf(), + hint: suggest_sudo(operation), + }) + } + Err(e) => Err(PolicyGenerationError::Output { + path: path.to_path_buf(), + reason: format!("Failed to access directory: {e}"), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_permission_error_true() { + let err = std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "access denied", + ); + assert!(is_permission_error(&err)); + } + + #[test] + fn test_is_permission_error_false_not_found() { + let err = std::io::Error::new( + std::io::ErrorKind::NotFound, + "file not found", + ); + assert!(!is_permission_error(&err)); + } + + #[test] + fn test_is_permission_error_false_other() { + let err = std::io::Error::other("something else"); + assert!(!is_permission_error(&err)); + } + + #[test] + fn test_suggest_sudo_format() { + let msg = suggest_sudo("policy generate runtime --ramdisk-dir /boot"); + assert!(msg.contains("sudo keylimectl")); + assert!(msg.contains("--ramdisk-dir /boot")); + assert!(msg.starts_with("Insufficient privileges")); + } + + #[test] + fn test_check_file_readable_not_found() { + let result = check_file_readable( + Path::new("/nonexistent/path/12345"), + "test operation", + ); + assert!(result.is_err()); + let err = result.unwrap_err(); //#[allow_ci] + // Should be Output (not PrivilegeRequired) for NotFound + match err { + PolicyGenerationError::Output { path, reason } => { + assert_eq!( + path, + std::path::PathBuf::from("/nonexistent/path/12345") + ); + assert!(reason.contains("Failed to access")); + } + PolicyGenerationError::PrivilegeRequired { .. } => { + // On some systems / might return PermissionDenied + // before NotFound -- that's also acceptable + } + other => { + panic!("Expected Output or PrivilegeRequired, got: {other}") //#[allow_ci] + } + } + } + + #[test] + fn test_write_sensitive_file_content() { + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + let path = tmp.path(); + let data = b"sensitive content"; + + write_sensitive_file(path, data).unwrap(); //#[allow_ci] + + let read_back = std::fs::read(path).unwrap(); //#[allow_ci] + assert_eq!(read_back, data); + } + + #[cfg(unix)] + #[test] + fn test_write_sensitive_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::NamedTempFile::new().unwrap(); //#[allow_ci] + let path = tmp.path(); + + write_sensitive_file(path, b"secret").unwrap(); //#[allow_ci] + + let metadata = std::fs::metadata(path).unwrap(); //#[allow_ci] + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "expected 0o600, got {mode:#o}"); + } + + #[test] + fn test_check_dir_readable_not_found() { + let result = check_dir_readable( + Path::new("/nonexistent/dir/12345"), + "test operation", + ); + assert!(result.is_err()); + } +} diff --git a/keylimectl/src/policy_tools/rpm_repo.rs b/keylimectl/src/policy_tools/rpm_repo.rs new file mode 100644 index 000000000..6ac833dae --- /dev/null +++ b/keylimectl/src/policy_tools/rpm_repo.rs @@ -0,0 +1,902 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! RPM repository analysis for policy generation. +//! +//! Supports both local and remote RPM repositories. +//! Local repos are scanned for RPM files and their headers are parsed +//! to extract file digests. Remote repos use `repomd.xml` metadata, +//! with `filelists-ext.xml` as a fast path when available. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use crate::commands::error::PolicyGenerationError; +use crate::policy_tools::{merge_digest_maps, DigestMap}; + +/// Check if a hex digest string is all zeros (empty/unset digest). +fn is_empty_digest(hex: &str) -> bool { + !hex.is_empty() && hex.chars().all(|c| c == '0') +} + +/// Validate that an href from repository XML is a safe relative path. +/// +/// Rejects absolute URLs, path traversal, query strings, fragments, +/// and null bytes to prevent SSRF via injected hrefs. +fn validate_relative_href(href: &str) -> Result<(), PolicyGenerationError> { + if href.contains("://") { + return Err(PolicyGenerationError::RpmParse { + path: PathBuf::from(""), + reason: format!( + "Absolute URL in repository href is not allowed: {href}" + ), + }); + } + if href.contains("..") { + return Err(PolicyGenerationError::RpmParse { + path: PathBuf::from(""), + reason: format!( + "Path traversal in repository href is not allowed: {href}" + ), + }); + } + if href.contains('?') || href.contains('#') || href.contains('\0') { + return Err(PolicyGenerationError::RpmParse { + path: PathBuf::from(""), + reason: format!( + "Query string, fragment, or null byte in repository href is not allowed: {href}" + ), + }); + } + Ok(()) +} + +/// Analyze a single RPM package file, extracting file digests +/// from the header. +/// +/// Returns a map of file paths to their digests in +/// `"algorithm:hex"` format. Filters out entries with +/// empty/zero digests. +pub fn analyze_rpm_pkg( + path: &Path, +) -> Result { + let metadata = rpm::PackageMetadata::open(path).map_err(|e| { + PolicyGenerationError::RpmParse { + path: path.to_path_buf(), + reason: format!("Failed to parse RPM header: {e}"), + } + })?; + + extract_digests_from_metadata(&metadata, path) +} + +/// Extract file digests from parsed RPM package metadata. +fn extract_digests_from_metadata( + metadata: &rpm::PackageMetadata, + source_path: &Path, +) -> Result { + let file_entries = metadata.get_file_entries().map_err(|e| { + PolicyGenerationError::RpmParse { + path: source_path.to_path_buf(), + reason: format!("Failed to get file entries: {e}"), + } + })?; + + let mut digests = DigestMap::new(); + + for entry in &file_entries { + if let Some(ref file_digest) = entry.digest { + let hex = file_digest.as_hex(); + if !hex.is_empty() && !is_empty_digest(hex) { + digests + .entry(entry.path.to_string_lossy().to_string()) + .or_default() + .push(hex.to_string()); + } + } + } + + Ok(digests) +} + +/// Analyze all RPM packages in a local repository directory. +/// +/// Scans for `*.rpm` files recursively and extracts file digests +/// from each package's header. If `repodata/repomd.xml.asc` exists, +/// the signature is verified using `gpg_key_path` or +/// `repodata/repomd.xml.key`. +pub fn analyze_local_repo( + repo_dir: &Path, + gpg_key_path: Option<&Path>, +) -> Result { + if !repo_dir.is_dir() { + return Err(PolicyGenerationError::RpmParse { + path: repo_dir.to_path_buf(), + reason: "Not a directory".to_string(), + }); + } + + let repodata_dir = repo_dir.join("repodata"); + if !repodata_dir.is_dir() { + log::warn!( + "No repodata/ directory found in {}; scanning for RPM files anyway", + repo_dir.display() + ); + } else { + let repomd_path = repodata_dir.join("repomd.xml"); + let sig_path = repodata_dir.join("repomd.xml.asc"); + if sig_path.exists() { + let repomd_bytes = std::fs::read(&repomd_path).map_err(|e| { + PolicyGenerationError::RpmParse { + path: repomd_path.clone(), + reason: format!("Failed to read repomd.xml: {e}"), + } + })?; + let sig_bytes = std::fs::read(&sig_path).map_err(|e| { + PolicyGenerationError::RpmParse { + path: sig_path.clone(), + reason: format!("Failed to read repomd.xml.asc: {e}"), + } + })?; + let key_bytes = match gpg_key_path { + Some(p) => crate::policy_tools::gpg_verify::load_key_file(p)?, + None => { + let key_path = repodata_dir.join("repomd.xml.key"); + if !key_path.exists() { + return Err(PolicyGenerationError::GpgVerification { + path: sig_path.clone(), + reason: format!( + "Signature file exists but no key found at \ + {}; provide one with --gpg-key", + key_path.display() + ), + }); + } + crate::policy_tools::gpg_verify::load_key_file(&key_path)? + } + }; + crate::policy_tools::gpg_verify::verify_detached_signature( + &key_bytes, + &sig_bytes, + &repomd_bytes, + &repomd_path, + )?; + log::info!( + "Repository metadata signature verified: {}", + repomd_path.display() + ); + } else { + log::warn!( + "Unsigned repository metadata (no repomd.xml.asc found); \ + continuing anyway" + ); + } + } + + // Find all RPM files + let rpm_files = find_rpm_files(repo_dir)?; + + if rpm_files.is_empty() { + log::warn!("No RPM files found in {}", repo_dir.display()); + return Ok(DigestMap::new()); + } + + log::info!( + "Found {} RPM files in {}", + rpm_files.len(), + repo_dir.display() + ); + + // Analyze each RPM and merge results + let mut merged = DigestMap::new(); + for rpm_path in &rpm_files { + match analyze_rpm_pkg(rpm_path) { + Ok(pkg_digests) => { + merge_digest_maps(&mut merged, &pkg_digests); + } + Err(e) => { + log::warn!("Failed to analyze {}: {e}", rpm_path.display()); + } + } + } + + Ok(merged) +} + +/// Analyze a remote RPM repository via HTTP. +/// +/// Attempts the fast path using `filelists-ext.xml` metadata +/// first. Falls back to parsing `primary.xml` and downloading +/// individual RPM files if extended file lists are not available. +/// If `repodata/repomd.xml.asc` is present the signature is verified +/// using `gpg_key_path` or the bundled `repodata/repomd.xml.key`. +pub async fn analyze_remote_repo( + repo_url: &str, + gpg_key_path: Option<&Path>, + allow_unsigned: bool, +) -> Result { + let base_url = if repo_url.ends_with('/') { + repo_url.to_string() + } else { + format!("{repo_url}/") + }; + + // Download repomd.xml + let repomd_url = format!("{base_url}repodata/repomd.xml"); + let repomd_bytes = fetch_bytes(&repomd_url).await.map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(&repomd_url), + reason: format!("Failed to download repomd.xml: {e}"), + } + })?; + + // Verify signature if present + let sig_url = format!("{base_url}repodata/repomd.xml.asc"); + match fetch_optional_bytes(&sig_url).await? { + Some(sig_bytes) => { + let key_bytes = match gpg_key_path { + Some(p) => crate::policy_tools::gpg_verify::load_key_file(p)?, + None => { + let key_url = + format!("{base_url}repodata/repomd.xml.key"); + fetch_optional_bytes(&key_url).await?.ok_or_else( + || PolicyGenerationError::GpgVerification { + path: PathBuf::from(&sig_url), + reason: format!( + "Signature file exists but no key found \ + at {key_url}; provide one with --gpg-key" + ), + }, + )? + } + }; + crate::policy_tools::gpg_verify::verify_detached_signature( + &key_bytes, + &sig_bytes, + &repomd_bytes, + &PathBuf::from(&repomd_url), + )?; + log::info!( + "Repository metadata signature verified: {repomd_url}" + ); + } + None => { + if allow_unsigned { + log::warn!( + "Unsigned repository metadata (no repomd.xml.asc found); \ + continuing because --allow-unsigned-repo was specified" + ); + } else { + return Err(PolicyGenerationError::GpgVerification { + path: PathBuf::from(&repomd_url), + reason: "Repository metadata is unsigned (no repomd.xml.asc). \ + Use --allow-unsigned-repo to proceed anyway, or \ + provide a GPG key with --gpg-key." + .to_string(), + }); + } + } + } + + let repomd_xml = String::from_utf8_lossy(&repomd_bytes); + + // Try fast path: filelists-ext.xml + if let Some(filelists_href) = + parse_repomd_location(&repomd_xml, "filelists-ext") + { + validate_relative_href(&filelists_href)?; + let filelists_url = format!("{base_url}{filelists_href}"); + log::info!("Using filelists-ext.xml fast path: {filelists_url}"); + + match fetch_and_decompress(&filelists_url).await { + Ok(xml_data) => { + let xml = String::from_utf8_lossy(&xml_data); + return parse_filelists_ext(&xml); + } + Err(e) => { + log::warn!( + "Failed to fetch filelists-ext.xml: {e}; \ + falling back to RPM downloads" + ); + } + } + } + + // Slow path: parse primary.xml for RPM URLs, download + // each RPM file and parse its header. + log::warn!( + "filelists-ext.xml not available; \ + using slow path (downloading RPM files)" + ); + + let primary_href = parse_repomd_location(&repomd_xml, "primary") + .ok_or_else(|| PolicyGenerationError::RpmParse { + path: PathBuf::from(&base_url), + reason: "No primary metadata found in repomd.xml".to_string(), + })?; + + validate_relative_href(&primary_href)?; + let primary_url = format!("{base_url}{primary_href}"); + let primary_data = + fetch_and_decompress(&primary_url).await.map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(&primary_url), + reason: format!("Failed to download primary.xml: {e}"), + } + })?; + + let primary_xml = String::from_utf8_lossy(&primary_data); + let rpm_urls = parse_primary_rpm_urls(&primary_xml, &base_url)?; + + log::info!("Found {} RPM packages to analyze", rpm_urls.len()); + + let mut merged = DigestMap::new(); + for rpm_url in &rpm_urls { + match fetch_and_parse_rpm(rpm_url).await { + Ok(pkg_digests) => { + merge_digest_maps(&mut merged, &pkg_digests); + } + Err(e) => { + log::warn!("Failed to analyze {rpm_url}: {e}"); + } + } + } + + Ok(merged) +} + +/// Find all `.rpm` files recursively in a directory. +fn find_rpm_files(dir: &Path) -> Result, PolicyGenerationError> { + let mut rpm_files = Vec::new(); + find_rpm_files_recursive(dir, &mut rpm_files)?; + Ok(rpm_files) +} + +fn find_rpm_files_recursive( + dir: &Path, + results: &mut Vec, +) -> Result<(), PolicyGenerationError> { + let entries = std::fs::read_dir(dir).map_err(|e| { + PolicyGenerationError::RpmParse { + path: dir.to_path_buf(), + reason: format!("Failed to read directory: {e}"), + } + })?; + + for entry in entries { + let entry = entry.map_err(|e| PolicyGenerationError::RpmParse { + path: dir.to_path_buf(), + reason: format!("Failed to read directory entry: {e}"), + })?; + + let path = entry.path(); + if path.is_dir() { + find_rpm_files_recursive(&path, results)?; + } else if let Some(ext) = path.extension() { + if ext == "rpm" { + results.push(path); + } + } + } + + Ok(()) +} + +/// Parse repomd.xml to find the location of a specific data type. +/// +/// Looks for `` and +/// returns the href value. +fn parse_repomd_location(xml: &str, data_type: &str) -> Option { + use quick_xml::events::Event; + use quick_xml::Reader; + + let mut reader = Reader::from_str(xml); + let mut buf = Vec::new(); + let mut in_target_data = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) if e.local_name().as_ref() == b"data" => { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"type" + && attr.value.as_ref() == data_type.as_bytes() + { + in_target_data = true; + } + } + } + Ok(Event::Empty(e)) + if in_target_data + && e.local_name().as_ref() == b"location" => + { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"href" { + return Some( + String::from_utf8_lossy(&attr.value).to_string(), + ); + } + } + } + Ok(Event::End(e)) if e.local_name().as_ref() == b"data" => { + in_target_data = false; + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + buf.clear(); + } + + None +} + +/// Parse filelists-ext.xml to extract file digests. +/// +/// Looks for `PATH` elements. +fn parse_filelists_ext( + xml: &str, +) -> Result { + use quick_xml::events::Event; + use quick_xml::Reader; + + let mut reader = Reader::from_str(xml); + let mut buf = Vec::new(); + let mut digests = DigestMap::new(); + let mut current_hash: Option = None; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) if e.local_name().as_ref() == b"file" => { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"hash" { + current_hash = Some( + String::from_utf8_lossy(&attr.value).to_string(), + ); + } + } + } + Ok(Event::Text(e)) => { + if let Some(ref hash) = current_hash { + let text = e.decode().unwrap_or_default().to_string(); + if !text.is_empty() { + digests.entry(text).or_default().push(hash.clone()); + } + } + } + Ok(Event::End(e)) if e.local_name().as_ref() == b"file" => { + current_hash = None; + } + Ok(Event::Eof) => break, + Err(e) => { + return Err(PolicyGenerationError::RpmParse { + path: PathBuf::from(""), + reason: format!("XML parse error: {e}"), + }); + } + _ => {} + } + buf.clear(); + } + + Ok(digests) +} + +/// Parse primary.xml to get RPM package location URLs. +fn parse_primary_rpm_urls( + xml: &str, + base_url: &str, +) -> Result, PolicyGenerationError> { + use quick_xml::events::Event; + use quick_xml::Reader; + + let mut reader = Reader::from_str(xml); + let mut buf = Vec::new(); + let mut urls = Vec::new(); + let mut in_package = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) if e.local_name().as_ref() == b"package" => { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"type" + && attr.value.as_ref() == b"rpm" + { + in_package = true; + } + } + } + Ok(Event::Empty(e)) + if in_package && e.local_name().as_ref() == b"location" => + { + for attr in e.attributes().flatten() { + if attr.key.local_name().as_ref() == b"href" { + let href = String::from_utf8_lossy(&attr.value); + validate_relative_href(&href)?; + urls.push(format!("{base_url}{href}")); + } + } + } + Ok(Event::End(e)) if e.local_name().as_ref() == b"package" => { + in_package = false; + } + Ok(Event::Eof) => break, + Err(e) => { + return Err(PolicyGenerationError::RpmParse { + path: PathBuf::from(""), + reason: format!("XML parse error: {e}"), + }); + } + _ => {} + } + buf.clear(); + } + + Ok(urls) +} + +/// Fetch raw bytes from a URL, returning an error for any non-2xx response. +async fn fetch_bytes(url: &str) -> Result, PolicyGenerationError> { + let response = reqwest::get(url).await.map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("HTTP request failed: {e}"), + } + })?; + let status = response.status(); + if !status.is_success() { + return Err(PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("HTTP {status}"), + }); + } + response.bytes().await.map(|b| b.to_vec()).map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("Failed to read response body: {e}"), + } + }) +} + +/// Fetch raw bytes from a URL, returning `Ok(None)` on 404. +async fn fetch_optional_bytes( + url: &str, +) -> Result>, PolicyGenerationError> { + let response = reqwest::get(url).await.map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("HTTP request failed: {e}"), + } + })?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + let status = response.status(); + if !status.is_success() { + return Err(PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("HTTP {status}"), + }); + } + let data = response.bytes().await.map(|b| b.to_vec()).map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("Failed to read response body: {e}"), + } + })?; + Ok(Some(data)) +} + +/// Fetch data from a URL and decompress if needed (gzip, xz, +/// zstd, bzip2). +async fn fetch_and_decompress( + url: &str, +) -> Result, PolicyGenerationError> { + let response = reqwest::get(url).await.map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("HTTP request failed: {e}"), + } + })?; + let status = response.status(); + if !status.is_success() { + return Err(PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("HTTP {status}"), + }); + } + let data = response.bytes().await.map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("Failed to read response body: {e}"), + } + })?; + let data = data.to_vec(); + + decompress_data(&data, url) +} + +const MAX_REPO_DECOMPRESSED_SIZE: u64 = 100_000_000; // 100 MB + +/// Detect compression format and decompress data with size limit. +fn decompress_data( + data: &[u8], + source: &str, +) -> Result, PolicyGenerationError> { + if data.len() >= 2 && data[0] == 0x1f && data[1] == 0x8b { + // Gzip + let decoder = flate2::read::GzDecoder::new(data); + let mut limited = decoder.take(MAX_REPO_DECOMPRESSED_SIZE); + let mut decompressed = Vec::new(); + let _ = limited.read_to_end(&mut decompressed).map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(source), + reason: format!("Gzip decompression failed: {e}"), + } + })?; + Ok(decompressed) + } else if data.len() >= 6 + && data[0..6] == [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00] + { + // XZ + let decoder = xz2::read::XzDecoder::new(data); + let mut limited = decoder.take(MAX_REPO_DECOMPRESSED_SIZE); + let mut decompressed = Vec::new(); + let _ = limited.read_to_end(&mut decompressed).map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(source), + reason: format!("XZ decompression failed: {e}"), + } + })?; + Ok(decompressed) + } else if data.len() >= 4 && data[0..4] == [0x28, 0xb5, 0x2f, 0xfd] { + // Zstd + let decoder = zstd::stream::Decoder::new(data).map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(source), + reason: format!("Zstd init failed: {e}"), + } + })?; + let mut limited = decoder.take(MAX_REPO_DECOMPRESSED_SIZE); + let mut decompressed = Vec::new(); + let _ = limited.read_to_end(&mut decompressed).map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(source), + reason: format!("Zstd decompression failed: {e}"), + } + })?; + Ok(decompressed) + } else if data.len() >= 3 + && data[0] == b'B' + && data[1] == b'Z' + && data[2] == b'h' + { + // Bzip2 + let decoder = bzip2::read::BzDecoder::new(data); + let mut limited = decoder.take(MAX_REPO_DECOMPRESSED_SIZE); + let mut decompressed = Vec::new(); + let _ = limited.read_to_end(&mut decompressed).map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(source), + reason: format!("Bzip2 decompression failed: {e}"), + } + })?; + Ok(decompressed) + } else { + // Assume uncompressed + Ok(data.to_vec()) + } +} + +/// Fetch and parse an RPM file from a remote URL. +/// +/// Downloads the full RPM file and parses only the header +/// metadata to extract file digests. +async fn fetch_and_parse_rpm( + url: &str, +) -> Result { + let response = reqwest::get(url).await.map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("HTTP request failed: {e}"), + } + })?; + let status = response.status(); + if !status.is_success() { + return Err(PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("HTTP {status}"), + }); + } + let data = response.bytes().await.map_err(|e| { + PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("Failed to read response body: {e}"), + } + })?; + + let metadata = + rpm::PackageMetadata::parse(&mut std::io::Cursor::new(&data)) + .map_err(|e| PolicyGenerationError::RpmParse { + path: PathBuf::from(url), + reason: format!("Failed to parse RPM header: {e}"), + })?; + + extract_digests_from_metadata(&metadata, Path::new(url)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_empty_digest() { + assert!(is_empty_digest("00000000000000000000000000000000")); + assert!(is_empty_digest( + "0000000000000000000000000000000000000000000000000000000000000000" + )); + assert!(!is_empty_digest("abcdef1234567890abcdef1234567890")); + assert!(!is_empty_digest("")); + assert!(is_empty_digest("0")); + } + + #[test] + fn test_merge_digest_maps() { + let mut dst = DigestMap::new(); + let _ = dst.insert("/usr/bin/a".into(), vec!["aaaa".into()]); + + let mut src = DigestMap::new(); + let _ = src.insert("/usr/bin/a".into(), vec!["bbbb".into()]); + let _ = src.insert("/usr/bin/b".into(), vec!["cccc".into()]); + + merge_digest_maps(&mut dst, &src); + + assert_eq!(dst["/usr/bin/a"].len(), 2); + assert!(dst["/usr/bin/a"].contains(&"aaaa".to_string())); + assert!(dst["/usr/bin/a"].contains(&"bbbb".to_string())); + assert_eq!(dst["/usr/bin/b"], vec!["cccc".to_string()]); + } + + #[test] + fn test_merge_digest_maps_dedup() { + let mut dst = DigestMap::new(); + let _ = dst.insert("/usr/bin/a".into(), vec!["aaaa".into()]); + + let mut src = DigestMap::new(); + let _ = src.insert("/usr/bin/a".into(), vec!["aaaa".into()]); + + merge_digest_maps(&mut dst, &src); + + assert_eq!(dst["/usr/bin/a"].len(), 1); + } + + #[test] + fn test_parse_repomd_location_filelists_ext() { + let xml = r#" + + + + + + + + + + +"#; + + let result = parse_repomd_location(xml, "filelists-ext"); + assert_eq!(result, Some("repodata/filelists-ext.xml.gz".to_string())); + + let result = parse_repomd_location(xml, "primary"); + assert_eq!(result, Some("repodata/primary.xml.gz".to_string())); + + let result = parse_repomd_location(xml, "nonexistent"); + assert_eq!(result, None); + } + + #[test] + fn test_parse_filelists_ext() { + let xml = r#" + + + /usr/bin/bash + /usr/bin/sh + + + /usr/bin/ls + +"#; + + let result = parse_filelists_ext(xml).unwrap(); //#[allow_ci] + assert_eq!(result.len(), 3); + assert_eq!(result["/usr/bin/bash"], vec!["abcdef1234567890"]); + assert_eq!(result["/usr/bin/sh"], vec!["1234567890abcdef"]); + assert_eq!(result["/usr/bin/ls"], vec!["deadbeef12345678"]); + } + + #[test] + fn test_parse_primary_rpm_urls() { + let xml = r#" + + + bash + + + + coreutils + + +"#; + + let urls = + parse_primary_rpm_urls(xml, "https://example.com/repo/").unwrap(); //#[allow_ci] + assert_eq!(urls.len(), 2); + assert_eq!( + urls[0], + "https://example.com/repo/Packages/bash-5.2.26-1.fc40.x86_64.rpm" + ); + assert_eq!( + urls[1], + "https://example.com/repo/Packages/coreutils-9.4-1.fc40.x86_64.rpm" + ); + } + + #[test] + fn test_find_rpm_files() { + let dir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create test files + std::fs::write(dir.path().join("test1.rpm"), b"fake").unwrap(); //#[allow_ci] + std::fs::write(dir.path().join("test2.rpm"), b"fake").unwrap(); //#[allow_ci] + std::fs::write(dir.path().join("readme.txt"), b"text").unwrap(); //#[allow_ci] + + let subdir = dir.path().join("subdir"); + std::fs::create_dir(&subdir).unwrap(); //#[allow_ci] + std::fs::write(subdir.join("test3.rpm"), b"fake").unwrap(); //#[allow_ci] + + let rpm_files = find_rpm_files(dir.path()).unwrap(); //#[allow_ci] + assert_eq!(rpm_files.len(), 3); + assert!(rpm_files.iter().all(|p| p.extension().unwrap() == "rpm")); //#[allow_ci] + } + + #[test] + fn test_decompress_uncompressed() { + let data = b"hello world"; + let result = decompress_data(data, "test").unwrap(); //#[allow_ci] + assert_eq!(result, b"hello world"); + } + + #[test] + fn test_decompress_gzip() { + use flate2::write::GzEncoder; + use std::io::Write; + + let mut encoder = + GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(b"gzip test data").unwrap(); //#[allow_ci] + let compressed = encoder.finish().unwrap(); //#[allow_ci] + + let result = decompress_data(&compressed, "test").unwrap(); //#[allow_ci] + assert_eq!(result, b"gzip test data"); + } + + #[test] + fn test_parse_repomd_empty() { + let xml = r#" + +"#; + + let result = parse_repomd_location(xml, "primary"); + assert_eq!(result, None); + } + + #[test] + fn test_parse_filelists_ext_empty() { + let xml = r#" + +"#; + + let result = parse_filelists_ext(xml).unwrap(); //#[allow_ci] + assert!(result.is_empty()); + } +} diff --git a/keylimectl/src/policy_tools/runtime_policy.rs b/keylimectl/src/policy_tools/runtime_policy.rs new file mode 100644 index 000000000..30f040cd0 --- /dev/null +++ b/keylimectl/src/policy_tools/runtime_policy.rs @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Runtime policy v1 schema types. +//! +//! These types match the Python `RuntimePolicyType` TypedDict definition +//! from `keylime.ima.types`, ensuring compatibility between the Python +//! and Rust implementations. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// The current runtime policy schema version. +/// Must match `RUNTIME_POLICY_CURRENT_VERSION` in the Python verifier. +pub const RUNTIME_POLICY_VERSION: u32 = 1; + +/// Generator identifier (integer matching the Python +/// `RUNTIME_POLICY_GENERATOR` enum: Unknown=0, EmptyAllowList=1, +/// CompatibleAllowList=2, LegacyAllowList=3). +pub const RUNTIME_POLICY_GENERATOR: u32 = 3; + +/// A v1 runtime policy. +/// +/// All fields marked `Required` in the Python `RuntimePolicyType` are +/// non-optional here. Fields marked `NotRequired` use `Option` or have +/// serde defaults. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimePolicy { + /// Policy metadata (version, generator, timestamp). + pub meta: PolicyMeta, + + /// Policy release number (incremented on updates). + #[serde(default)] + pub release: u32, + + /// File path -> list of acceptable digests (bare hex, e.g., `"abcd1234..."`). + pub digests: HashMap>, + + /// Glob patterns for paths to exclude from verification. + #[serde(default)] + pub excludes: Vec, + + /// Keyring name -> list of acceptable digests. + #[serde(default)] + pub keyrings: HashMap>, + + /// IMA-specific configuration. + #[serde(default)] + pub ima: ImaPolicyConfig, + + /// IMA-buf entry name -> list of acceptable digests. + #[serde(default, rename = "ima-buf")] + pub ima_buf: HashMap>, + + /// JSON-encoded IMA signature verification keys. + #[serde(default, rename = "verification-keys")] + pub verification_keys: String, +} + +/// Policy metadata. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PolicyMeta { + /// Schema version number. + pub version: u32, + + /// Generator identifier (integer matching the Python + /// `RUNTIME_POLICY_GENERATOR` enum). + pub generator: u32, + + /// ISO 8601 timestamp of when the policy was generated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + +/// IMA-specific policy configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ImaPolicyConfig { + /// Keyring names to ignore during verification. + #[serde(default)] + pub ignored_keyrings: Vec, + + /// Hash algorithm used in the IMA measurement log. + #[serde(default = "default_log_hash_alg")] + pub log_hash_alg: String, + + /// Device-mapper policy configuration (dm-verity, dm-crypt). + /// Always serialized (as null when None) because the verifier schema + /// requires the field to be present. + #[serde(default)] + pub dm_policy: Option, +} + +impl Default for ImaPolicyConfig { + fn default() -> Self { + Self { + ignored_keyrings: Vec::new(), + log_hash_alg: default_log_hash_alg(), + dm_policy: None, + } + } +} + +fn default_log_hash_alg() -> String { + "sha1".to_string() +} + +impl RuntimePolicy { + /// Create a new empty runtime policy with default metadata. + pub fn new() -> Self { + Self { + meta: PolicyMeta { + version: RUNTIME_POLICY_VERSION, + generator: RUNTIME_POLICY_GENERATOR, + timestamp: Some(chrono::Utc::now().to_rfc3339()), + }, + release: 0, + digests: HashMap::new(), + excludes: Vec::new(), + keyrings: HashMap::new(), + ima: ImaPolicyConfig::default(), + ima_buf: HashMap::new(), + verification_keys: String::new(), + } + } + + /// Add a digest entry for a file path, avoiding duplicates. + pub fn add_digest(&mut self, path: String, digest: String) { + let entry = self.digests.entry(path).or_default(); + if !entry.contains(&digest) { + entry.push(digest); + } + } + + /// Add an exclude pattern. + pub fn add_exclude(&mut self, pattern: String) { + if !self.excludes.contains(&pattern) { + self.excludes.push(pattern); + } + } + + /// Add a keyring entry, avoiding duplicates. + pub fn add_keyring(&mut self, keyring: String, digest: String) { + let entry = self.keyrings.entry(keyring).or_default(); + if !entry.contains(&digest) { + entry.push(digest); + } + } + + /// Add an ima-buf entry, avoiding duplicates. + pub fn add_ima_buf(&mut self, name: String, digest: String) { + let entry = self.ima_buf.entry(name).or_default(); + if !entry.contains(&digest) { + entry.push(digest); + } + } + + /// Set the hash algorithm used in the IMA log. + pub fn set_log_hash_alg(&mut self, alg: String) { + self.ima.log_hash_alg = alg; + } + + /// Add a keyring name to the ignored keyrings list. + pub fn add_ignored_keyring(&mut self, keyring: String) { + if !self.ima.ignored_keyrings.contains(&keyring) { + self.ima.ignored_keyrings.push(keyring); + } + } + + /// Return the total number of unique file paths with digests. + pub fn digest_count(&self) -> usize { + self.digests.len() + } + + /// Return the total number of exclude patterns. + pub fn exclude_count(&self) -> usize { + self.excludes.len() + } +} + +impl Default for RuntimePolicy { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_new_policy_has_correct_defaults() { + let policy = RuntimePolicy::new(); + assert_eq!(policy.meta.version, RUNTIME_POLICY_VERSION); + assert_eq!(policy.meta.generator, RUNTIME_POLICY_GENERATOR); + assert_eq!(policy.meta.generator, 3); // LegacyAllowList + assert_eq!(policy.release, 0); + assert!(policy.digests.is_empty()); + assert!(policy.excludes.is_empty()); + assert!(policy.keyrings.is_empty()); + assert!(policy.ima_buf.is_empty()); + assert_eq!(policy.ima.log_hash_alg, "sha1"); + assert!(policy.ima.ignored_keyrings.is_empty()); + assert!(policy.ima.dm_policy.is_none()); + assert!(policy.verification_keys.is_empty()); + assert!(policy.meta.timestamp.is_some()); + } + + #[test] + fn test_add_digest() { + let mut policy = RuntimePolicy::new(); + policy.add_digest( + "/usr/bin/bash".to_string(), + "abc123def456abc123def456abc123def456abc123".to_string(), + ); + policy.add_digest( + "/usr/bin/bash".to_string(), + "def456abc123def456abc123def456abc123def456".to_string(), + ); + policy.add_digest( + "/usr/bin/ls".to_string(), + "789abcdef012789abcdef012789abcdef012789abc".to_string(), + ); + + assert_eq!(policy.digest_count(), 2); + assert_eq!(policy.digests["/usr/bin/bash"].len(), 2); + assert_eq!(policy.digests["/usr/bin/ls"].len(), 1); + } + + #[test] + fn test_add_exclude_no_duplicates() { + let mut policy = RuntimePolicy::new(); + policy.add_exclude("/tmp/*".to_string()); + policy.add_exclude("/proc/*".to_string()); + policy.add_exclude("/tmp/*".to_string()); + + assert_eq!(policy.exclude_count(), 2); + } + + #[test] + fn test_add_keyring() { + let mut policy = RuntimePolicy::new(); + policy.add_keyring( + ".builtin_trusted_keys".to_string(), + "aabbccddeeff00112233aabbccddeeff00112233".to_string(), + ); + + assert_eq!(policy.keyrings.len(), 1); + assert_eq!( + policy.keyrings[".builtin_trusted_keys"], + vec!["aabbccddeeff00112233aabbccddeeff00112233"] + ); + } + + #[test] + fn test_serialization_roundtrip() { + let mut policy = RuntimePolicy::new(); + policy.add_digest( + "/usr/bin/bash".to_string(), + "abc123def456abc123def456abc123def456abc123".to_string(), + ); + policy.add_exclude("/tmp/*".to_string()); + policy.add_keyring( + "_ima".to_string(), + "aabbccddeeff00112233aabbccddeeff00112233".to_string(), + ); + policy.add_ima_buf( + "dm_table".to_string(), + "1122334455667788990011223344556677889900".to_string(), + ); + policy.set_log_hash_alg("sha256".to_string()); + + let json_str = serde_json::to_string(&policy).unwrap(); //#[allow_ci] + let deserialized: RuntimePolicy = + serde_json::from_str(&json_str).unwrap(); //#[allow_ci] + + assert_eq!(policy, deserialized); + } + + #[test] + fn test_deserialize_python_compatible_policy() { + // Simulate a policy generated by the Python implementation + let python_policy = json!({ + "meta": { + "version": 1, + "generator": 3, + "timestamp": "2025-01-01T00:00:00Z" + }, + "release": 1, + "digests": { + "/usr/bin/bash": ["abcdef1234567890abcdef1234567890abcdef1234"], + "/usr/bin/ls": ["1234567890abcdef1234567890abcdef12345678901234567890abcdef12345678", "aabbccddee112233445566778899001122334455"] + }, + "excludes": ["/tmp/*", "/proc/*"], + "keyrings": { + ".builtin_trusted_keys": ["a7d52aaa18c23d2d9bb2abb4308c0eeee67387a42259f4a6b1a42257065f3d5a"] + }, + "ima": { + "ignored_keyrings": ["_evm"], + "log_hash_alg": "sha256", + "dm_policy": null + }, + "ima-buf": { + "dm_table": ["abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"] + }, + "verification-keys": "" + }); + + let policy: RuntimePolicy = + serde_json::from_value(python_policy).unwrap(); //#[allow_ci] + + assert_eq!(policy.meta.version, 1); + // Python generator is numeric (3 = LegacyAllowList) + assert_eq!(policy.meta.generator, 3); + assert_eq!(policy.release, 1); + assert_eq!(policy.digest_count(), 2); + assert_eq!(policy.digests["/usr/bin/ls"].len(), 2); + assert_eq!(policy.exclude_count(), 2); + assert_eq!(policy.ima.log_hash_alg, "sha256"); + assert_eq!(policy.ima.ignored_keyrings, vec!["_evm"]); + assert!(policy.ima.dm_policy.is_none()); + assert!(policy.verification_keys.is_empty()); + } + + #[test] + fn test_deserialize_minimal_policy() { + // Only required fields + let minimal = json!({ + "meta": { "version": 1, "generator": 0 }, + "digests": {}, + "excludes": [], + "keyrings": {}, + "ima": { + "ignored_keyrings": [], + "log_hash_alg": "sha1" + }, + "ima-buf": {}, + "verification-keys": "" + }); + + let policy: RuntimePolicy = serde_json::from_value(minimal).unwrap(); //#[allow_ci] + + assert_eq!(policy.meta.version, 1); + assert_eq!(policy.release, 0); + assert!(policy.digests.is_empty()); + } + + #[test] + fn test_serialized_json_has_correct_keys() { + let policy = RuntimePolicy::new(); + let json_val: serde_json::Value = + serde_json::to_value(&policy).unwrap(); //#[allow_ci] + + // Verify hyphenated key names (Rust uses underscores internally) + assert!(json_val.get("ima-buf").is_some()); + assert!(json_val.get("verification-keys").is_some()); + // These should NOT appear + assert!(json_val.get("ima_buf").is_none()); + assert!(json_val.get("verification_keys").is_none()); + + // dm_policy must always be present (verifier schema requires it) + let ima = json_val.get("ima").unwrap(); //#[allow_ci] + assert!( + ima.get("dm_policy").is_some(), + "dm_policy must be serialized even when None" + ); + assert!(ima.get("dm_policy").unwrap().is_null()); //#[allow_ci] + } + + #[test] + fn test_set_log_hash_alg() { + let mut policy = RuntimePolicy::new(); + assert_eq!(policy.ima.log_hash_alg, "sha1"); + policy.set_log_hash_alg("sha256".to_string()); + assert_eq!(policy.ima.log_hash_alg, "sha256"); + } + + #[test] + fn test_add_ignored_keyring_no_duplicates() { + let mut policy = RuntimePolicy::new(); + policy.add_ignored_keyring("_evm".to_string()); + policy.add_ignored_keyring("_ima".to_string()); + policy.add_ignored_keyring("_evm".to_string()); + + assert_eq!(policy.ima.ignored_keyrings.len(), 2); + } +} diff --git a/keylimectl/src/policy_tools/tpm_policy.rs b/keylimectl/src/policy_tools/tpm_policy.rs new file mode 100644 index 000000000..12cf6da61 --- /dev/null +++ b/keylimectl/src/policy_tools/tpm_policy.rs @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! TPM policy types. +//! +//! A TPM policy specifies a PCR mask and expected PCR values +//! for a given hash algorithm. + +#![allow(dead_code)] // Types used in later implementation steps + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A TPM policy specifying expected PCR values. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TpmPolicy { + /// PCR mask as a hex string (e.g., `"0x408000"`). + pub mask: String, + + /// PCR index -> expected hex digest value. + #[serde(flatten)] + pub pcr_values: HashMap, +} + +impl TpmPolicy { + /// Create a new empty TPM policy. + pub fn new() -> Self { + Self { + mask: "0x0".to_string(), + pcr_values: HashMap::new(), + } + } + + /// Create a TPM policy from PCR indices and values. + pub fn from_pcrs(pcrs: &[(u32, String)]) -> Self { + let mut mask: u32 = 0; + let mut pcr_values = HashMap::new(); + + for (index, value) in pcrs { + if *index >= 24 { + log::warn!( + "PCR index {index} is out of range (0-23), skipping" + ); + continue; + } + mask |= 1u32 << index; + let _ = pcr_values.insert(index.to_string(), value.clone()); + } + + Self { + mask: format!("0x{mask:x}"), + pcr_values, + } + } + + /// Parse a PCR mask string to get the set of selected indices. + pub fn parse_mask(mask: &str) -> Result, String> { + let hex_str = mask + .strip_prefix("0x") + .or_else(|| mask.strip_prefix("0X")) + .unwrap_or(mask); + + let value = u32::from_str_radix(hex_str, 16) + .map_err(|e| format!("Invalid PCR mask '{mask}': {e}"))?; + + let mut indices = Vec::new(); + for i in 0..24 { + if value & (1 << i) != 0 { + indices.push(i); + } + } + Ok(indices) + } +} + +impl Default for TpmPolicy { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_policy() { + let policy = TpmPolicy::new(); + assert_eq!(policy.mask, "0x0"); + assert!(policy.pcr_values.is_empty()); + } + + #[test] + fn test_from_pcrs() { + let pcrs = vec![ + (0, "aabb".to_string()), + (1, "ccdd".to_string()), + (7, "eeff".to_string()), + ]; + + let policy = TpmPolicy::from_pcrs(&pcrs); + + // Mask should be 0x83 (bits 0, 1, 7) + assert_eq!(policy.mask, "0x83"); + assert_eq!(policy.pcr_values["0"], "aabb"); + assert_eq!(policy.pcr_values["1"], "ccdd"); + assert_eq!(policy.pcr_values["7"], "eeff"); + } + + #[test] + fn test_parse_mask() { + assert_eq!(TpmPolicy::parse_mask("0x87").unwrap(), vec![0, 1, 2, 7]); //#[allow_ci] + assert_eq!(TpmPolicy::parse_mask("0x0").unwrap(), Vec::::new()); //#[allow_ci] + assert_eq!(TpmPolicy::parse_mask("0x1").unwrap(), vec![0]); //#[allow_ci] + // Without prefix + assert_eq!( + TpmPolicy::parse_mask("ff").unwrap(), //#[allow_ci] + vec![0, 1, 2, 3, 4, 5, 6, 7] + ); + } + + #[test] + fn test_parse_mask_invalid() { + assert!(TpmPolicy::parse_mask("invalid").is_err()); + } + + #[test] + fn test_serialization_roundtrip() { + let pcrs = + vec![(0, "aabbccdd".to_string()), (7, "eeff0011".to_string())]; + let policy = TpmPolicy::from_pcrs(&pcrs); + + let json_str = serde_json::to_string(&policy).unwrap(); //#[allow_ci] + let deserialized: TpmPolicy = + serde_json::from_str(&json_str).unwrap(); //#[allow_ci] + + assert_eq!(policy, deserialized); + } +} diff --git a/keylimectl/src/policy_tools/tpm_policy_gen.rs b/keylimectl/src/policy_tools/tpm_policy_gen.rs new file mode 100644 index 000000000..988d92983 --- /dev/null +++ b/keylimectl/src/policy_tools/tpm_policy_gen.rs @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! TPM policy generation from PCR values. +//! +//! Generates a TPM policy by reading PCR values from a file +//! or (behind a feature flag) from the local TPM. + +use crate::commands::error::PolicyGenerationError; +use crate::policy_tools::tpm_policy::TpmPolicy; +#[cfg(any(feature = "tpm-local", feature = "tpm-quote-validation"))] +use std::env; +use std::path::Path; + +/// Generate a TPM policy from a PCR values file. +/// +/// The file should contain one PCR value per line in the format: +/// ```text +/// PCR_INDEX HEX_VALUE +/// ``` +/// or simply one hex value per line (index is inferred from line number). +pub fn generate_from_file( + path: &Path, + pcr_indices: &[u32], +) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| { + PolicyGenerationError::Output { + path: path.to_path_buf(), + reason: format!("Failed to read PCR file: {e}"), + } + })?; + + let mut pcrs: Vec<(u32, String)> = Vec::new(); + let mut data_line_idx: u32 = 0; + + for (line_idx, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Try "INDEX VALUE" format first + let parts: Vec<&str> = + line.splitn(2, |c: char| c.is_whitespace()).collect(); + + let (index, value) = if parts.len() == 2 { + let idx = parts[0].trim().parse::().map_err(|e| { + PolicyGenerationError::Output { + path: path.to_path_buf(), + reason: format!( + "Invalid PCR index on line {}: {e}", + line_idx + 1 + ), + } + })?; + (idx, parts[1].trim().to_string()) + } else { + // Single value per line - index from data line count + let idx = data_line_idx; + (idx, line.to_string()) + }; + + data_line_idx += 1; + + // Only include if it's in the requested indices + if pcr_indices.contains(&index) { + // Validate hex value + if !value.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(PolicyGenerationError::Output { + path: path.to_path_buf(), + reason: format!( + "Invalid hex value for PCR {index}: '{value}'" + ), + }); + } + pcrs.push((index, value)); + } + } + + if pcrs.is_empty() { + return Err(PolicyGenerationError::Output { + path: path.to_path_buf(), + reason: "No PCR values found for the requested indices" + .to_string(), + }); + } + + Ok(TpmPolicy::from_pcrs(&pcrs)) +} + +/// Parse a comma-separated list of PCR indices. +pub fn parse_pcr_indices( + pcrs_str: &str, +) -> Result, PolicyGenerationError> { + let mut indices = Vec::new(); + for part in pcrs_str.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let idx = part.parse::().map_err(|e| { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Invalid PCR index '{part}': {e}"), + } + })?; + if idx > 23 { + return Err(PolicyGenerationError::Output { + path: "".into(), + reason: format!("PCR index {idx} out of range (0-23)"), + }); + } + indices.push(idx); + } + Ok(indices) +} + +/// Map a hash algorithm name to `tss_esapi::interface_types::algorithm::HashingAlgorithm`. +#[cfg(any(feature = "tpm-local", feature = "tpm-quote-validation"))] +fn map_hash_algorithm( + alg: &str, +) -> Result< + tss_esapi::interface_types::algorithm::HashingAlgorithm, + PolicyGenerationError, +> { + use tss_esapi::interface_types::algorithm::HashingAlgorithm; + match alg.to_lowercase().as_str() { + "sha1" => Ok(HashingAlgorithm::Sha1), + "sha256" => Ok(HashingAlgorithm::Sha256), + "sha384" => Ok(HashingAlgorithm::Sha384), + "sha512" => Ok(HashingAlgorithm::Sha512), + other => Err(PolicyGenerationError::UnsupportedAlgorithm { + algorithm: format!("Unsupported TPM hash algorithm: {other}"), + }), + } +} + +/// Map a u32 PCR index to a `tss_esapi::structures::PcrSlot`. +#[cfg(any(feature = "tpm-local", feature = "tpm-quote-validation"))] +fn map_pcr_slot( + index: u32, +) -> Result { + use tss_esapi::structures::PcrSlot; + match index { + 0 => Ok(PcrSlot::Slot0), + 1 => Ok(PcrSlot::Slot1), + 2 => Ok(PcrSlot::Slot2), + 3 => Ok(PcrSlot::Slot3), + 4 => Ok(PcrSlot::Slot4), + 5 => Ok(PcrSlot::Slot5), + 6 => Ok(PcrSlot::Slot6), + 7 => Ok(PcrSlot::Slot7), + 8 => Ok(PcrSlot::Slot8), + 9 => Ok(PcrSlot::Slot9), + 10 => Ok(PcrSlot::Slot10), + 11 => Ok(PcrSlot::Slot11), + 12 => Ok(PcrSlot::Slot12), + 13 => Ok(PcrSlot::Slot13), + 14 => Ok(PcrSlot::Slot14), + 15 => Ok(PcrSlot::Slot15), + 16 => Ok(PcrSlot::Slot16), + 17 => Ok(PcrSlot::Slot17), + 18 => Ok(PcrSlot::Slot18), + 19 => Ok(PcrSlot::Slot19), + 20 => Ok(PcrSlot::Slot20), + 21 => Ok(PcrSlot::Slot21), + 22 => Ok(PcrSlot::Slot22), + 23 => Ok(PcrSlot::Slot23), + _ => Err(PolicyGenerationError::Output { + path: "".into(), + reason: format!("PCR index {index} out of range (0-23)"), + }), + } +} + +/// Generate a TPM policy by reading PCR values from the local TPM. +/// +/// Requires the `tpm-local` or `tpm-quote-validation` feature flag. +#[cfg(any(feature = "tpm-local", feature = "tpm-quote-validation"))] +pub fn generate_from_tpm( + pcr_indices: &[u32], + hash_alg: &str, +) -> Result { + use crate::policy_tools::privilege; + use tss_esapi::structures::PcrSelectionListBuilder; + use tss_esapi::tcti_ldr::TctiNameConf; + + let hashing_alg = map_hash_algorithm(hash_alg)?; + + // Map indices to PcrSlots + let slots: Vec = pcr_indices + .iter() + .map(|&idx| map_pcr_slot(idx)) + .collect::, _>>()?; + + // Build PCR selection list + let pcr_selection = PcrSelectionListBuilder::new() + .with_selection(hashing_alg, &slots) + .build() + .map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to build PCR selection: {e}"), + })?; + + // Determine TCTI path + let tcti_str = env::var("TPM2TOOLS_TCTI") + .or_else(|_| env::var("TCTI")) + .unwrap_or_else(|_| "device:/dev/tpmrm0".to_string()); + + let tcti: TctiNameConf = + tcti_str + .parse() + .map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!( + "Failed to parse TCTI configuration '{tcti_str}': {e}" + ), + })?; + + // Open TPM context + let mut context = tss_esapi::Context::new(tcti).map_err(|e| { + if privilege::is_permission_error(&std::io::Error::new( + if e.to_string().contains("Permission") + || e.to_string().contains("EACCES") + { + std::io::ErrorKind::PermissionDenied + } else { + std::io::ErrorKind::Other + }, + e.to_string(), + )) { + PolicyGenerationError::PrivilegeRequired { + operation: "policy generate tpm --from-tpm".to_string(), + path: std::path::PathBuf::from("/dev/tpmrm0"), + hint: privilege::suggest_sudo( + "policy generate tpm --from-tpm", + ), + } + } else { + PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to open TPM context: {e}"), + } + } + })?; + + // Read PCR values + let (_, _, pcr_digests) = context + .execute_without_session(|ctx| ctx.pcr_read(pcr_selection.clone())) + .map_err(|e| PolicyGenerationError::Output { + path: "".into(), + reason: format!("Failed to read PCR values: {e}"), + })?; + + // Extract digest bytes and build PCR value pairs + let mut pcrs: Vec<(u32, String)> = Vec::new(); + + for (slot_idx, digest) in pcr_digests.value().iter().enumerate() { + if slot_idx < pcr_indices.len() { + let hex_value = hex::encode(digest.value()); + pcrs.push((pcr_indices[slot_idx], hex_value)); + } + } + + if pcrs.is_empty() { + return Err(PolicyGenerationError::Output { + path: "".into(), + reason: "No PCR values read from TPM".to_string(), + }); + } + + Ok(TpmPolicy::from_pcrs(&pcrs)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_generate_from_file_indexed() { + let mut tmp = NamedTempFile::new().unwrap(); //#[allow_ci] + writeln!(tmp, "0 aabbccdd").unwrap(); //#[allow_ci] + writeln!(tmp, "7 eeff0011").unwrap(); //#[allow_ci] + writeln!(tmp, "14 22334455").unwrap(); //#[allow_ci] + + let policy = generate_from_file(tmp.path(), &[0, 7, 14]).unwrap(); //#[allow_ci] + + assert_eq!(policy.pcr_values.len(), 3); + assert_eq!(policy.pcr_values["0"], "aabbccdd"); + assert_eq!(policy.pcr_values["7"], "eeff0011"); + } + + #[test] + fn test_generate_from_file_sequential() { + let mut tmp = NamedTempFile::new().unwrap(); //#[allow_ci] + writeln!(tmp, "aaaa").unwrap(); //#[allow_ci] + writeln!(tmp, "bbbb").unwrap(); //#[allow_ci] + writeln!(tmp, "cccc").unwrap(); //#[allow_ci] + + let policy = generate_from_file(tmp.path(), &[0, 1, 2]).unwrap(); //#[allow_ci] + + assert_eq!(policy.pcr_values.len(), 3); + assert_eq!(policy.pcr_values["0"], "aaaa"); + assert_eq!(policy.pcr_values["1"], "bbbb"); + assert_eq!(policy.pcr_values["2"], "cccc"); + } + + #[test] + fn test_generate_from_file_filter_indices() { + let mut tmp = NamedTempFile::new().unwrap(); //#[allow_ci] + writeln!(tmp, "0 aaaa").unwrap(); //#[allow_ci] + writeln!(tmp, "1 bbbb").unwrap(); //#[allow_ci] + writeln!(tmp, "7 cccc").unwrap(); //#[allow_ci] + + // Only request PCR 0 and 7 + let policy = generate_from_file(tmp.path(), &[0, 7]).unwrap(); //#[allow_ci] + + assert_eq!(policy.pcr_values.len(), 2); + assert!(policy.pcr_values.contains_key("0")); + assert!(policy.pcr_values.contains_key("7")); + assert!(!policy.pcr_values.contains_key("1")); + } + + #[test] + fn test_generate_from_file_comments() { + let mut tmp = NamedTempFile::new().unwrap(); //#[allow_ci] + writeln!(tmp, "# PCR values").unwrap(); //#[allow_ci] + writeln!(tmp, "0 aaaa").unwrap(); //#[allow_ci] + writeln!(tmp).unwrap(); //#[allow_ci] + writeln!(tmp, "7 bbbb").unwrap(); //#[allow_ci] + + let policy = generate_from_file(tmp.path(), &[0, 7]).unwrap(); //#[allow_ci] + + assert_eq!(policy.pcr_values.len(), 2); + } + + #[test] + fn test_generate_from_file_invalid_hex() { + let mut tmp = NamedTempFile::new().unwrap(); //#[allow_ci] + writeln!(tmp, "0 not_hex!").unwrap(); //#[allow_ci] + + let result = generate_from_file(tmp.path(), &[0]); + assert!(result.is_err()); + } + + #[test] + fn test_generate_from_file_no_matching_pcrs() { + let mut tmp = NamedTempFile::new().unwrap(); //#[allow_ci] + writeln!(tmp, "0 aaaa").unwrap(); //#[allow_ci] + + let result = generate_from_file( + tmp.path(), + &[7], // PCR 7 not in file + ); + assert!(result.is_err()); + } + + #[test] + fn test_parse_pcr_indices() { + assert_eq!( + parse_pcr_indices("0,1,2,7").unwrap(), //#[allow_ci] + vec![0, 1, 2, 7] + ); + } + + #[test] + fn test_parse_pcr_indices_with_spaces() { + assert_eq!( + parse_pcr_indices("0, 1, 7").unwrap(), //#[allow_ci] + vec![0, 1, 7] + ); + } + + #[test] + fn test_parse_pcr_indices_out_of_range() { + assert!(parse_pcr_indices("0,1,25").is_err()); + } + + #[test] + fn test_parse_pcr_indices_invalid() { + assert!(parse_pcr_indices("0,abc,7").is_err()); + } +} diff --git a/keylimectl/src/policy_tools/uefi_event_data.rs b/keylimectl/src/policy_tools/uefi_event_data.rs new file mode 100644 index 000000000..dd1d0dfdf --- /dev/null +++ b/keylimectl/src/policy_tools/uefi_event_data.rs @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Parsers for UEFI event data structures. +//! +//! These parsers extract structured information from raw `event_data` +//! bytes returned by [`keylime::uefi::UefiLogHandler`]. + +/// Parsed UEFI_VARIABLE_DATA structure. +/// +/// Represents the content of `EV_EFI_VARIABLE_DRIVER_CONFIG`, +/// `EV_EFI_VARIABLE_BOOT`, and `EV_EFI_VARIABLE_AUTHORITY` events. +#[derive(Debug, Clone)] +pub struct EfiVariableData { + /// The variable name (e.g., "PK", "KEK", "db", "vendor_db", "MokList"). + pub variable_name: String, + /// The raw variable data bytes. + #[allow(dead_code)] // Available for future signature extraction + pub variable_data: Vec, +} + +/// Parse a UEFI_VARIABLE_DATA structure from raw event data. +/// +/// Layout: +/// - VariableName GUID (16 bytes) +/// - UnicodeNameLength (u64, 8 bytes) — number of UTF-16 code units +/// - VariableDataLength (u64, 8 bytes) +/// - UnicodeName (UnicodeNameLength * 2 bytes, UTF-16LE) +/// - VariableData (VariableDataLength bytes) +pub fn parse_efi_variable_data(event_data: &[u8]) -> Option { + // Minimum: GUID(16) + name_len(8) + data_len(8) = 32 bytes + if event_data.len() < 32 { + return None; + } + + // Read UnicodeNameLength at offset 16 + let name_len_bytes: [u8; 8] = event_data[16..24].try_into().ok()?; + let name_len_raw = u64::from_le_bytes(name_len_bytes); + let name_len = usize::try_from(name_len_raw).ok()?; + + // Read VariableDataLength at offset 24 + let data_len_bytes: [u8; 8] = event_data[24..32].try_into().ok()?; + let data_len_raw = u64::from_le_bytes(data_len_bytes); + let data_len = usize::try_from(data_len_raw).ok()?; + + if name_len == 0 { + return None; + } + + let name_byte_len = name_len.checked_mul(2)?; + let name_start: usize = 32; + let name_end = name_start.checked_add(name_byte_len)?; + + if event_data.len() < name_end { + return None; + } + + // Decode UTF-16LE variable name + let name_bytes = &event_data[name_start..name_end]; + let u16_chars: Vec = name_bytes + .as_chunks::<2>() + .0 + .iter() + .map(|chunk| u16::from_le_bytes(*chunk)) + .collect(); + + let variable_name = String::from_utf16(&u16_chars) + .ok()? + .trim_end_matches('\0') + .to_string(); + + // Extract variable data + let data_start = name_end; + let data_end = + data_start.checked_add(data_len).unwrap_or(event_data.len()); + let variable_data = if event_data.len() >= data_end { + event_data[data_start..data_end].to_vec() + } else { + // Partial data — take what's available + event_data[data_start..].to_vec() + }; + + Some(EfiVariableData { + variable_name, + variable_data, + }) +} + +/// Parse EV_IPL event data as a string. +/// +/// Tries UTF-8 first, then UTF-16LE. Returns `None` if the data +/// cannot be decoded as either encoding. +pub fn parse_ipl_string(event_data: &[u8]) -> Option { + if event_data.is_empty() { + return None; + } + + // Try UTF-8 first (most common for GRUB/shim) + if let Ok(s) = std::str::from_utf8(event_data) { + let trimmed = s.trim_end_matches('\0').to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + + // Try UTF-16LE (less common, but some implementations use it) + if event_data.len() >= 2 && event_data.len().is_multiple_of(2) { + let u16_chars: Vec = event_data + .as_chunks::<2>() + .0 + .iter() + .map(|chunk| u16::from_le_bytes(*chunk)) + .collect(); + if let Ok(s) = String::from_utf16(&u16_chars) { + let trimmed = s.trim_end_matches('\0').to_string(); + if !trimmed.is_empty() { + return Some(trimmed); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: build a UEFI_VARIABLE_DATA byte buffer. + fn build_variable_data(name: &str, data: &[u8]) -> Vec { + let mut buf = Vec::new(); + // GUID (16 bytes) — use zeros + buf.extend_from_slice(&[0u8; 16]); + // UnicodeNameLength + let name_len = name.len() as u64; + buf.extend_from_slice(&name_len.to_le_bytes()); + // VariableDataLength + let data_len = data.len() as u64; + buf.extend_from_slice(&data_len.to_le_bytes()); + // UnicodeName in UTF-16LE + for c in name.chars() { + buf.push(c as u8); + buf.push(0); + } + // VariableData + buf.extend_from_slice(data); + buf + } + + #[test] + fn test_parse_efi_variable_data_pk() { + let data = build_variable_data("PK", &[0xAA, 0xBB]); + let parsed = parse_efi_variable_data(&data).unwrap(); //#[allow_ci] + assert_eq!(parsed.variable_name, "PK"); + assert_eq!(parsed.variable_data, vec![0xAA, 0xBB]); + } + + #[test] + fn test_parse_efi_variable_data_kek() { + let data = build_variable_data("KEK", &[0x01, 0x02, 0x03]); + let parsed = parse_efi_variable_data(&data).unwrap(); //#[allow_ci] + assert_eq!(parsed.variable_name, "KEK"); + assert_eq!(parsed.variable_data.len(), 3); + } + + #[test] + fn test_parse_efi_variable_data_vendor_db() { + let data = build_variable_data("vendor_db", &[0xFF; 32]); + let parsed = parse_efi_variable_data(&data).unwrap(); //#[allow_ci] + assert_eq!(parsed.variable_name, "vendor_db"); + assert_eq!(parsed.variable_data.len(), 32); + } + + #[test] + fn test_parse_efi_variable_data_moklist() { + let data = build_variable_data("MokList", &[0xDE, 0xAD]); + let parsed = parse_efi_variable_data(&data).unwrap(); //#[allow_ci] + assert_eq!(parsed.variable_name, "MokList"); + } + + #[test] + fn test_parse_efi_variable_data_too_short() { + let data = vec![0u8; 16]; // Too short + assert!(parse_efi_variable_data(&data).is_none()); + } + + #[test] + fn test_parse_efi_variable_data_empty_name() { + let mut data = Vec::new(); + data.extend_from_slice(&[0u8; 16]); // GUID + data.extend_from_slice(&0u64.to_le_bytes()); // name_len = 0 + data.extend_from_slice(&0u64.to_le_bytes()); // data_len = 0 + assert!(parse_efi_variable_data(&data).is_none()); + } + + #[test] + fn test_parse_ipl_string_utf8() { + let data = b"kernel_cmdline: root=/dev/sda1 ro"; + let result = parse_ipl_string(data); + assert_eq!( + result, + Some("kernel_cmdline: root=/dev/sda1 ro".to_string()) + ); + } + + #[test] + fn test_parse_ipl_string_utf8_with_null() { + let mut data = b"MokList".to_vec(); + data.push(0); + let result = parse_ipl_string(&data); + assert_eq!(result, Some("MokList".to_string())); + } + + #[test] + fn test_parse_ipl_string_utf16le() { + // Non-ASCII character that's invalid UTF-8 but valid UTF-16LE: + // U+00E9 (é) = 0xE9, 0x00 in UTF-16LE + let data = vec![0xE9, 0x00, 0x00, 0x00]; + let result = parse_ipl_string(&data); + assert_eq!(result, Some("\u{00E9}".to_string())); + } + + #[test] + fn test_parse_ipl_string_empty() { + let data: &[u8] = &[]; + assert!(parse_ipl_string(data).is_none()); + } +} diff --git a/keylimectl/src/policy_tools/validation.rs b/keylimectl/src/policy_tools/validation.rs new file mode 100644 index 000000000..3765d1957 --- /dev/null +++ b/keylimectl/src/policy_tools/validation.rs @@ -0,0 +1,579 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Policy validation for runtime, measured boot, and TPM policies. +//! +//! Provides structural and content validation for all policy types, +//! checking required fields, digest formats, and schema compatibility. + +use crate::policy_tools::measured_boot_policy::MeasuredBootPolicy; +use crate::policy_tools::runtime_policy::{ + RuntimePolicy, RUNTIME_POLICY_VERSION, +}; +use crate::policy_tools::tpm_policy::TpmPolicy; + +/// A single validation issue (error or warning). +#[derive(Debug, Clone)] +pub struct ValidationIssue { + /// A machine-readable code for the issue. + pub code: String, + /// A human-readable description. + pub message: String, +} + +/// Result of policy validation. +#[derive(Debug, Clone)] +pub struct ValidationResult { + /// Whether the policy is valid (no errors). + pub valid: bool, + /// Validation errors (policy is invalid if non-empty). + pub errors: Vec, + /// Validation warnings (policy is valid but may have issues). + pub warnings: Vec, +} + +impl ValidationResult { + fn new() -> Self { + Self { + valid: true, + errors: Vec::new(), + warnings: Vec::new(), + } + } + + fn add_error(&mut self, code: &str, message: String) { + self.valid = false; + self.errors.push(ValidationIssue { + code: code.to_string(), + message, + }); + } + + fn add_warning(&mut self, code: &str, message: String) { + self.warnings.push(ValidationIssue { + code: code.to_string(), + message, + }); + } +} + +/// Known hash algorithm names and their expected hex digest lengths. +const KNOWN_ALGORITHMS: &[(&str, usize)] = &[ + ("sha1", 40), + ("sha256", 64), + ("sha384", 96), + ("sha512", 128), + ("sm3_256", 64), +]; + +/// Validate a runtime policy. +pub fn validate_runtime_policy(policy: &RuntimePolicy) -> ValidationResult { + let mut result = ValidationResult::new(); + + // Check meta version + if policy.meta.version != RUNTIME_POLICY_VERSION { + result.add_warning( + "version_mismatch", + format!( + "Policy version {} does not match expected version {}", + policy.meta.version, RUNTIME_POLICY_VERSION + ), + ); + } + + // Check digest format: bare lowercase hex + for (path, digests) in &policy.digests { + if digests.is_empty() { + result.add_warning( + "empty_digests", + format!("Path '{path}' has no digests"), + ); + continue; + } + + for digest in digests { + validate_digest_string(digest, path, &mut result); + } + } + + // Check keyring digests + for (keyring, digests) in &policy.keyrings { + if keyring.is_empty() { + result.add_error( + "empty_keyring_name", + "Keyring name must not be empty".to_string(), + ); + } + for digest in digests { + validate_digest_string(digest, keyring, &mut result); + } + } + + // Check ima-buf digests + for (name, digests) in &policy.ima_buf { + if name.is_empty() { + result.add_error( + "empty_ima_buf_name", + "IMA-buf entry name must not be empty".to_string(), + ); + } + for digest in digests { + validate_digest_string(digest, name, &mut result); + } + } + + // Check exclude patterns are non-empty + for exclude in &policy.excludes { + if exclude.is_empty() { + result.add_error( + "empty_exclude", + "Exclude pattern must not be empty".to_string(), + ); + } + } + + // Check IMA config hash algorithm + let alg = &policy.ima.log_hash_alg; + if !KNOWN_ALGORITHMS.iter().any(|(name, _)| *name == alg) { + result.add_warning( + "unknown_hash_alg", + format!( + "IMA log hash algorithm '{alg}' is not a recognized algorithm" + ), + ); + } + + result +} + +/// Validate a measured boot policy. +pub fn validate_measured_boot_policy( + policy: &MeasuredBootPolicy, +) -> ValidationResult { + let mut result = ValidationResult::new(); + + // Check Secure Boot signature entries + for sig in &policy.pk { + if sig.signature_owner.is_empty() { + result.add_error( + "empty_pk_owner", + "PK signature owner must not be empty".to_string(), + ); + } + if sig.signature_data.is_empty() { + result.add_error( + "empty_pk_data", + "PK signature data must not be empty".to_string(), + ); + } + } + + for sig in &policy.kek { + if sig.signature_owner.is_empty() { + result.add_error( + "empty_kek_owner", + "KEK signature owner must not be empty".to_string(), + ); + } + } + + for sig in &policy.db { + if sig.signature_owner.is_empty() { + result.add_error( + "empty_db_owner", + "DB signature owner must not be empty".to_string(), + ); + } + } + + // Warn if no kernels are defined + if policy.kernels.is_empty() { + result.add_warning( + "no_kernels", + "No kernel boot chain entries defined".to_string(), + ); + } + + // Check kernel entries have at least one hash + for (i, kernel) in policy.kernels.iter().enumerate() { + if kernel.shim_authcode_sha256.is_none() + && kernel.grub_authcode_sha256.is_none() + && kernel.kernel_authcode_sha256.is_none() + && kernel.initrd_plain_sha256.is_none() + { + result.add_warning( + "kernel_no_hashes", + format!("Kernel entry {i} has no digest values"), + ); + } + } + + result +} + +/// Validate a TPM policy. +pub fn validate_tpm_policy(policy: &TpmPolicy) -> ValidationResult { + let mut result = ValidationResult::new(); + + // Validate mask format + match TpmPolicy::parse_mask(&policy.mask) { + Ok(indices) => { + // Check that each PCR index in the mask has a + // corresponding value + for idx in &indices { + let key = idx.to_string(); + if !policy.pcr_values.contains_key(&key) { + result.add_error( + "missing_pcr_value", + format!("PCR {idx} is set in mask but has no value"), + ); + } + } + + // Check for PCR values not in the mask + for key in policy.pcr_values.keys() { + if let Ok(idx) = key.parse::() { + if !indices.contains(&idx) { + result.add_warning( + "extra_pcr_value", + format!( + "PCR {idx} has a value but is not set in mask" + ), + ); + } + } else { + result.add_error( + "invalid_pcr_key", + format!("PCR key '{key}' is not a valid integer"), + ); + } + } + } + Err(e) => { + result + .add_error("invalid_mask", format!("Invalid PCR mask: {e}")); + } + } + + // Validate PCR values are valid hex + for (key, value) in &policy.pcr_values { + if value.is_empty() { + result.add_error( + "empty_pcr_value", + format!("PCR {key} value is empty"), + ); + } else if !value.chars().all(|c| c.is_ascii_hexdigit()) { + result.add_error( + "invalid_pcr_hex", + format!("PCR {key} value is not valid hex"), + ); + } + } + + result +} + +/// Try to auto-detect the policy type from a JSON value. +pub fn detect_policy_type(value: &serde_json::Value) -> Option<&'static str> { + if let Some(obj) = value.as_object() { + // Runtime policy: has "meta" and "digests" keys + if obj.contains_key("meta") && obj.contains_key("digests") { + return Some("runtime"); + } + // Measured boot: has "has_secureboot" key + if obj.contains_key("has_secureboot") { + return Some("measured-boot"); + } + // TPM policy: has "mask" key + if obj.contains_key("mask") { + return Some("tpm"); + } + } + None +} + +/// Validate a bare hex digest string. +/// +/// The verifier schema requires digests to match `^[0-9a-f]{40,128}$`: +/// lowercase hex characters only, length between 40 and 128. +fn validate_digest_string( + digest: &str, + context: &str, + result: &mut ValidationResult, +) { + if digest.is_empty() { + result.add_error( + "invalid_digest_format", + format!("Digest for '{context}' is empty"), + ); + return; + } + + if !digest + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + { + result.add_error( + "invalid_hex", + format!( + "Digest for '{context}' contains non-lowercase-hex characters: '{digest}'" + ), + ); + return; + } + + if digest.len() < 40 || digest.len() > 128 { + result.add_warning( + "digest_length_mismatch", + format!( + "Digest for '{context}' has length {} (expected 40-128): '{digest}'", + digest.len() + ), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy_tools::measured_boot_policy::{ + KernelEntry, SecureBootSignature, + }; + + #[test] + fn test_valid_runtime_policy() { + let mut policy = RuntimePolicy::new(); + policy.add_digest( + "/usr/bin/bash".to_string(), + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(), + ); + policy.add_exclude("/tmp/*".to_string()); + policy.set_log_hash_alg("sha256".to_string()); + + let result = validate_runtime_policy(&policy); + assert!(result.valid, "Errors: {:?}", result.errors); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_invalid_digest_format_non_hex() { + let mut policy = RuntimePolicy::new(); + policy.add_digest( + "/usr/bin/bash".to_string(), + "not_a_valid_hex_digest!".to_string(), + ); + + let result = validate_runtime_policy(&policy); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.code == "invalid_hex")); + } + + #[test] + fn test_invalid_digest_format_uppercase() { + let mut policy = RuntimePolicy::new(); + // Uppercase hex is not allowed by the verifier schema + policy.add_digest( + "/usr/bin/bash".to_string(), + "ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890".to_string(), + ); + + let result = validate_runtime_policy(&policy); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.code == "invalid_hex")); + } + + #[test] + fn test_empty_exclude_pattern() { + let mut policy = RuntimePolicy::new(); + policy.excludes.push(String::new()); + + let result = validate_runtime_policy(&policy); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.code == "empty_exclude")); + } + + #[test] + fn test_empty_keyring_name() { + let mut policy = RuntimePolicy::new(); + let _ = policy.keyrings.insert( + String::new(), + vec!["aabbccddeeff00112233aabbccddeeff0011223344".to_string()], + ); + + let result = validate_runtime_policy(&policy); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.code == "empty_keyring_name")); + } + + #[test] + fn test_version_mismatch_warning() { + let mut policy = RuntimePolicy::new(); + policy.meta.version = 99; + + let result = validate_runtime_policy(&policy); + assert!(result.valid); // warning, not error + assert!(result.warnings.iter().any(|e| e.code == "version_mismatch")); + } + + #[test] + fn test_unknown_hash_alg_warning() { + let mut policy = RuntimePolicy::new(); + policy.set_log_hash_alg("blake2b".to_string()); + + let result = validate_runtime_policy(&policy); + assert!(result.valid); // warning, not error + assert!(result.warnings.iter().any(|e| e.code == "unknown_hash_alg")); + } + + #[test] + fn test_digest_length_mismatch_warning() { + let mut policy = RuntimePolicy::new(); + // Verifier expects 40-128 hex chars, provide only 8 + policy + .add_digest("/usr/bin/test".to_string(), "aabbccdd".to_string()); + + let result = validate_runtime_policy(&policy); + assert!(result.valid); // warning, not error + assert!(result + .warnings + .iter() + .any(|e| e.code == "digest_length_mismatch")); + } + + #[test] + fn test_valid_tpm_policy() { + let policy = TpmPolicy::from_pcrs(&[ + (0, "aabbccdd".to_string()), + (7, "eeff0011".to_string()), + ]); + + let result = validate_tpm_policy(&policy); + assert!(result.valid, "Errors: {:?}", result.errors); + } + + #[test] + fn test_tpm_missing_pcr_value() { + let mut policy = TpmPolicy::new(); + policy.mask = "0x3".to_string(); // PCR 0 and 1 + let _ = policy + .pcr_values + .insert("0".to_string(), "aabb".to_string()); + // PCR 1 is in mask but has no value + + let result = validate_tpm_policy(&policy); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.code == "missing_pcr_value")); + } + + #[test] + fn test_tpm_invalid_mask() { + let mut policy = TpmPolicy::new(); + policy.mask = "invalid".to_string(); + + let result = validate_tpm_policy(&policy); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.code == "invalid_mask")); + } + + #[test] + fn test_tpm_invalid_pcr_hex() { + let policy = TpmPolicy::from_pcrs(&[(0, "not_hex!".to_string())]); + + let result = validate_tpm_policy(&policy); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.code == "invalid_pcr_hex")); + } + + #[test] + fn test_tpm_extra_pcr_value_warning() { + let mut policy = TpmPolicy::new(); + policy.mask = "0x1".to_string(); // Only PCR 0 + let _ = policy + .pcr_values + .insert("0".to_string(), "aabb".to_string()); + let _ = policy + .pcr_values + .insert("7".to_string(), "ccdd".to_string()); + + let result = validate_tpm_policy(&policy); + assert!(result.valid); // warning, not error + assert!(result.warnings.iter().any(|e| e.code == "extra_pcr_value")); + } + + #[test] + fn test_valid_measured_boot_policy() { + let mut policy = MeasuredBootPolicy::new(true); + policy.pk.push(SecureBootSignature { + signature_owner: "guid-1".to_string(), + signature_data: "0xaabb".to_string(), + }); + policy.kernels.push(KernelEntry { + shim_authcode_sha256: Some("0xshimhash".to_string()), + grub_authcode_sha256: None, + kernel_authcode_sha256: Some("0xkernhash".to_string()), + initrd_plain_sha256: None, + vmlinuz_plain_sha256: None, + kernel_cmdline: Some("root=/dev/sda1".to_string()), + }); + + let result = validate_measured_boot_policy(&policy); + assert!(result.valid, "Errors: {:?}", result.errors); + } + + #[test] + fn test_measured_boot_empty_pk_owner() { + let mut policy = MeasuredBootPolicy::new(true); + policy.pk.push(SecureBootSignature { + signature_owner: String::new(), + signature_data: "0xaabb".to_string(), + }); + + let result = validate_measured_boot_policy(&policy); + assert!(!result.valid); + assert!(result.errors.iter().any(|e| e.code == "empty_pk_owner")); + } + + #[test] + fn test_measured_boot_no_kernels_warning() { + let policy = MeasuredBootPolicy::new(true); + + let result = validate_measured_boot_policy(&policy); + assert!(result.valid); // warning, not error + assert!(result.warnings.iter().any(|e| e.code == "no_kernels")); + } + + #[test] + fn test_detect_policy_type_runtime() { + let val = serde_json::json!({ + "meta": {"version": 5}, + "digests": {} + }); + assert_eq!(detect_policy_type(&val), Some("runtime")); + } + + #[test] + fn test_detect_policy_type_measured_boot() { + let val = serde_json::json!({ + "has_secureboot": true, + "kernels": [] + }); + assert_eq!(detect_policy_type(&val), Some("measured-boot")); + } + + #[test] + fn test_detect_policy_type_tpm() { + let val = serde_json::json!({ + "mask": "0x87", + "0": "aabb" + }); + assert_eq!(detect_policy_type(&val), Some("tpm")); + } + + #[test] + fn test_detect_policy_type_unknown() { + let val = serde_json::json!({ + "unknown_field": true + }); + assert_eq!(detect_policy_type(&val), None); + } +} diff --git a/keylimectl/tests/info.rs b/keylimectl/tests/info.rs new file mode 100644 index 000000000..a1442ea1b --- /dev/null +++ b/keylimectl/tests/info.rs @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Integration tests for `keylimectl info` command. +//! +//! These tests exercise the info subcommands that do not require +//! network connectivity (i.e., `info` and `info tls`). + +#![allow(deprecated)] // cargo_bin deprecation — replacement API not yet stable + +use assert_cmd::Command; +use predicates::prelude::*; + +/// Create a command that runs from a temporary directory with clean env. +fn keylimectl_in_clean_dir(tmpdir: &tempfile::TempDir) -> Command { + let mut cmd = Command::cargo_bin("keylimectl").unwrap(); //#[allow_ci] + cmd.current_dir(tmpdir.path()); + // Point HOME to the temp dir so config search paths based on + // ~/.config/keylimectl/ won't find the user's real config files. + cmd.env("HOME", tmpdir.path()); + cmd.env_remove("XDG_CONFIG_HOME"); + // Isolate from system config files by restricting the search to + // a single path inside the tmpdir (same pattern as KEYLIME_AGENT_CONFIG). + cmd.env("KEYLIMECTL_CONFIG", tmpdir.path().join("keylimectl.toml")); + // Suppress env vars that might affect config loading + cmd.env_remove("KEYLIME_VERIFIER__IP"); + cmd.env_remove("KEYLIME_VERIFIER__PORT"); + cmd.env_remove("KEYLIME_REGISTRAR__IP"); + cmd.env_remove("KEYLIME_REGISTRAR__PORT"); + cmd.env_remove("KEYLIME_CLIENT__TIMEOUT"); + cmd +} + +#[test] +fn test_info_exits_successfully() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .arg("info") + .assert() + .success(); +} + +#[test] +fn test_info_json_output_valid() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir) + .arg("info") + .output() + .unwrap(); //#[allow_ci] + + let stdout = String::from_utf8_lossy(&output.stdout); + // Should be valid JSON + let parsed: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| { + panic!( //#[allow_ci] + "Expected valid JSON output, got error: {e}\nstdout: {stdout}" + ) + }); + + // Should have top-level keys + assert!( + parsed.get("keylimectl").is_some(), + "Expected 'keylimectl' key in JSON output" + ); + assert!( + parsed.get("config_files").is_some(), + "Expected 'config_files' key in JSON output" + ); + assert!( + parsed.get("effective_config").is_some(), + "Expected 'effective_config' key in JSON output" + ); +} + +#[test] +fn test_info_shows_version() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir) + .arg("info") + .output() + .unwrap(); //#[allow_ci] + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); //#[allow_ci] + + let version = &parsed["keylimectl"]["version"]; + assert!( + version.is_string(), + "Expected version string, got: {version}" + ); +} + +#[test] +fn test_info_shows_features() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir) + .arg("info") + .output() + .unwrap(); //#[allow_ci] + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); //#[allow_ci] + + let features = &parsed["keylimectl"]["features"]; + assert!( + features["api-v2"].is_boolean(), + "Expected api-v2 feature flag" + ); + assert!( + features["api-v3"].is_boolean(), + "Expected api-v3 feature flag" + ); +} + +#[test] +fn test_info_shows_default_config() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir) + .arg("info") + .output() + .unwrap(); //#[allow_ci] + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); //#[allow_ci] + + let effective = &parsed["effective_config"]; + + // Check default verifier config + assert_eq!( + effective["verifier"]["ip"]["value"], "127.0.0.1", + "Expected default verifier IP" + ); + assert_eq!( + effective["verifier"]["port"]["value"], 8881, + "Expected default verifier port" + ); + assert_eq!( + effective["verifier"]["ip"]["source"], "default", + "Expected 'default' source for verifier IP" + ); +} + +#[test] +fn test_info_config_files_searched() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir) + .arg("info") + .output() + .unwrap(); //#[allow_ci] + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); //#[allow_ci] + + let config_files = &parsed["config_files"]; + assert!( + config_files["loaded_from"].is_null(), + "Expected no config file loaded in clean dir" + ); + assert!( + config_files["searched"].is_array(), + "Expected searched paths array" + ); + let searched = config_files["searched"].as_array().unwrap(); //#[allow_ci] + assert!(!searched.is_empty(), "Expected non-empty searched paths"); +} + +#[test] +fn test_info_tls_exits_successfully() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["info", "tls"]) + .assert() + .success(); +} + +#[test] +fn test_info_tls_json_structure() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir) + .args(["info", "tls"]) + .output() + .unwrap(); //#[allow_ci] + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = + serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( //#[allow_ci] + "Expected valid JSON for info tls, got error: {e}\nstdout: {stdout}" + ) + }); + + assert!( + parsed.get("tls_config").is_some(), + "Expected 'tls_config' key" + ); + assert!( + parsed.get("certificates").is_some(), + "Expected 'certificates' key" + ); + assert!(parsed.get("issues").is_some(), "Expected 'issues' key"); + assert!( + parsed.get("suggestions").is_some(), + "Expected 'suggestions' key" + ); +} + +#[test] +fn test_info_tls_with_missing_certs() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Write a config that explicitly points to non-existent cert files + // so the test does not depend on host filesystem state (the CI image + // may have real certs at the default paths). + let config_path = tmpdir.path().join("keylimectl.toml"); + std::fs::write( + &config_path, + r#" +[tls] +client_cert = "/nonexistent/client.crt" +client_key = "/nonexistent/client.pem" +trusted_ca = ["/nonexistent/ca.crt"] +"#, + ) + .unwrap(); //#[allow_ci] + + let output = keylimectl_in_clean_dir(&tmpdir) + .args(["info", "tls"]) + .output() + .unwrap(); //#[allow_ci] + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); //#[allow_ci] + + let issues = parsed["issues"].as_array().unwrap(); //#[allow_ci] + assert!( + !issues.is_empty(), + "Expected issues when cert files don't exist" + ); +} + +#[test] +fn test_info_diag_alias() { + // "diag" is an alias for "info" + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .arg("diag") + .assert() + .success(); +} + +#[test] +fn test_help_shows_info_command() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("info")); +} diff --git a/keylimectl/tests/no_args.rs b/keylimectl/tests/no_args.rs new file mode 100644 index 000000000..5b5808cb8 --- /dev/null +++ b/keylimectl/tests/no_args.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Integration tests for keylimectl no-argument behavior. + +#![allow(deprecated)] // cargo_bin deprecation — replacement API not yet stable + +use assert_cmd::Command; +use predicates::prelude::*; + +/// Create a command that runs from a temporary directory where no config +/// files exist, ensuring predictable default behavior. +fn keylimectl_in_clean_dir(tmpdir: &tempfile::TempDir) -> Command { + let mut cmd = Command::cargo_bin("keylimectl").unwrap(); //#[allow_ci] + cmd.current_dir(tmpdir.path()); + // Point HOME to the temp dir so config search paths based on + // ~/.config/keylimectl/ won't find the user's real config files. + cmd.env("HOME", tmpdir.path()); + cmd.env("KEYLIMECTL_CONFIG", tmpdir.path().join("nonexistent.conf")); + cmd.env_remove("XDG_CONFIG_HOME"); + // Suppress env vars that might affect config loading + cmd.env_remove("KEYLIME_VERIFIER__IP"); + cmd.env_remove("KEYLIME_VERIFIER__PORT"); + cmd.env_remove("KEYLIME_REGISTRAR__IP"); + cmd.env_remove("KEYLIME_REGISTRAR__PORT"); + cmd +} + +#[test] +fn test_no_args_exits_successfully() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir).assert().success(); +} + +#[test] +fn test_no_args_shows_config_summary() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir).output().unwrap(); //#[allow_ci] + + let stderr = String::from_utf8_lossy(&output.stderr); + // Config summary goes to stderr + assert!( + stderr.contains("Verifier:"), + "Expected config summary with 'Verifier:' on stderr, got: {stderr}" + ); + assert!( + stderr.contains("Registrar:"), + "Expected config summary with 'Registrar:' on stderr, got: {stderr}" + ); + assert!( + stderr.contains("TLS:"), + "Expected config summary with 'TLS:' on stderr, got: {stderr}" + ); +} + +#[test] +fn test_no_args_shows_help_with_subcommands() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir).output().unwrap(); //#[allow_ci] + + let stdout = String::from_utf8_lossy(&output.stdout); + // clap help goes to stdout + assert!( + stdout.contains("Usage:"), + "Expected 'Usage:' in help output on stdout, got: {stdout}" + ); + // Dynamically generated subcommand list should include these + assert!( + stdout.contains("agent"), + "Expected 'agent' subcommand in help output, got: {stdout}" + ); + assert!( + stdout.contains("configure"), + "Expected 'configure' subcommand in help output, got: {stdout}" + ); +} + +#[test] +fn test_no_args_shows_default_config_values() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir).output().unwrap(); //#[allow_ci] + + let stderr = String::from_utf8_lossy(&output.stderr); + // Default configuration values + assert!( + stderr.contains("127.0.0.1:8881"), + "Expected default verifier address '127.0.0.1:8881', got: {stderr}" + ); + assert!( + stderr.contains("127.0.0.1:8891"), + "Expected default registrar address '127.0.0.1:8891', got: {stderr}" + ); + assert!( + stderr.contains("(defaults)"), + "Expected '(defaults)' since no config file exists, got: {stderr}" + ); +} + +#[test] +fn test_no_args_no_config_file_message() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let output = keylimectl_in_clean_dir(&tmpdir).output().unwrap(); //#[allow_ci] + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("No configuration file found"), + "Expected 'No configuration file found' message, got: {stderr}" + ); +} + +#[test] +fn test_help_flag_works() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Usage:")) + .stdout(predicate::str::contains("keylimectl")); +} + +#[test] +fn test_version_flag_works() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("keylimectl")); +} + +#[test] +fn test_configure_non_interactive() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let config_path = tmpdir.path().join(".keylimectl").join("config.toml"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "configure", + "--non-interactive", + "--scope", + "local", + "--verifier-ip", + "10.0.0.1", + "--verifier-port", + "9001", + ]) + .assert() + .success(); + + // Verify the config file was created + assert!( + config_path.exists(), + "Expected config file at {config_path:?}" + ); + + // Verify it contains expected values + let contents = std::fs::read_to_string(&config_path).unwrap(); //#[allow_ci] + assert!( + contents.contains("10.0.0.1"), + "Expected verifier IP in config file" + ); + assert!( + contents.contains("9001"), + "Expected verifier port in config file" + ); +} diff --git a/keylimectl/tests/policy_tools.rs b/keylimectl/tests/policy_tools.rs new file mode 100644 index 000000000..58a23b445 --- /dev/null +++ b/keylimectl/tests/policy_tools.rs @@ -0,0 +1,959 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Keylime Authors + +//! Integration tests for Phase 6 policy tools commands. +//! +//! Tests cover local policy generation, signing, validation, +//! and conversion subcommands that do not require network +//! connectivity. + +#![allow(deprecated)] // cargo_bin deprecation — replacement API not yet stable + +use assert_cmd::Command; +use predicates::prelude::*; +use std::io::Write; + +/// Create a command that runs from a temporary directory with clean env. +fn keylimectl_in_clean_dir(tmpdir: &tempfile::TempDir) -> Command { + let mut cmd = Command::cargo_bin("keylimectl").unwrap(); //#[allow_ci] + cmd.current_dir(tmpdir.path()); + // Point HOME to the temp dir so config search paths based on + // ~/.config/keylimectl/ won't find the user's real config files. + cmd.env("HOME", tmpdir.path()); + cmd.env_remove("XDG_CONFIG_HOME"); + cmd.env_remove("KEYLIME_VERIFIER__IP"); + cmd.env_remove("KEYLIME_VERIFIER__PORT"); + cmd.env_remove("KEYLIME_REGISTRAR__IP"); + cmd.env_remove("KEYLIME_REGISTRAR__PORT"); + cmd +} + +// ── Help output tests ──────────────────────────────────────── + +#[test] +fn test_policy_help_shows_generate() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["policy", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("generate")) + .stdout(predicate::str::contains("sign")) + .stdout(predicate::str::contains("validate")) + .stdout(predicate::str::contains("convert")); +} + +#[test] +fn test_policy_generate_runtime_help() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["policy", "generate", "runtime", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--ima-measurement-list")) + .stdout(predicate::str::contains("--allowlist")) + .stdout(predicate::str::contains("--rootfs")) + .stdout(predicate::str::contains("--output")); +} + +#[test] +fn test_policy_generate_measured_boot_help() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["policy", "generate", "measured-boot", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--eventlog-file")) + .stdout(predicate::str::contains("--without-secureboot")); +} + +#[test] +fn test_policy_generate_tpm_help() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["policy", "generate", "tpm", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--pcr-file")) + .stdout(predicate::str::contains("--pcrs")) + .stdout(predicate::str::contains("--mask")); +} + +#[test] +fn test_verify_help_shows_evidence() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["verify", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("evidence")); +} + +// ── Runtime policy generation ──────────────────────────────── + +#[test] +fn test_generate_runtime_from_ima_log() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a test IMA measurement list. + // Format: + let ima_path = tmpdir.path().join("ima_log.txt"); + let mut f = std::fs::File::create(&ima_path).unwrap(); //#[allow_ci] + writeln!( + f, + "10 0000000000000000000000000000000000000000 ima-ng sha256:a94cd382dd0a40c3312e6e89a4c7c39e22e0c4a3bcf83ce9f0fe52c8f1f /usr/bin/test1" + ) + .unwrap(); //#[allow_ci] + writeln!( + f, + "10 0000000000000000000000000000000000000000 ima-ng sha256:b94cd382dd0a40c3312e6e89a4c7c39e22e0c4a3bcf83ce9f0fe52c8f1f /usr/bin/test2" + ) + .unwrap(); //#[allow_ci] + + let output_path = tmpdir.path().join("runtime_policy.json"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "generate", + "runtime", + "--ima-measurement-list", + ima_path.to_str().unwrap(), //#[allow_ci] + "--output", + output_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + // Verify output file exists and is valid JSON + assert!(output_path.exists(), "Expected output file to exist"); + + let content = std::fs::read_to_string(&output_path).unwrap(); //#[allow_ci] + let policy: serde_json::Value = + serde_json::from_str(&content).unwrap_or_else(|e| { + panic!( //#[allow_ci] + "Expected valid JSON policy, got error: {e}\ncontent: {content}" + ) //#[allow_ci] + }); + + // Check policy structure + assert!( + policy.get("meta").is_some(), + "Expected 'meta' field in policy" + ); + assert!( + policy.get("digests").is_some(), + "Expected 'digests' field in policy" + ); + + // Verify digests contain our test files + let digests = policy["digests"].as_object().unwrap(); //#[allow_ci] + assert!( + digests.contains_key("/usr/bin/test1"), + "Expected /usr/bin/test1 in digests, got keys: {:?}", + digests.keys().collect::>() + ); + assert!( + digests.contains_key("/usr/bin/test2"), + "Expected /usr/bin/test2 in digests" + ); +} + +#[test] +fn test_generate_runtime_from_allowlist() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a flat-text allowlist + let allowlist_path = tmpdir.path().join("allowlist.txt"); + let mut f = std::fs::File::create(&allowlist_path).unwrap(); //#[allow_ci] + writeln!(f, "abc123def456 /usr/bin/allowed1").unwrap(); //#[allow_ci] + writeln!(f, "789012345678 /usr/bin/allowed2").unwrap(); //#[allow_ci] + + let output_path = tmpdir.path().join("runtime_policy.json"); + + // Only pass --allowlist, no --ima-measurement-list + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "generate", + "runtime", + "--allowlist", + allowlist_path.to_str().unwrap(), //#[allow_ci] + "--output", + output_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + let content = std::fs::read_to_string(&output_path).unwrap(); //#[allow_ci] + let policy: serde_json::Value = serde_json::from_str(&content).unwrap(); //#[allow_ci] + + let digests = policy["digests"].as_object().unwrap(); //#[allow_ci] + assert!( + digests.contains_key("/usr/bin/allowed1"), + "Expected /usr/bin/allowed1 in digests, got: {:?}", + digests.keys().collect::>() + ); +} + +#[test] +fn test_generate_runtime_to_stdout() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a minimal IMA log + let ima_path = tmpdir.path().join("ima_log.txt"); + let mut f = std::fs::File::create(&ima_path).unwrap(); //#[allow_ci] + writeln!( + f, + "10 0000000000000000000000000000000000000000 ima-ng sha256:a94cd382dd0a40c3312e6e89a4c7c39e22e0c4a3bcf83ce9f0fe52c8f1f /usr/bin/stdout_test" + ) + .unwrap(); //#[allow_ci] + + let output = keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "generate", + "runtime", + "--ima-measurement-list", + ima_path.to_str().unwrap(), //#[allow_ci] + ]) + .output() + .unwrap(); //#[allow_ci] + + assert!( + output.status.success(), + "Command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let policy: serde_json::Value = + serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( //#[allow_ci] + "Expected valid JSON on stdout, got error: {e}\nstdout: {stdout}" + ) //#[allow_ci] + }); + + assert!(policy.get("digests").is_some()); +} + +#[test] +fn test_generate_runtime_with_excludelist() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a minimal IMA log + let ima_path = tmpdir.path().join("ima_log.txt"); + let mut f = std::fs::File::create(&ima_path).unwrap(); //#[allow_ci] + writeln!( + f, + "10 0000000000000000000000000000000000000000 ima-ng sha256:a94cd382dd0a40c3312e6e89a4c7c39e22e0c4a3bcf83ce9f0fe52c8f1f /usr/bin/excl_test" + ) + .unwrap(); //#[allow_ci] + + // Create an exclude list + let exclude_path = tmpdir.path().join("excludelist.txt"); + let mut f = std::fs::File::create(&exclude_path).unwrap(); //#[allow_ci] + writeln!(f, "/tmp/.*").unwrap(); //#[allow_ci] + writeln!(f, "/var/log/.*").unwrap(); //#[allow_ci] + + let output_path = tmpdir.path().join("runtime_policy.json"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "generate", + "runtime", + "--ima-measurement-list", + ima_path.to_str().unwrap(), //#[allow_ci] + "--excludelist", + exclude_path.to_str().unwrap(), //#[allow_ci] + "--output", + output_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + let content = std::fs::read_to_string(&output_path).unwrap(); //#[allow_ci] + let policy: serde_json::Value = serde_json::from_str(&content).unwrap(); //#[allow_ci] + + let excludes = policy["excludes"].as_array().unwrap(); //#[allow_ci] + assert!( + excludes.len() >= 2, + "Expected at least 2 exclude patterns, got {}", + excludes.len() + ); +} + +// ── TPM policy generation ──────────────────────────────────── + +#[test] +fn test_generate_tpm_from_file() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a PCR values file + let pcr_path = tmpdir.path().join("pcr_values.txt"); + let mut f = std::fs::File::create(&pcr_path).unwrap(); //#[allow_ci] + writeln!(f, "0 aabbccddee").unwrap(); //#[allow_ci] + writeln!(f, "7 ff00112233").unwrap(); //#[allow_ci] + + let output_path = tmpdir.path().join("tpm_policy.json"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "generate", + "tpm", + "--pcr-file", + pcr_path.to_str().unwrap(), //#[allow_ci] + "--pcrs", + "0,7", + "--output", + output_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + let content = std::fs::read_to_string(&output_path).unwrap(); //#[allow_ci] + let policy: serde_json::Value = + serde_json::from_str(&content).unwrap_or_else(|e| { + panic!( //#[allow_ci] + "Expected valid JSON TPM policy, got error: {e}\ncontent: {content}" + ) //#[allow_ci] + }); + + assert!( + policy.get("mask").is_some(), + "Expected 'mask' field in TPM policy" + ); + + // TpmPolicy uses #[serde(flatten)] so PCR values are at the + // top level, not under a "pcr_values" key. + assert!( + policy.get("0").is_some(), + "Expected PCR '0' at top level, got: {policy}" + ); + assert!( + policy.get("7").is_some(), + "Expected PCR '7' at top level, got: {policy}" + ); +} + +#[test] +fn test_generate_tpm_to_stdout() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + let pcr_path = tmpdir.path().join("pcr_values.txt"); + let mut f = std::fs::File::create(&pcr_path).unwrap(); //#[allow_ci] + writeln!(f, "0 aabb").unwrap(); //#[allow_ci] + + let output = keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "generate", + "tpm", + "--pcr-file", + pcr_path.to_str().unwrap(), //#[allow_ci] + "--pcrs", + "0", + ]) + .output() + .unwrap(); //#[allow_ci] + + assert!( + output.status.success(), + "Command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + let policy: serde_json::Value = + serde_json::from_str(&stdout).unwrap_or_else(|e| { + panic!( //#[allow_ci] + "Expected valid JSON on stdout, got error: {e}\nstdout: {stdout}" + ) //#[allow_ci] + }); + + assert!(policy.get("mask").is_some()); +} + +#[test] +#[cfg(not(any(feature = "tpm-local", feature = "tpm-quote-validation")))] +fn test_generate_tpm_from_tpm_fails_without_feature() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["policy", "generate", "tpm", "--from-tpm", "--pcrs", "0,7"]) + .assert() + .failure(); +} + +// ── Policy validation ──────────────────────────────────────── + +#[test] +fn test_validate_valid_runtime_policy() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + let policy_path = tmpdir.path().join("valid_policy.json"); + let policy = serde_json::json!({ + "meta": { + "version": 1, + "generator": 0, + "timestamp": "2025-01-01T00:00:00Z" + }, + "release": 0, + "digests": { + "/usr/bin/test": ["aabbccddeeff00112233aabbccddeeff00112233"] + }, + "excludes": [], + "keyrings": {}, + "ima": { + "ignored_keyrings": [], + "log_hash_alg": "sha256" + } + }); + std::fs::write( + &policy_path, + serde_json::to_string_pretty(&policy).unwrap(), //#[allow_ci] + ) + .unwrap(); //#[allow_ci] + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "validate", + policy_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); +} + +#[test] +fn test_validate_invalid_runtime_policy() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Policy with invalid digest format + let policy_path = tmpdir.path().join("invalid_policy.json"); + let policy = serde_json::json!({ + "meta": { + "version": 5, + "generator": "keylimectl" + }, + "release": 0, + "digests": { + "/usr/bin/test": ["not_a_valid_digest"] + }, + "excludes": [] + }); + std::fs::write( + &policy_path, + serde_json::to_string_pretty(&policy).unwrap(), //#[allow_ci] + ) + .unwrap(); //#[allow_ci] + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "validate", + policy_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .failure(); +} + +#[test] +fn test_validate_tpm_policy() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // TpmPolicy uses #[serde(flatten)] so PCR values are at the + // top level alongside the mask — not nested under "pcr_values". + let policy_path = tmpdir.path().join("tpm_policy.json"); + let policy = serde_json::json!({ + "mask": "0x81", + "0": "aabbccdd", + "7": "eeff0011" + }); + std::fs::write( + &policy_path, + serde_json::to_string_pretty(&policy).unwrap(), //#[allow_ci] + ) + .unwrap(); //#[allow_ci] + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "validate", + policy_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); +} + +#[test] +fn test_validate_nonexistent_file() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["policy", "validate", "/nonexistent/file.json"]) + .assert() + .failure(); +} + +// ── Policy signing and verification ────────────────────────── + +#[test] +fn test_sign_and_verify_policy() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a simple policy file + let policy_path = tmpdir.path().join("policy.json"); + let policy = serde_json::json!({ + "meta": {"version": 1, "generator": 0}, + "release": 0, + "digests": {"/usr/bin/test": ["aabbccddeeff00112233aabbccddeeff00112233"]}, + "excludes": [] + }); + std::fs::write( + &policy_path, + serde_json::to_string_pretty(&policy).unwrap(), //#[allow_ci] + ) + .unwrap(); //#[allow_ci] + + let signed_path = tmpdir.path().join("signed_policy.json"); + let key_path = tmpdir.path().join("signing_key.pem"); + + // Sign the policy + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "sign", + policy_path.to_str().unwrap(), //#[allow_ci] + "--keypath", + key_path.to_str().unwrap(), //#[allow_ci] + "--output", + signed_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + assert!(signed_path.exists(), "Expected signed policy file to exist"); + assert!(key_path.exists(), "Expected generated key file to exist"); + + // Verify the signed policy file is valid JSON + let signed_content = std::fs::read_to_string(&signed_path).unwrap(); //#[allow_ci] + let signed: serde_json::Value = serde_json::from_str(&signed_content) + .unwrap_or_else(|e| { + panic!("Expected valid JSON signed envelope, got error: {e}") //#[allow_ci] + }); + + assert!( + signed.get("payload").is_some(), + "Expected 'payload' field in DSSE envelope" + ); + assert!( + signed.get("signatures").is_some(), + "Expected 'signatures' field in DSSE envelope" + ); + + // The public key is saved at .pub + let pub_key_path = format!( + "{}.pub", + key_path.to_str().unwrap() //#[allow_ci] + ); + assert!( + std::path::Path::new(&pub_key_path).exists(), + "Expected public key file at {pub_key_path}" + ); + + // Verify the signature using the public key + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "verify-signature", + signed_path.to_str().unwrap(), //#[allow_ci] + "--key", + &pub_key_path, + ]) + .assert() + .success(); +} + +#[test] +fn test_validate_signed_policy() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a valid runtime policy + let policy_path = tmpdir.path().join("policy.json"); + let policy = serde_json::json!({ + "meta": {"version": 1, "generator": 0}, + "release": 0, + "digests": {"/usr/bin/test": ["aabbccddeeff00112233aabbccddeeff00112233"]}, + "excludes": [], + "keyrings": {}, + "ima": { + "ignored_keyrings": [], + "log_hash_alg": "sha256" + } + }); + std::fs::write( + &policy_path, + serde_json::to_string_pretty(&policy).unwrap(), //#[allow_ci] + ) + .unwrap(); //#[allow_ci] + + // Sign it + let signed_path = tmpdir.path().join("signed_policy.json"); + let key_path = tmpdir.path().join("signing_key.pem"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "sign", + policy_path.to_str().unwrap(), //#[allow_ci] + "--keypath", + key_path.to_str().unwrap(), //#[allow_ci] + "--output", + signed_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + // Validate the signed policy (DSSE envelope) + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "validate", + signed_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); +} + +#[test] +fn test_sign_with_x509_backend() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + let policy_path = tmpdir.path().join("policy.json"); + let policy = serde_json::json!({ + "meta": {"version": 5, "generator": "keylimectl"}, + "release": 0, + "digests": {}, + "excludes": [] + }); + std::fs::write( + &policy_path, + serde_json::to_string_pretty(&policy).unwrap(), //#[allow_ci] + ) + .unwrap(); //#[allow_ci] + + let signed_path = tmpdir.path().join("signed_x509.json"); + let key_path = tmpdir.path().join("x509_key.pem"); + let cert_path = tmpdir.path().join("cert.pem"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "sign", + policy_path.to_str().unwrap(), //#[allow_ci] + "--backend", + "x509", + "--keypath", + key_path.to_str().unwrap(), //#[allow_ci] + "--cert-outfile", + cert_path.to_str().unwrap(), //#[allow_ci] + "--output", + signed_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + assert!(signed_path.exists(), "Expected signed policy file"); + assert!(cert_path.exists(), "Expected X.509 certificate file"); +} + +// ── Policy conversion ──────────────────────────────────────── + +#[test] +fn test_convert_flat_allowlist() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Create a flat-text allowlist + let allowlist_path = tmpdir.path().join("allowlist.txt"); + let mut f = std::fs::File::create(&allowlist_path).unwrap(); //#[allow_ci] + writeln!(f, "abc123 /usr/bin/file1").unwrap(); //#[allow_ci] + writeln!(f, "def456 /usr/bin/file2").unwrap(); //#[allow_ci] + + let output_path = tmpdir.path().join("converted.json"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "convert", + allowlist_path.to_str().unwrap(), //#[allow_ci] + "--output", + output_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + assert!(output_path.exists(), "Expected converted policy file"); + + let content = std::fs::read_to_string(&output_path).unwrap(); //#[allow_ci] + let policy: serde_json::Value = serde_json::from_str(&content) + .unwrap_or_else(|e| { + panic!("Expected valid JSON, got error: {e}\ncontent: {content}") //#[allow_ci] + }); + + assert!( + policy.get("digests").is_some(), + "Expected 'digests' field in converted policy" + ); +} + +#[test] +fn test_convert_json_allowlist() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + let allowlist_path = tmpdir.path().join("allowlist.json"); + let allowlist = serde_json::json!({ + "hashes": { + "/usr/bin/app": ["sha256:aabbccdd"] + } + }); + std::fs::write( + &allowlist_path, + serde_json::to_string_pretty(&allowlist).unwrap(), //#[allow_ci] + ) + .unwrap(); //#[allow_ci] + + let output_path = tmpdir.path().join("converted.json"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "convert", + allowlist_path.to_str().unwrap(), //#[allow_ci] + "--output", + output_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + let content = std::fs::read_to_string(&output_path).unwrap(); //#[allow_ci] + let policy: serde_json::Value = serde_json::from_str(&content).unwrap(); //#[allow_ci] + + let digests = policy["digests"].as_object().unwrap(); //#[allow_ci] + assert!( + digests.contains_key("/usr/bin/app"), + "Expected /usr/bin/app in converted digests" + ); +} + +#[test] +fn test_convert_with_excludelist() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + let allowlist_path = tmpdir.path().join("allowlist.txt"); + let mut f = std::fs::File::create(&allowlist_path).unwrap(); //#[allow_ci] + writeln!(f, "abc123 /usr/bin/file1").unwrap(); //#[allow_ci] + + let exclude_path = tmpdir.path().join("excludelist.txt"); + let mut f = std::fs::File::create(&exclude_path).unwrap(); //#[allow_ci] + writeln!(f, "/tmp/.*").unwrap(); //#[allow_ci] + + let output_path = tmpdir.path().join("converted.json"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "convert", + allowlist_path.to_str().unwrap(), //#[allow_ci] + "--output", + output_path.to_str().unwrap(), //#[allow_ci] + "--excludelist", + exclude_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + let content = std::fs::read_to_string(&output_path).unwrap(); //#[allow_ci] + let policy: serde_json::Value = serde_json::from_str(&content).unwrap(); //#[allow_ci] + + let excludes = policy["excludes"].as_array().unwrap(); //#[allow_ci] + assert!( + !excludes.is_empty(), + "Expected exclude patterns in converted policy" + ); +} + +#[test] +fn test_convert_requires_output() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + let allowlist_path = tmpdir.path().join("allowlist.txt"); + std::fs::write(&allowlist_path, "abc123 /usr/bin/file1\n").unwrap(); //#[allow_ci] + + // Should fail because --output is required for convert + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "convert", + allowlist_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .failure(); +} + +// ── End-to-end: generate, validate, sign, verify ───────────── + +#[test] +fn test_generate_validate_sign_verify_pipeline() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + + // Step 1: Generate a runtime policy from an IMA log + let ima_path = tmpdir.path().join("ima_log.txt"); + let mut f = std::fs::File::create(&ima_path).unwrap(); //#[allow_ci] + writeln!( + f, + "10 0000000000000000000000000000000000000000 ima-ng sha256:a94cd382dd0a40c3312e6e89a4c7c39e22e0c4a3bcf83ce9f0fe52c8f1f /usr/bin/pipeline_test" + ) + .unwrap(); //#[allow_ci] + + let policy_path = tmpdir.path().join("policy.json"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "generate", + "runtime", + "--ima-measurement-list", + ima_path.to_str().unwrap(), //#[allow_ci] + "--output", + policy_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + // Step 2: Validate the generated policy + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "validate", + policy_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + // Step 3: Sign the policy + let signed_path = tmpdir.path().join("signed_policy.json"); + let key_path = tmpdir.path().join("signing_key.pem"); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "sign", + policy_path.to_str().unwrap(), //#[allow_ci] + "--keypath", + key_path.to_str().unwrap(), //#[allow_ci] + "--output", + signed_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + // Step 4: Verify the signature using the public key + let pub_key_path = format!( + "{}.pub", + key_path.to_str().unwrap() //#[allow_ci] + ); + + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "verify-signature", + signed_path.to_str().unwrap(), //#[allow_ci] + "--key", + &pub_key_path, + ]) + .assert() + .success(); +} + +// ── Phase 6b: Privileged operations tests ──────────────────── + +#[test] +fn test_generate_runtime_help_shows_ramdisk_dir() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["policy", "generate", "runtime", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--ramdisk-dir")); +} + +#[cfg(feature = "rpm-repo")] +#[test] +fn test_generate_runtime_help_shows_rpm_options() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["policy", "generate", "runtime", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--local-rpm-repo")) + .stdout(predicate::str::contains("--remote-rpm-repo")); +} + +#[test] +fn test_generate_runtime_ramdisk_nonexistent() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "generate", + "runtime", + "--ramdisk-dir", + "/nonexistent/ramdisk/dir", + ]) + .assert() + .failure(); +} + +#[cfg(any(feature = "tpm-local", feature = "tpm-quote-validation"))] +#[test] +fn test_generate_tpm_help_shows_from_tpm() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + keylimectl_in_clean_dir(&tmpdir) + .args(["policy", "generate", "tpm", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--from-tpm")); +} + +#[test] +fn test_generate_runtime_ramdisk_empty_dir() { + let tmpdir = tempfile::tempdir().unwrap(); //#[allow_ci] + let ramdisk_dir = tmpdir.path().join("empty_ramdisk"); + std::fs::create_dir(&ramdisk_dir).unwrap(); //#[allow_ci] + + let output_path = tmpdir.path().join("policy.json"); + + // An empty ramdisk dir should succeed + // (just produces no initrd digests) + keylimectl_in_clean_dir(&tmpdir) + .args([ + "policy", + "generate", + "runtime", + "--ramdisk-dir", + ramdisk_dir.to_str().unwrap(), //#[allow_ci] + "--output", + output_path.to_str().unwrap(), //#[allow_ci] + ]) + .assert() + .success(); + + let content = std::fs::read_to_string(&output_path).unwrap(); //#[allow_ci] + let policy: serde_json::Value = serde_json::from_str(&content).unwrap(); //#[allow_ci] + + assert!( + policy.get("digests").is_some(), + "Expected 'digests' field in policy" + ); +} diff --git a/rpm/centos/keylime-agent-rust.spec b/rpm/centos/keylime-agent-rust.spec index 976980f3b..3de2cf604 100644 --- a/rpm/centos/keylime-agent-rust.spec +++ b/rpm/centos/keylime-agent-rust.spec @@ -66,6 +66,7 @@ BuildRequires: systemd BuildRequires: openssl-devel BuildRequires: tpm2-tss-devel BuildRequires: clang +BuildRequires: rpm-devel BuildRequires: rust-toolset # Requires common files from exact same release @@ -142,6 +143,18 @@ The Keylime IMA emulator for testing with emulated TPM #=============================================================================== +%package -n keylimectl +Summary: Command-line tool for Keylime remote attestation +License: (Apache-2.0 OR MIT) AND BSD-3-Clause AND (MIT OR Apache-2.0) AND Unicode-DFS-2016 AND (Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT) AND (Apache-2.0 OR BSL-1.0) AND (Apache-2.0 OR MIT) AND (Apache-2.0 OR MIT OR Zlib) AND Apache-2.0 WITH LLVM-exception AND ISC AND MIT AND (MIT OR Unlicense) +Requires: openssl + +%description -n keylimectl +keylimectl is a command-line tool for managing Keylime remote +attestation: adding/removing agents, generating and validating +attestation policies, and querying system status. + +#=============================================================================== + %prep %autosetup -n rust-keylime-%{version} -N %{?bundled_rust_deps:-a1} %autopatch -M 99 -p1 @@ -203,6 +216,12 @@ install -Dpm 0755 \ install -Dpm 0755 \ -t %{buildroot}%{_bindir} \ ./target/release/keylime_push_model_agent +install -Dpm 0755 \ + -t %{buildroot}%{_bindir} \ + ./target/release/keylimectl +install -Dpm 0644 \ + keylimectl/keylimectl.conf \ + %{buildroot}%{_sysconfdir}/keylime/keylimectl.conf %posttrans chmod 500 %{_sysconfdir}/keylime/agent.conf.d @@ -255,6 +274,14 @@ chown -R keylime:keylime %{_sysconfdir}/keylime %endif %{_bindir}/keylime_ima_emulator +%files -n keylimectl +%license LICENSE.dependencies +%if 0%{?bundled_rust_deps} +%license cargo-vendor.txt +%endif +%{_bindir}/keylimectl +%config(noreplace) %{_sysconfdir}/keylime/keylimectl.conf + %if %{with check} %check %cargo_test diff --git a/rpm/fedora/keylime-agent-rust.spec b/rpm/fedora/keylime-agent-rust.spec index 30f29aaad..204cae84b 100644 --- a/rpm/fedora/keylime-agent-rust.spec +++ b/rpm/fedora/keylime-agent-rust.spec @@ -67,6 +67,7 @@ BuildRequires: systemd BuildRequires: openssl-devel BuildRequires: tpm2-tss-devel BuildRequires: clang +BuildRequires: rpm-devel BuildRequires: rust-packaging >= 21-2 # Requires common files from exact same release @@ -143,6 +144,18 @@ The Keylime IMA emulator for testing with emulated TPM #=============================================================================== +%package -n keylimectl +Summary: Command-line tool for Keylime remote attestation +License: (Apache-2.0 OR MIT) AND BSD-3-Clause AND (MIT OR Apache-2.0) AND Unicode-DFS-2016 AND (Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT) AND (Apache-2.0 OR BSL-1.0) AND (Apache-2.0 OR MIT) AND (Apache-2.0 OR MIT OR Zlib) AND Apache-2.0 WITH LLVM-exception AND ISC AND MIT AND (MIT OR Unlicense) +Requires: openssl + +%description -n keylimectl +keylimectl is a command-line tool for managing Keylime remote +attestation: adding/removing agents, generating and validating +attestation policies, and querying system status. + +#=============================================================================== + %prep %autosetup -n rust-keylime-%{version} -N %{?bundled_rust_deps:-a1} %autopatch -M 99 -p1 @@ -202,6 +215,12 @@ install -Dpm 0755 \ install -Dpm 0755 \ -t %{buildroot}%{_bindir} \ ./target/release/keylime_push_model_agent +install -Dpm 0755 \ + -t %{buildroot}%{_bindir} \ + ./target/release/keylimectl +install -Dpm 0644 \ + keylimectl/keylimectl.conf \ + %{buildroot}%{_sysconfdir}/keylime/keylimectl.conf %posttrans chmod 500 %{_sysconfdir}/keylime/agent.conf.d @@ -254,6 +273,14 @@ chown -R keylime:keylime %{_sysconfdir}/keylime %endif %{_bindir}/keylime_ima_emulator +%files -n keylimectl +%license LICENSE.dependencies +%if 0%{?bundled_rust_deps} +%license cargo-vendor.txt +%endif +%{_bindir}/keylimectl +%config(noreplace) %{_sysconfdir}/keylime/keylimectl.conf + %if %{with check} %check %cargo_test diff --git a/tests/nopanic.ci b/tests/nopanic.ci index b0bae3f7b..0b817d329 100755 --- a/tests/nopanic.ci +++ b/tests/nopanic.ci @@ -10,7 +10,7 @@ import pathlib banned = ["unwrap(", "panic!("] -toplevel = ["keylime", "keylime-agent", "keylime-ima-emulator"] +toplevel = ["keylime", "keylime-agent", "keylime-ima-emulator", "keylimectl"] srcs = []