diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92578c506..f9b9a9126 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -289,6 +289,14 @@ jobs: env: AWS_LC_SYS_NO_ASM: true + - name: Root-only tests + if: ${{ matrix.os == 'linux' }} + # Cargo builds as the runner user and runs only the test binary as root, so no build output is owned by root. + # UNTESTED: the test binary runs as root. + # A root-only test run without root skips and passes, so after changing this step, check that its log has no SKIP line. + run: | + cargo test --locked --target ${{ matrix.target }} --config "target.${{ matrix.target }}.runner='sudo'" -p device-key --lib root_only_ + - name: Show sccache stats if: ${{ needs.preflight.outputs.sccache == 'true' && !cancelled() }} shell: pwsh @@ -1293,6 +1301,28 @@ jobs: run: dotnet test utils/dotnet/GatewayUtils.sln shell: pwsh + - name: Build agent channel package + run: ./crates/agent-channel-proto/dotnet/build.ps1 + shell: pwsh + + agent-identity-vectors: + name: Agent Identity test vectors + runs-on: ubuntu-latest + needs: [preflight] + + steps: + - name: Checkout ${{ github.repository }} + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + + # The vectors are recomputed with an independent library, so that an error in them can't pass into every implementation that agrees with them. + - name: Verify the vectors + run: | + python3 -m venv "$RUNNER_TEMP/vectors" + "$RUNNER_TEMP/vectors/bin/pip" install --quiet --disable-pip-version-check cryptography==50.0.2 + "$RUNNER_TEMP/vectors/bin/python" -I docs/agent-identity/verify_vectors.py + "$RUNNER_TEMP/vectors/bin/python" -I docs/agent-identity/verify_vectors.py --self-test agent-installer-event-log-tests: name: Agent installer Event Log lifecycle tests runs-on: windows-2022 @@ -1569,7 +1599,7 @@ jobs: success: name: Success if: ${{ always() }} - needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, oracle-markers, oracle-markers-tests, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, gateway-service-account-tests, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, agent-installer-event-log-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier] + needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, oracle-markers, oracle-markers-tests, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, gateway-service-account-tests, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, agent-identity-vectors, agent-installer-event-log-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier] runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish-libraries.yml b/.github/workflows/publish-libraries.yml index e7206968d..2faa724fd 100644 --- a/.github/workflows/publish-libraries.yml +++ b/.github/workflows/publish-libraries.yml @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - library: [ dotnet-client, dotnet-subscriber, utils, pedm-dotnet-client ] + library: [ dotnet-client, dotnet-subscriber, utils, pedm-dotnet-client, agent-channel ] include: - library: dotnet-client libpath: ./devolutions-gateway/openapi/dotnet-client @@ -50,6 +50,8 @@ jobs: libpath: ./utils/dotnet - library: pedm-dotnet-client libpath: ./crates/devolutions-pedm/openapi/dotnet-client + - library: agent-channel + libpath: ./crates/agent-channel-proto/dotnet steps: - name: Check out ${{ github.repository }} diff --git a/Cargo.lock b/Cargo.lock index 7c9a28980..a261720c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,176 @@ dependencies = [ "const-oid 0.10.2", ] +[[package]] +name = "agent-channel-client" +version = "0.0.0" +dependencies = [ + "agent-channel-proto", + "agent-identity-httpsig", + "anyhow", + "base64 0.22.1", + "device-key", + "http 1.4.2", + "http-client-proxy", + "hyper 1.10.1", + "hyper-util", + "p256 0.14.0-rc.14", + "percent-encoding", + "rand 0.10.2", + "rcgen", + "rstest", + "rustls 0.23.43", + "rustls-native-certs", + "serde_json", + "signature 3.0.0", + "time", + "tokio 1.52.3", + "tokio-rustls", + "tokio-stream", + "tonic 0.12.3", + "tower 0.5.3", + "url", + "uuid", +] + +[[package]] +name = "agent-channel-proto" +version = "0.0.0" +dependencies = [ + "prost 0.13.5", + "prost-build", + "prost-types", + "protoc-bin-vendored", + "tonic 0.12.3", + "tonic-build", +] + +[[package]] +name = "agent-identity" +version = "0.0.0" +dependencies = [ + "agent-identity-httpsig", + "anyhow", + "base64 0.22.1", + "camino", + "der 0.8.1", + "device-key", + "hex", + "hostname 0.4.2", + "http 1.4.2", + "http-client-proxy", + "httpdate", + "libc", + "p256 0.14.0-rc.14", + "reqwest", + "rstest", + "rustls-pemfile 2.2.0", + "serde", + "serde_json", + "sha2 0.11.0", + "signature 3.0.0", + "spki 0.8.0", + "sysinfo", + "time", + "tokio 1.52.3", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "win-api-wrappers", + "windows 0.61.3", + "x509-cert 0.3.0", + "zeroize", +] + +[[package]] +name = "agent-identity-conformance" +version = "0.0.0" +dependencies = [ + "agent-channel-proto", + "anyhow", + "base64 0.22.1", + "der 0.8.1", + "futures", + "http 1.4.2", + "libc", + "p256 0.14.0-rc.14", + "p384 0.14.0-rc.14", + "prost 0.13.5", + "rand 0.10.2", + "rcgen", + "reqwest", + "rustls 0.23.43", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "time", + "tokio 1.52.3", + "tokio-stream", + "tonic 0.12.3", + "tower 0.5.3", + "uuid", + "windows 0.61.3", + "x509-cert 0.3.0", +] + +[[package]] +name = "agent-identity-httpsig" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "device-key", + "http 1.4.2", + "httpsig", + "p256 0.14.0-rc.14", + "rand 0.10.2", + "serde_json", + "sfv", + "sha2 0.11.0", + "signature 3.0.0", + "tokio 1.52.3", + "tower 0.5.3", + "url", +] + +[[package]] +name = "agent-identity-mock" +version = "0.0.0" +dependencies = [ + "agent-channel-proto", + "anyhow", + "axum 0.8.9", + "base64 0.22.1", + "der 0.8.1", + "futures", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "hyper 1.10.1", + "hyper-util", + "p256 0.14.0-rc.14", + "prost 0.13.5", + "prost-types", + "rand 0.10.2", + "rcgen", + "rstest", + "serde", + "serde_json", + "sha2 0.11.0", + "spki 0.8.0", + "time", + "tokio 1.52.3", + "tokio-rustls", + "tokio-stream", + "tonic 0.12.3", + "tower 0.5.3", + "tracing", + "uuid", + "x509-cert 0.3.0", +] + [[package]] name = "agent-policy-tester" version = "0.0.0" @@ -1436,6 +1606,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -1491,7 +1673,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "rand_core 0.10.1", ] @@ -1738,10 +1920,33 @@ dependencies = [ "cipher 0.5.2", ] +[[package]] +name = "device-key" +version = "0.0.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "camino", + "libc", + "p256 0.14.0-rc.14", + "rand 0.10.2", + "secure-memory", + "serde_json", + "sha2 0.11.0", + "signature 3.0.0", + "uuid", + "win-api-wrappers", + "windows 0.61.3", + "zeroize", +] + [[package]] name = "devolutions-agent" version = "2026.3.3" dependencies = [ + "agent-channel-client", + "agent-identity", + "agent-sysevent-codes", "agent-tunnel-proto", "anyhow", "async-trait", @@ -1752,6 +1957,7 @@ dependencies = [ "camino", "ceviche", "ctrlc", + "device-key", "devolutions-agent-shared", "devolutions-gateway-task", "devolutions-log", @@ -1775,12 +1981,16 @@ dependencies = [ "rand 0.8.7", "rcgen", "reqwest", + "rstest", "rustls 0.23.43", "rustls-pemfile 2.2.0", "rustls-pki-types", "serde", "serde_json", "sha2 0.10.9", + "sysevent", + "sysevent-syslog", + "sysevent-winevent", "tap", "tempfile", "thiserror 2.0.20", @@ -1796,6 +2006,7 @@ dependencies = [ "win-api-wrappers", "windows 0.61.3", "x509-parser", + "zeroize", ] [[package]] @@ -2130,6 +2341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2302,6 +2514,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", +] + [[package]] name = "ecdsa" version = "0.17.0-rc.22" @@ -2310,9 +2535,9 @@ checksum = "b7c72d1455753a703ad4b90ed2a759f2bc4562024a303176439cf6e593b5ade4" dependencies = [ "der 0.8.1", "digest 0.11.3", - "elliptic-curve", - "rfc6979", - "signature", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0-pre.0", + "signature 3.0.0", "spki 0.8.0", "zeroize", ] @@ -2323,7 +2548,16 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "signature", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-compact" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1454db4f2edb7f0e8fe0c5b375b0c978fc63244cc9f010d160e417eb10139aa8" +dependencies = [ + "getrandom 0.4.3", ] [[package]] @@ -2346,6 +2580,24 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "elliptic-curve" version = "0.14.1" @@ -2353,17 +2605,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ "base16ct 1.0.0", - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", "digest 0.11.3", - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hkdf", "hybrid-array", "pem-rfc7468 1.0.0", - "pkcs8", + "pkcs8 0.11.0", "rand_core 0.10.1", - "sec1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -2473,6 +2725,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ff" version = "0.14.0" @@ -2744,6 +3006,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2802,13 +3065,24 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "group" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ - "ff", + "ff 0.14.0", "rand_core 0.10.1", "subtle", ] @@ -3088,6 +3362,7 @@ dependencies = [ "rstest", "tracing", "url", + "winreg", ] [[package]] @@ -3114,6 +3389,31 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "httpsig" +version = "0.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5c2b530de2c85aa07eafdf40489cc714eae4668948ebe92cdcb81e38e176315" +dependencies = [ + "base64 0.22.1", + "bytes 1.12.1", + "ecdsa 0.16.9", + "ed25519-compact", + "hmac 0.12.1", + "indexmap 2.14.0", + "p256 0.13.2", + "p384 0.13.1", + "pkcs8 0.10.2", + "rand 0.10.2", + "rustc-hash 2.1.3", + "sec1 0.7.3", + "sfv", + "sha2 0.10.9", + "spki 0.7.3", + "thiserror 2.0.20", + "tracing", +] + [[package]] name = "humantime" version = "2.4.0" @@ -3191,6 +3491,7 @@ dependencies = [ "tokio 1.52.3", "tokio-rustls", "tower-service", + "webpki-roots 1.0.10", ] [[package]] @@ -5177,30 +5478,54 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p256" version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c855a8d2ffd346aa03122626f22e96e3aa75e3bfe64e6bf6cb82f71821ed6ae7" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.17.0-rc.22", + "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0-rc.14", "sha2 0.11.0", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p384" version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62941b68907ddf996ac20f0debf700c236ccc3d874637731a93c631129ca042f" dependencies = [ - "ecdsa", - "elliptic-curve", + "ecdsa 0.17.0-rc.22", + "elliptic-curve 0.14.1", "fiat-crypto", "primefield", - "primeorder", + "primeorder 0.14.0-rc.14", "sha2 0.11.0", ] @@ -5211,10 +5536,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd6f2fe6e76c8d5e8828e92aafa463777d1e72e70b78acc724214757e92479a" dependencies = [ "base16ct 1.0.0", - "ecdsa", - "elliptic-curve", + "ecdsa 0.17.0-rc.22", + "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0-rc.14", "sha2 0.11.0", ] @@ -5406,28 +5731,28 @@ dependencies = [ "aes-kw", "base64 0.22.1", "cbc 0.2.1", - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", "ctr", "curve25519-dalek", "des", "digest 0.11.3", - "ecdsa", + "ecdsa 0.17.0-rc.22", "ed25519-dalek", "hex", "hmac 0.13.0", "http 1.4.2", "inout 0.2.2", "md-5 0.11.0", - "p256", - "p384", + "p256 0.14.0-rc.14", + "p384 0.14.0-rc.14", "p521", "pbkdf2", "picky-asn1", "picky-asn1-der", "picky-asn1-x509", "pkcs1 0.8.0-rc.4", - "primeorder", + "primeorder 0.14.0-rc.14", "rand 0.10.2", "rand_core 0.10.1", "rc2", @@ -5477,7 +5802,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "859d4117bd1b1dc5646359ee7243c50c5000c0920ea2d1fb120335a2f4c684b8" dependencies = [ "base64 0.22.1", - "crypto-bigint", + "crypto-bigint 0.7.5", "oid", "picky-asn1", "picky-asn1-der", @@ -5497,7 +5822,7 @@ dependencies = [ "byteorder", "cbc 0.2.1", "cipher 0.5.2", - "crypto-bigint", + "crypto-bigint 0.7.5", "des", "hmac 0.13.0", "inout 0.2.2", @@ -5592,6 +5917,16 @@ dependencies = [ "spki 0.8.0", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" @@ -5804,21 +6139,30 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-common 0.2.2", - "ff", + "ff 0.14.0", "rand_core 0.10.1", "subtle", "zeroize", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + [[package]] name = "primeorder" version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e56e6d67fdf5744e9e245ae571450fe584b91f5af261d0e40163b618e53a1f6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.14.1", "once_cell", "primefield", "serdect", @@ -6501,6 +6845,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.10", ] [[package]] @@ -6519,6 +6864,16 @@ dependencies = [ "slice-pool2", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfc6979" version = "0.6.0-pre.0" @@ -6565,13 +6920,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ "const-oid 0.10.2", - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-primes", "digest 0.11.3", "pkcs1 0.8.0-rc.4", - "pkcs8", + "pkcs8 0.11.0", "rand_core 0.10.1", - "signature", + "signature 3.0.0", "spki 0.8.0", "zeroize", ] @@ -6966,6 +7321,19 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d25679a62f678e7485f21abdea76f91a15322a0fe51efea791fd9d124f1c473c" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "subtle", + "zeroize", +] + [[package]] name = "sec1" version = "0.8.1" @@ -7192,6 +7560,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sfv" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d471eaefb14f4b30032525bdb124b36e55ba9cb1292080e06f1a236cd10fe87" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "ref-cast", +] + [[package]] name = "sha1" version = "0.10.7" @@ -7278,6 +7657,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -7426,7 +7815,7 @@ dependencies = [ "bytemuck", "byteorder", "cfg-if", - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-mac", "cryptoki", "curve25519-dalek", @@ -7439,8 +7828,8 @@ dependencies = [ "num-derive", "num-traits", "oid", - "p256", - "p384", + "p256 0.14.0-rc.14", + "p384 0.14.0-rc.14", "p521", "picky", "picky-asn1", @@ -7448,7 +7837,7 @@ dependencies = [ "picky-asn1-x509", "picky-krb", "pkcs1 0.8.0-rc.4", - "primeorder", + "primeorder 0.14.0-rc.14", "rand 0.10.2", "rand_core 0.10.1", "rsa", @@ -8028,6 +8417,7 @@ dependencies = [ "futures-core", "pin-project-lite 0.2.17", "tokio 1.52.3", + "tokio-util", ] [[package]] @@ -8183,6 +8573,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "webpki-roots 0.26.11", ] [[package]] @@ -9696,7 +10087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12dafb3c1468d0a3f5440e21e51614b53d1fdc62c9f82cc861c447906d09c69a" dependencies = [ "bitflags 2.13.1", - "crypto-bigint", + "crypto-bigint 0.7.5", "flate2", "iso7816", "iso7816-tlv", @@ -9764,6 +10155,8 @@ checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" dependencies = [ "const-oid 0.10.2", "der 0.8.1", + "sha1 0.11.0", + "signature 3.0.0", "spki 0.8.0", "tls_codec", ] diff --git a/clippy.toml b/clippy.toml index 2ded6b073..888f42c89 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,3 +1,8 @@ semicolon-outside-block-ignore-multiline = true accept-comment-above-statement = true accept-comment-above-attributes = true + +# The Linux release sysroot is glibc 2.27. +disallowed-methods = [ + { path = "libc::renameat2", reason = "needs glibc 2.28; the Linux release baseline is glibc 2.27, so call the renameat2 syscall directly", allow-invalid = true }, +] diff --git a/crates/agent-channel-client/Cargo.toml b/crates/agent-channel-client/Cargo.toml new file mode 100644 index 000000000..907c6de1c --- /dev/null +++ b/crates/agent-channel-client/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "agent-channel-client" +version = "0.0.0" +authors = ["Devolutions Inc. "] +edition = "2024" +description = "Agent side of the agent channel" +publish = false + +[lints] +workspace = true + +[dependencies] +agent-channel-proto = { path = "../agent-channel-proto" } +agent-identity-httpsig = { path = "../agent-identity-httpsig" } +device-key = { path = "../device-key" } +http-client-proxy = { path = "../http-client-proxy" } +anyhow = "1" +base64 = "0.22" +http = "1" +hyper = { version = "1", features = ["client", "http2"] } +hyper-util = { version = "0.1", features = ["client-legacy", "client-proxy", "http2", "tokio"] } +percent-encoding = "2" +rand = "0.10" +rustls = { version = "0.23", default-features = false, features = ["logging", "ring", "std", "tls12"] } +rustls-native-certs = "0.8" +time = { version = "0.3", features = ["parsing"] } +tokio = { version = "1.52", features = ["macros", "net", "rt", "sync", "time"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["logging", "ring", "tls12"] } +tokio-stream = "0.1" +tonic = { version = "0.12", default-features = false, features = ["codegen", "prost"] } +tower = { version = "0.5", features = ["util"] } +url = "2.5" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +hyper = { version = "1", features = ["http1", "http2", "server"] } +hyper-util = { version = "0.1", features = ["server", "service", "tokio"] } +p256 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] } +rcgen = "0.13" +rstest = "0.25" +serde_json = "1" +signature = "3" +time = { version = "0.3", features = ["formatting"] } +tokio = { version = "1.52", features = ["io-util", "net", "rt-multi-thread"] } diff --git a/crates/agent-channel-client/INTENT.agent-identity.md b/crates/agent-channel-client/INTENT.agent-identity.md new file mode 120000 index 000000000..56d20dc7a --- /dev/null +++ b/crates/agent-channel-client/INTENT.agent-identity.md @@ -0,0 +1 @@ +../../docs/agent-identity/INTENT.agent-identity.md \ No newline at end of file diff --git a/crates/agent-channel-client/INTENT.md b/crates/agent-channel-client/INTENT.md new file mode 100644 index 000000000..b7a3b1cf4 --- /dev/null +++ b/crates/agent-channel-client/INTENT.md @@ -0,0 +1,20 @@ +This crate is the agent's side of the channel defined in `agent-channel-proto`. + +## Connection + +- It connects through the agent's configured proxy, like the agent's identity HTTP requests. +- It sends an HTTP/2 keepalive every 30 s, and reconnects after a closed stream with a jittered exponential backoff from 1 s, unless the outcome is terminal. + The backoff grows to 1 h when the response shows the path cannot carry gRPC, such as a reverse proxy that answers without HTTP/2 or gRPC, and to 5 min after network errors, server downtime, and any other transient refusal or closure of the stream, so that a device behind such a proxy stops retrying often while an outage still recovers quickly. +- The backoff doubles from 1 s and is multiplied by a jitter drawn uniformly from `[0.5, 1.5]` before its cap, as the agent's other retries are. +- The backoff restarts from 1 s only after a stream has stayed authenticated for 60 s, so that a stream that authenticates and then drops at once keeps backing off. + After such a long-lived stream closes, the first reconnect waits a delay drawn uniformly from `[0, 30 s]`, so that an authority restart doesn't bring every agent back within the same second. +- A server message that cannot be decoded, or whose `id` is not a UUID, closes the stream as a transient failure without a reply, so the agent never echoes an unchecked `id` and never sends an envelope larger than `CONTRACT.agent-identity.md` §7.1 allows. + +## Handshake + +- An opening that has not received `Welcome` 30 s after the connection started is a network error; until then, messages other than the expected `Challenge` and `Welcome` are ignored when their `id` is a UUID. +- A `Challenge` that is not 32 bytes closes the stream as a transient failure, without sending `Hello`. + +## Closing + +- Dropping a channel stops an opening or a wait to reopen at once; an authenticated stream first sends the acknowledgements already queued, then ends the agent's side and gives the authority up to 5 s to end its own, so that no acknowledgement queued before the drop is lost. diff --git a/crates/agent-channel-client/clippy.toml b/crates/agent-channel-client/clippy.toml new file mode 100644 index 000000000..61d07ff18 --- /dev/null +++ b/crates/agent-channel-client/clippy.toml @@ -0,0 +1,8 @@ +semicolon-outside-block-ignore-multiline = true +accept-comment-above-statement = true +accept-comment-above-attributes = true + +# Device-key signing can fail (for example in the Windows software provider); sign would panic, so callers use try_sign. +disallowed-methods = [ + { path = "signature::Signer::sign", reason = "use try_sign: device key signing is fallible" }, +] diff --git a/crates/agent-channel-client/src/backoff.rs b/crates/agent-channel-client/src/backoff.rs new file mode 100644 index 000000000..1c978031b --- /dev/null +++ b/crates/agent-channel-client/src/backoff.rs @@ -0,0 +1,266 @@ +//! The reconnect backoff ([`crates/agent-channel-client/INTENT.md`]). +//! +//! [`crates/agent-channel-client/INTENT.md`]: ../INTENT.md + +use std::sync::Arc; +use std::time::Duration; + +use rand::RngExt as _; +use tokio::time::Instant; + +use crate::TransientKind; + +const NO_GRPC_PATH_CAP: Duration = Duration::from_secs(3600); +const OUTAGE_CAP: Duration = Duration::from_secs(300); +const STABLE_AFTER: Duration = Duration::from_secs(60); +const SPREAD_MAX: Duration = Duration::from_secs(30); + +/// When a channel reopens its stream after a transient closure. +/// +/// The `n`-th consecutive closure waits `2^(n-1)` seconds times a jitter factor, then at most the cap of its +/// [`TransientKind`]. +/// A stream that stays authenticated for [`stable_after`](Self::stable_after) starts the count again, and the first +/// closure after it waits a [`spread`](Self::spread) draw instead, at most the same cap. +#[derive(Clone)] +pub struct Backoff { + /// The cap when the response shows the path can't carry gRPC. + pub no_grpc_path_cap: Duration, + /// The cap after every other transient closure. + pub outage_cap: Duration, + /// A lower cap for every closure, such as the agent's `backoff_max_secs`. + pub max: Option, + /// Draws the factor each delay is multiplied by. + /// + /// The default is [`uniform_jitter`]; `|| 1.0` disables jitter. + pub jitter: Arc f64 + Send + Sync>, + /// How long a stream stays authenticated before the count starts again. + pub stable_after: Duration, + /// Draws the wait before reopening after a stream that stayed authenticated for `stable_after`. + /// + /// The default is [`uniform_spread`]. + pub spread: Arc Duration + Send + Sync>, +} + +impl Default for Backoff { + fn default() -> Self { + Self { + no_grpc_path_cap: NO_GRPC_PATH_CAP, + outage_cap: OUTAGE_CAP, + max: None, + jitter: Arc::new(uniform_jitter), + stable_after: STABLE_AFTER, + spread: Arc::new(uniform_spread), + } + } +} + +/// Draws a jitter factor uniformly from `[0.5, 1.5]`. +pub fn uniform_jitter() -> f64 { + rand::rng().random_range(0.5..=1.5) +} + +/// Draws a wait uniformly from `[0, 30 s]`. +pub fn uniform_spread() -> Duration { + SPREAD_MAX.mul_f64(rand::rng().random_range(0.0..=1.0)) +} + +/// Counts the consecutive closures since the last stream that stayed authenticated for [`Backoff::stable_after`]. +#[derive(Default)] +pub(crate) struct BackoffState { + closures: u32, + /// When the current stream authenticated, until its closure. + authenticated_at: Option, +} + +impl BackoffState { + /// Returns the delay before reopening after one more transient closure of `kind`, which happened at `now`. + pub(crate) fn next_delay(&mut self, backoff: &Backoff, kind: TransientKind, now: Instant) -> Duration { + let cap = match kind { + TransientKind::NoGrpcPath => backoff.no_grpc_path_cap, + TransientKind::Outage => backoff.outage_cap, + }; + let cap = backoff.max.map_or(cap, |max| cap.min(max)); + if let Some(authenticated_at) = self.authenticated_at.take() + && backoff.stable_after <= now.saturating_duration_since(authenticated_at) + { + self.closures = 0; + return (backoff.spread)().min(cap); + } + self.closures = self.closures.saturating_add(1); + // The exponent stops at 62 so that `2^62` s times any sane jitter still fits a `Duration`. + let exponent = (self.closures - 1).min(62); + let delay = Duration::from_secs(1 << exponent).as_secs_f64() * (backoff.jitter)(); + // A jitter that makes the delay negative, infinite or not a number waits the cap. + Duration::try_from_secs_f64(delay).map_or(cap, |delay| delay.min(cap)) + } + + /// Records that a stream authenticated at `at`. + pub(crate) fn authenticated(&mut self, at: Instant) { + self.authenticated_at = Some(at); + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + fn delays(backoff: &Backoff, kind: TransientKind, count: usize) -> Vec { + let mut state = BackoffState::default(); + delays_of(&mut state, backoff, kind, count) + } + + fn delays_of(state: &mut BackoffState, backoff: &Backoff, kind: TransientKind, count: usize) -> Vec { + (0..count) + .map(|_| state.next_delay(backoff, kind, Instant::now())) + .collect() + } + + fn seconds(values: &[f64]) -> Vec { + values.iter().copied().map(Duration::from_secs_f64).collect() + } + + fn without_jitter() -> Backoff { + Backoff { + jitter: Arc::new(|| 1.0), + ..Backoff::default() + } + } + + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + #[rstest] + #[case::outage( + TransientKind::Outage, + &[1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 300.0, 300.0] + )] + #[case::no_grpc_path( + TransientKind::NoGrpcPath, + &[1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 512.0, 1024.0, 2048.0, 3600.0, 3600.0] + )] + fn doubles_from_one_second_up_to_the_cap_of_the_closure(#[case] kind: TransientKind, #[case] expected: &[f64]) { + assert_eq!(delays(&without_jitter(), kind, expected.len()), seconds(expected)); + } + + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + #[rstest] + #[case::low(0.5, &[0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 300.0])] + #[case::high(1.5, &[1.5, 3.0, 6.0, 12.0, 24.0, 48.0, 96.0, 192.0, 300.0, 300.0])] + fn scales_each_delay_by_the_jitter_before_the_cap(#[case] factor: f64, #[case] expected: &[f64]) { + let backoff = Backoff { + jitter: Arc::new(move || factor), + ..Backoff::default() + }; + assert_eq!( + delays(&backoff, TransientKind::Outage, expected.len()), + seconds(expected) + ); + } + + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + // oracle: intent devolutions-agent/src/identity/INTENT.md §Configuration + // The agent passes its `backoff_max_secs` as `max`. + #[rstest] + #[case::outage(TransientKind::Outage)] + #[case::no_grpc_path(TransientKind::NoGrpcPath)] + fn the_callers_maximum_lowers_every_cap(#[case] kind: TransientKind) { + let backoff = Backoff { + max: Some(Duration::from_secs(3)), + ..without_jitter() + }; + assert_eq!(delays(&backoff, kind, 4), seconds(&[1.0, 2.0, 3.0, 3.0])); + } + + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + #[test] + fn stays_at_its_cap_however_many_closures_follow() { + let delays = delays(&without_jitter(), TransientKind::NoGrpcPath, 100); + assert!( + delays[12..].iter().all(|delay| *delay == Duration::from_secs(3600)), + "{delays:?}" + ); + } + + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + #[test] + fn the_default_jitter_stays_within_half_and_one_and_a_half() { + let draws: Vec = (0..1000).map(|_| uniform_jitter()).collect(); + assert!(draws.iter().all(|draw| (0.5..=1.5).contains(draw))); + assert!(draws.iter().any(|draw| *draw < 0.9) && draws.iter().any(|draw| 1.1 < *draw)); + } + + /// A backoff without jitter whose spread always draws 17 s. + fn with_spread() -> Backoff { + Backoff { + spread: Arc::new(|| Duration::from_secs(17)), + ..without_jitter() + } + } + + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + #[test] + fn keeps_growing_after_a_stream_authenticated_for_less_than_60_s() { + let backoff = with_spread(); + let mut state = BackoffState::default(); + delays_of(&mut state, &backoff, TransientKind::Outage, 3); + let authenticated = Instant::now(); + state.authenticated(authenticated); + + let closed = authenticated + Duration::from_secs(60) - Duration::from_millis(1); + assert_eq!( + state.next_delay(&backoff, TransientKind::Outage, closed), + Duration::from_secs(8) + ); + } + + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + #[test] + fn spreads_the_first_reconnect_after_a_stream_authenticated_for_60_s_then_restarts_from_1_s() { + let backoff = with_spread(); + let mut state = BackoffState::default(); + delays_of(&mut state, &backoff, TransientKind::Outage, 3); + let authenticated = Instant::now(); + state.authenticated(authenticated); + + let closed = authenticated + Duration::from_secs(60); + assert_eq!( + state.next_delay(&backoff, TransientKind::Outage, closed), + Duration::from_secs(17) + ); + assert_eq!( + delays_of(&mut state, &backoff, TransientKind::Outage, 2), + seconds(&[1.0, 2.0]) + ); + } + + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + // oracle: intent devolutions-agent/src/identity/INTENT.md §Configuration + // The agent passes its `backoff_max_secs` as `max`. + #[test] + fn the_callers_maximum_lowers_the_spread() { + let backoff = Backoff { + max: Some(Duration::from_secs(3)), + ..with_spread() + }; + let mut state = BackoffState::default(); + let authenticated = Instant::now(); + state.authenticated(authenticated); + + let closed = authenticated + Duration::from_secs(60); + assert_eq!( + state.next_delay(&backoff, TransientKind::Outage, closed), + Duration::from_secs(3) + ); + } + + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + #[test] + fn the_default_spread_stays_within_0_and_30_s() { + let draws: Vec = (0..1000).map(|_| uniform_spread()).collect(); + assert!(draws.iter().all(|draw| *draw <= Duration::from_secs(30)), "{draws:?}"); + assert!( + draws.iter().any(|draw| *draw < Duration::from_secs(5)) + && draws.iter().any(|draw| Duration::from_secs(25) < *draw) + ); + } +} diff --git a/crates/agent-channel-client/src/closure.rs b/crates/agent-channel-client/src/closure.rs new file mode 100644 index 000000000..409193a2a --- /dev/null +++ b/crates/agent-channel-client/src/closure.rs @@ -0,0 +1,131 @@ +//! How a stream ended, classified as [CONTRACT.agent-identity.md §5.4 "Errors"][errors] and [§7.4 +//! "Termination"][termination] say. +//! +//! [errors]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#54-errors +//! [termination]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#74-termination + +use std::error::Error; +use std::fmt; + +use crate::transport::NotGrpc; + +/// Why a stream closed or its opening failed ([CONTRACT.agent-identity.md §7.4 "Termination"][contract]). +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#74-termination +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Closure { + pub class: ClosureClass, + /// The gRPC status the authority closed the stream with, if it sent one. + pub grpc_code: Option, + /// The `error-code` trailer. + pub error_code: Option, + /// The `server-time` trailer. + pub server_time: Option, + /// A description of the cause, for logs. + pub reason: String, +} + +/// What a closure means for the identity ([CONTRACT.agent-identity.md §5.4 "Errors"][contract]). +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#54-errors +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClosureClass { + /// The identity is over for this authority; the channel stops. + Terminal(TerminalCode), + /// The certificate expired; the channel stops, and the caller renews before it connects again. + CertificateExpired, + /// Any other outcome; the channel opens a new stream after a backoff. + Transient(TransientKind), +} + +/// The error codes that end an identity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerminalCode { + IdentityRevoked, + IdentityUnknown, +} + +impl TerminalCode { + pub const fn as_str(self) -> &'static str { + match self { + Self::IdentityRevoked => "identity_revoked", + Self::IdentityUnknown => "identity_unknown", + } + } +} + +/// The kind of a transient closure, which sets how far the backoff grows. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransientKind { + /// The response shows the path to the authority can't carry gRPC. + NoGrpcPath, + /// A network error, server downtime, or any other transient refusal or closure of the stream. + Outage, +} + +impl Closure { + /// A closure that no gRPC status explains, such as a network error. + pub(crate) fn transient(kind: TransientKind, reason: impl fmt::Display) -> Self { + Self { + class: ClosureClass::Transient(kind), + grpc_code: None, + error_code: None, + server_time: None, + reason: reason.to_string(), + } + } + + pub(crate) fn outage(reason: impl fmt::Display) -> Self { + Self::transient(TransientKind::Outage, reason) + } + + /// Classifies the status a stream ended with. + /// + /// A status the authority sent is classified by its `error-code` trailer; a status the client derived from a + /// connection failure carries the failure as its source. + pub(crate) fn from_status(status: &tonic::Status) -> Self { + let mut cause = status.source(); + while let Some(error) = cause { + if let Some(not_grpc) = error.downcast_ref::() { + return Self::transient(not_grpc.kind(), error); + } + cause = error.source(); + } + if let Some(source) = status.source() { + return Self::outage(error_chain(status.message(), source)); + } + let trailer = |name: &str| { + status + .metadata() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + let error_code = trailer("error-code"); + let class = match error_code.as_deref() { + Some("identity_revoked") => ClosureClass::Terminal(TerminalCode::IdentityRevoked), + Some("identity_unknown") => ClosureClass::Terminal(TerminalCode::IdentityUnknown), + Some("certificate_expired") => ClosureClass::CertificateExpired, + _ => ClosureClass::Transient(TransientKind::Outage), + }; + Self { + class, + grpc_code: Some(status.code()), + error_code, + server_time: trailer("server-time"), + reason: status.message().to_owned(), + } + } +} + +/// Formats `message`, then `source` and each of its causes. +pub(crate) fn error_chain(message: &str, source: &(dyn Error + 'static)) -> String { + let mut chain = message.to_owned(); + let mut cause = Some(source); + while let Some(error) = cause { + chain.push_str(": "); + chain.push_str(&error.to_string()); + cause = error.source(); + } + chain +} diff --git a/crates/agent-channel-client/src/lib.rs b/crates/agent-channel-client/src/lib.rs new file mode 100644 index 000000000..751bc1bd1 --- /dev/null +++ b/crates/agent-channel-client/src/lib.rs @@ -0,0 +1,168 @@ +//! The agent's side of the agent channel ([CONTRACT.agent-identity.md §7 "Channel (gRPC)"][contract]). +//! +//! [`ChannelClient::connect`] keeps one stream open to an authority: it opens and authenticates the stream, reports +//! the authority's messages as [`Event`]s, and reopens the stream after a transient closure. +//! The caller decides what the messages and closures mean for the identity. +//! +//! [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#7-channel-grpc + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use device_key::DeviceKey; +use http_client_proxy::ProxyConfig; +use rustls::pki_types::CertificateDer; +use tokio::sync::mpsc; +use url::Url; + +use crate::transport::Transport; + +mod backoff; +mod closure; +mod stream; +mod transport; + +pub use crate::backoff::{Backoff, uniform_jitter, uniform_spread}; +pub use crate::closure::{Closure, ClosureClass, TerminalCode, TransientKind}; + +const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); +const OPENING_TIMEOUT: Duration = Duration::from_secs(30); + +/// Opens agent channels. +pub struct ChannelClient { + shared: Arc, +} + +struct Shared { + transport: Transport, + settings: Settings, +} + +/// Timing settings of a [`ChannelClient`]. +pub struct Settings { + /// Interval between HTTP/2 keepalive pings. + pub keepalive_interval: Duration, + /// Time allowed from the TCP connection to `Welcome`. + pub opening_timeout: Duration, + /// When a closed stream is opened again. + pub backoff: Backoff, +} + +impl Default for Settings { + fn default() -> Self { + Self { + keepalive_interval: KEEPALIVE_INTERVAL, + opening_timeout: OPENING_TIMEOUT, + backoff: Backoff::default(), + } + } +} + +/// The authority and identity a channel connects with. +pub struct Target { + /// The identity's `agent_channel_url`. + pub url: Url, + /// The device key that signs the opening request and the channel proof. + pub key: Arc, + /// The leaf certificate of `key`, whose thumbprint names the key to the authority. + pub leaf_certificate_der: Vec, + /// Supplies the `Hello` content each time a stream sends one. + pub hello: Arc HelloFields + Send + Sync>, +} + +/// The caller-supplied part of a `Hello`, collected when it is sent. +pub struct HelloFields { + /// The agent's metadata ([CONTRACT.agent-identity.md §3.1 "Metadata sent by the agent"][contract]). + /// + /// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#31-metadata-sent-by-the-agent + pub metadata: HashMap, + /// The stored `config.revision`, sent as `applied_state_versions["config"]`. + pub config_revision: u64, +} + +/// What a channel reports to its caller. +/// +/// Each `id` an event carries is a lowercase hyphenated UUID: the channel closes a stream on a message whose `id` is +/// not. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Event { + /// The authority answered the `Hello` with `Welcome`: the stream is authenticated. + Authenticated, + /// The authority asks the identity to renew; the caller acknowledges `id`. + RenewRequested { id: String, reason: String }, + /// The authority asks for a new stream, which the caller opens before dropping this channel. + Reconnect { id: String, reason: String }, + /// A newer effective config, as a JSON object; the caller acknowledges `id`. + ConfigUpdate { id: String, config_json: String }, + /// The stream closed or its opening failed. + /// + /// The channel opens a new stream after `reopen_after`; without one, the channel has stopped. + Closed { + closure: Closure, + reopen_after: Option, + }, +} + +/// One channel: a stream that is reopened after each transient closure. +/// +/// Dropping the channel closes it: an opening, or a wait to reopen, stops at once, and an authenticated stream ends +/// once the acknowledgements queued before are sent. +pub struct Channel { + events: mpsc::Receiver, + /// Dropping it tells the stream task to stop. + acks: mpsc::UnboundedSender, +} + +impl ChannelClient { + /// Creates a client that trusts the OS trust store plus `extra_trusted_roots`, and connects through `proxy`. + pub fn new( + proxy: ProxyConfig, + extra_trusted_roots: &[CertificateDer<'_>], + settings: Settings, + ) -> anyhow::Result { + Ok(Self { + shared: Arc::new(Shared { + transport: Transport::new(proxy, extra_trusted_roots)?, + settings, + }), + }) + } + + /// Starts a channel to `target`; it must be called within a Tokio runtime. + /// + /// The channel runs over HTTP/2 and TLS, so `target.url` must be an `https` URL with a host. + pub fn connect(&self, target: Target) -> anyhow::Result { + anyhow::ensure!(target.url.scheme() == "https", "channel URL is not https"); + transport::endpoint(&target.url)?; + let (event_sender, events) = mpsc::channel(16); + let (acks, ack_receiver) = mpsc::unbounded_channel(); + tokio::spawn(stream::run( + Arc::clone(&self.shared), + target, + event_sender, + ack_receiver, + )); + Ok(Channel { events, acks }) + } +} + +impl Channel { + /// Waits for the next event, or returns `None` once the channel has stopped. + pub async fn next_event(&mut self) -> Option { + self.events.recv().await + } + + /// Acknowledges the authority's message `correlation_id` ([CONTRACT.agent-identity.md §7.3 "Sequence"][contract]). + /// + /// Only an authenticated stream carries acknowledgements, and one sent without such a stream is dropped: the + /// authority repeats a renewal request on the next stream, and `Hello` reconciles the config. + /// + /// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#73-sequence + pub fn ack(&self, correlation_id: &str) { + let _ = self.acks.send(correlation_id.to_owned()); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/agent-channel-client/src/stream.rs b/crates/agent-channel-client/src/stream.rs new file mode 100644 index 000000000..a7039908f --- /dev/null +++ b/crates/agent-channel-client/src/stream.rs @@ -0,0 +1,361 @@ +//! The task behind one [`Channel`](crate::Channel): it opens, authenticates and reopens the stream. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use agent_channel_proto::agent_channel_client::AgentChannelClient; +use agent_channel_proto::server_message::Payload; +use agent_channel_proto::{Ack, AgentMessage, Hello, ServerMessage, agent_message}; +use agent_identity_httpsig::{ChannelLayer, OpeningNonce, RequestSigner}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tokio_stream::wrappers::ReceiverStream; +use tonic::metadata::MetadataMap; +use tonic::{Code, Status, Streaming}; +use tower::Layer as _; +use uuid::Uuid; + +use crate::backoff::BackoffState; +use crate::closure::{Closure, ClosureClass}; +use crate::transport::endpoint; +use crate::{Event, Shared, Target}; + +const CHANNEL_PROOF_DOMAIN: &[u8] = b"devolutions-agent-identity/v1/channel-proof"; +const CHALLENGE_LEN: usize = 32; +/// The largest envelope, in bytes of serialized protobuf, that each side accepts ([CONTRACT.agent-identity.md §7.1 +/// "Service"][contract]). +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#71-service +const MAX_ENVELOPE_LEN: usize = 65_536; +/// How long a stream the caller dropped waits for the authority to end its side. +// UNTESTED: a dropped channel's stream releases its connection as soon as the authority ends its side. +// Waiting out the whole timeout would hold a connection that the authority has already finished with. +// When changing `close`, keep reading until the authority's side ends. +const CLOSE_TIMEOUT: Duration = Duration::from_secs(5); + +pub(crate) async fn run( + shared: Arc, + target: Target, + events: mpsc::Sender, + mut acks: mpsc::UnboundedReceiver, +) { + let mut backoff = BackoffState::default(); + loop { + let Some(closure) = stream_once(&shared, &target, &events, &mut acks, &mut backoff).await else { + return; + }; + let reopen_after = match closure.class { + ClosureClass::Transient(kind) => Some(backoff.next_delay(&shared.settings.backoff, kind, Instant::now())), + ClosureClass::Terminal(_) | ClosureClass::CertificateExpired => None, + }; + if events.send(Event::Closed { closure, reopen_after }).await.is_err() { + return; + } + let Some(delay) = reopen_after else { + return; + }; + tokio::select! { + () = tokio::time::sleep(delay) => {} + () = dropped(&mut acks) => return, + } + } +} + +/// Runs one stream until it closes, or returns `None` once the caller has dropped the channel. +async fn stream_once( + shared: &Shared, + target: &Target, + events: &mpsc::Sender, + acks: &mut mpsc::UnboundedReceiver, + backoff: &mut BackoffState, +) -> Option { + let mut opened = tokio::select! { + opened = authenticated_stream(shared, target) => match opened { + Ok(opened) => opened, + Err(closure) => return Some(closure), + }, + () = dropped(acks) => return None, + }; + backoff.authenticated(Instant::now()); + // Acknowledgements sent before this stream authenticated answer an earlier stream's messages. + while acks.try_recv().is_ok() {} + events.send(Event::Authenticated).await.ok()?; + loop { + tokio::select! { + // Acknowledgements come first, so that those queued before the caller dropped the channel are sent. + biased; + correlation_id = acks.recv() => { + let Some(correlation_id) = correlation_id else { + close(opened).await; + return None; + }; + let ack = AgentMessage { + id: Uuid::new_v4().to_string(), + correlation_id: Some(correlation_id), + payload: Some(agent_message::Payload::Ack(Ack {})), + }; + // A stream that can't take the acknowledgement has closed, which its next message reports. + let _ = opened.outbound.send(ack).await; + } + message = opened.inbound.message() => match trusted(message) { + Ok(Some(message)) => { + if let Some(event) = push_event(message) { + // Without a caller the event is lost, and the caller's drop also closes the acknowledgement + // queue, which closes the stream once its acknowledgements are sent. + let _ = events.send(event).await; + } + } + Ok(None) => return Some(ended_with_ok(&mut opened.inbound, &opened.headers, "stream ended").await), + Err(closure) => return Some(closure), + }, + } + } +} + +/// Waits until the caller drops the channel, discarding the acknowledgements it queued, which no stream carries now. +async fn dropped(acks: &mut mpsc::UnboundedReceiver) { + while acks.recv().await.is_some() {} +} + +/// Closes an authenticated stream that the caller dropped: ends the agent's side after its queued messages, then lets +/// the authority end its side, for at most [`CLOSE_TIMEOUT`]. +/// +/// Dropping the response first would reset the stream, which discards the messages still queued, such as an +/// acknowledgement the caller sent just before ([CONTRACT.agent-identity.md §7.3 "Sequence"][contract], step 7). +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#73-sequence +async fn close(opened: Opened) { + let Opened { + outbound, mut inbound, .. + } = opened; + drop(outbound); + let _ = tokio::time::timeout(CLOSE_TIMEOUT, async { + while let Ok(Some(_)) = inbound.message().await {} + }) + .await; +} + +/// Answers the challenge and waits for `Welcome` ([CONTRACT.agent-identity.md §7.3 "Sequence"][contract], steps 3–4). +/// +/// Until `Welcome` answers the `Hello`, every other message is ignored, so that nothing counts before the +/// authority has verified the proof. +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#73-sequence +async fn authenticate(target: &Target, opened: &mut Opened) -> Result<(), Closure> { + if opened.challenge.len() != CHALLENGE_LEN { + return Err(Closure::outage("challenge is not 32 bytes")); + } + let hello = hello(target, &opened.challenge_id, &opened.challenge, &opened.nonce)?; + let hello_id = hello.id.clone(); + opened + .outbound + .send(hello) + .await + .map_err(|_| Closure::outage("stream closed before Hello"))?; + loop { + if let ServerMessage { + correlation_id: Some(correlation_id), + payload: Some(Payload::Welcome(_)), + .. + } = next_message(&mut opened.inbound, &opened.headers, "Welcome").await? + && correlation_id == hello_id + { + return Ok(()); + } + } +} + +/// Returns the event that reports a message on the authenticated stream, if the caller acts on it. +fn push_event(message: ServerMessage) -> Option { + match message.payload? { + Payload::RenewRequested(request) => Some(Event::RenewRequested { + id: message.id, + reason: request.reason, + }), + Payload::Reconnect(request) => Some(Event::Reconnect { + id: message.id, + reason: request.reason, + }), + Payload::ConfigUpdate(update) => Some(Event::ConfigUpdate { + id: message.id, + config_json: update.config_json, + }), + Payload::Welcome(_) | Payload::Ack(_) | Payload::Challenge(_) => None, + } +} + +/// A stream whose opening the authority accepted with a `Challenge`. +struct Opened { + outbound: mpsc::Sender, + inbound: Streaming, + /// The response headers, which hold the status of a trailers-only response. + headers: MetadataMap, + /// The nonce of the opening signature, which the channel proof binds. + nonce: String, + challenge_id: String, + challenge: Vec, +} + +/// Receives the next message of a stream that must not end before `awaited` arrives. +async fn next_message( + inbound: &mut Streaming, + headers: &MetadataMap, + awaited: &str, +) -> Result { + match trusted(inbound.message().await)? { + Some(message) => Ok(message), + None => Err(ended_with_ok(inbound, headers, &format!("stream ended before {awaited}")).await), + } +} + +/// Returns what a stream received, or the closure of a stream whose message the client can't trust. +/// +/// A message that can't be decoded, or whose `id` is not a UUID ([CONTRACT.agent-identity.md §1 "Protocol +/// conventions"][conventions]), closes the stream, so that no reply echoes an unchecked `id` or grows past the +/// envelope limit ([§7.1 "Service"][service]). +/// +/// [conventions]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#1-protocol-conventions +/// [service]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#71-service +fn trusted(received: Result, Status>) -> Result, Closure> { + match received { + Ok(Some(message)) if !is_uuid(&message.id) => Err(Closure::outage("message id is not a UUID")), + Ok(message) => Ok(message), + Err(status) => Err(Closure::from_status(&status)), + } +} + +/// Returns whether `id` is a UUID in lowercase hyphenated form. +fn is_uuid(id: &str) -> bool { + Uuid::try_parse(id).is_ok_and(|uuid| *uuid.hyphenated().encode_lower(&mut Uuid::encode_buffer()) == *id) +} + +/// Classifies a stream that ended with gRPC status OK, with the trailers it ended with. +/// +/// A trailers-only response carries them in `headers`. +async fn ended_with_ok(inbound: &mut Streaming, headers: &MetadataMap, reason: &str) -> Closure { + let metadata = match inbound.trailers().await { + Ok(Some(trailers)) => trailers, + Ok(None) | Err(_) => headers.clone(), + }; + Closure::from_status(&Status::with_metadata(Code::Ok, reason, metadata)) +} + +/// Builds the `Hello` that answers a challenge ([CONTRACT.agent-identity.md §7.3 "Sequence"][contract], step 3). +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#73-sequence +fn hello(target: &Target, challenge_id: &str, challenge: &[u8], nonce: &str) -> Result { + let proof = target + .key + .try_sign(&channel_proof_input(challenge, nonce)) + .map_err(|error| Closure::outage(format!("sign channel proof: {error}")))?; + let fields = (target.hello)(); + Ok(AgentMessage { + id: Uuid::new_v4().to_string(), + correlation_id: Some(challenge_id.to_owned()), + payload: Some(agent_message::Payload::Hello(Hello { + metadata: fields.metadata, + capabilities: Vec::new(), + applied_state_versions: HashMap::from([("config".to_owned(), fields.config_revision)]), + proof: proof.to_bytes().to_vec(), + })), + }) +} + +/// Opens and authenticates a stream, and retries once at the authority's time when it refuses the opening with +/// `clock_skew` ([CONTRACT.agent-identity.md §6 "RFC 9421 profile"][contract]). +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#6-rfc-9421-profile +async fn authenticated_stream(shared: &Shared, target: &Target) -> Result { + match authenticated_stream_at(shared, target, 0).await { + Err(closure) => match clock_offset(&closure) { + Some(offset) => authenticated_stream_at(shared, target, offset).await, + None => Err(closure), + }, + opened => opened, + } +} + +/// Opens and authenticates a stream whose signature is offset by `clock_offset` seconds, within the opening timeout. +async fn authenticated_stream_at(shared: &Shared, target: &Target, clock_offset: i64) -> Result { + let opening = async { + let mut opened = open(shared, target, clock_offset).await?; + authenticate(target, &mut opened).await?; + Ok(opened) + }; + tokio::time::timeout(shared.settings.opening_timeout, opening) + .await + .unwrap_or_else(|_| Err(Closure::outage("no Welcome within the opening timeout"))) +} + +/// Returns `server_time − local_time` in seconds for a `clock_skew` refusal with a `server-time` trailer. +fn clock_offset(closure: &Closure) -> Option { + if closure.error_code.as_deref() != Some("clock_skew") { + return None; + } + let server_time = OffsetDateTime::parse(closure.server_time.as_deref()?, &Rfc3339).ok()?; + Some(server_time.unix_timestamp() - OffsetDateTime::now_utc().unix_timestamp()) +} + +/// Sends the signed opening request, with its `created` time offset by `clock_offset` seconds, and waits for the +/// `Challenge` ([CONTRACT.agent-identity.md §7.3 "Sequence"][contract], steps 1–2). +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#73-sequence +async fn open(shared: &Shared, target: &Target, clock_offset: i64) -> Result { + let service = shared + .transport + .connect(&target.url, shared.settings.keepalive_interval) + .await?; + let signer = RequestSigner::new(Arc::clone(&target.key), &target.leaf_certificate_der); + let service = ChannelLayer::new(signer, &target.url) + .with_clock_offset(clock_offset) + .layer(service); + let (_, origin) = endpoint(&target.url).map_err(|error| Closure::outage(format!("{error:#}")))?; + // A larger message fails to decode, which closes the stream as `trusted` says. + let mut client = AgentChannelClient::with_origin(service, origin).max_decoding_message_size(MAX_ENVELOPE_LEN); + let (outbound, outbound_receiver) = mpsc::channel(16); + let (handoff, nonce) = OpeningNonce::new(); + let mut request = tonic::Request::new(ReceiverStream::new(outbound_receiver)); + request.extensions_mut().insert(handoff); + let response = client + .connect(request) + .await + .map_err(|status| Closure::from_status(&status))?; + let headers = response.metadata().clone(); + let mut inbound = response.into_inner(); + let nonce = nonce + .await + .map_err(|_| Closure::outage("opening signed without a nonce"))?; + loop { + if let ServerMessage { + id, + payload: Some(Payload::Challenge(challenge)), + .. + } = next_message(&mut inbound, &headers, "Challenge").await? + { + return Ok(Opened { + outbound, + inbound, + headers, + nonce, + challenge_id: id, + challenge: challenge.challenge, + }); + } + } +} + +/// Returns the bytes a channel proof signs ([CONTRACT.agent-identity.md §7.3 "Sequence"][contract], step 3). +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#73-sequence +pub(crate) fn channel_proof_input(challenge: &[u8], connect_nonce: &str) -> Vec { + let mut input = + Vec::with_capacity(CHANNEL_PROOF_DOMAIN.len() + 1 /* separator */ + challenge.len() + connect_nonce.len()); + input.extend_from_slice(CHANNEL_PROOF_DOMAIN); + input.push(0x00); + input.extend_from_slice(challenge); + input.extend_from_slice(connect_nonce.as_bytes()); + input +} diff --git a/crates/agent-channel-client/src/tests/authority.rs b/crates/agent-channel-client/src/tests/authority.rs new file mode 100644 index 000000000..446121347 --- /dev/null +++ b/crates/agent-channel-client/src/tests/authority.rs @@ -0,0 +1,522 @@ +//! An in-process authority for the channel tests, built from `agent-channel-proto`. + +use std::convert::Infallible; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use agent_channel_proto::agent_channel_server::{AgentChannel, AgentChannelServer}; +use agent_channel_proto::server_message::Payload; +use agent_channel_proto::{AgentMessage, Challenge, ServerMessage, Welcome}; +use anyhow::Context as _; +use hyper::body::{Bytes, Frame}; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _, ReadBuf}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{Request, Response, Status, Streaming}; +use tower::Service as _; +use url::Url; + +/// How long a test waits for something the client must do. +pub(super) const WAIT: Duration = Duration::from_secs(10); + +const URL_PATH: &str = "/dvls"; + +/// What the authority's TLS endpoint speaks. +#[derive(Clone, Copy)] +pub(super) enum Mode { + /// A gRPC authority over HTTP/2. + Grpc, + /// A gRPC authority over HTTP/2 whose responses carry this content type. + GrpcAs(&'static str), + /// HTTP/2 that answers every request with a page of this status and content type, like a reverse proxy without a + /// gRPC route or in front of a server that is down. + Http2Page(http::StatusCode, &'static str), + /// HTTP/1.1 without ALPN, like a reverse proxy that ignores the offer of HTTP/2. + Http1WithoutAlpn, + /// A server whose only ALPN protocol is `http/1.1`. + Http1Alpn, +} + +/// A TLS server CA and a certificate it issued for `127.0.0.1` and `localhost`. +pub(super) struct Pki { + pub(super) ca: CertificateDer<'static>, + chain: Vec>, + key: PrivatePkcs8KeyDer<'static>, +} + +impl Pki { + pub(super) fn generate() -> anyhow::Result { + let ca_key = rcgen::KeyPair::generate()?; + let mut ca_params = rcgen::CertificateParams::new(Vec::::new())?; + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "agent-channel-client test CA"); + let ca = ca_params.self_signed(&ca_key)?; + let leaf_key = rcgen::KeyPair::generate()?; + let leaf = rcgen::CertificateParams::new(vec!["127.0.0.1".to_owned(), "localhost".to_owned()])? + .signed_by(&leaf_key, &ca, &ca_key)?; + Ok(Self { + ca: ca.der().clone(), + chain: vec![leaf.der().clone()], + key: PrivatePkcs8KeyDer::from(leaf_key.serialize_der()), + }) + } + + pub(super) fn server_config(&self, alpn: &[&[u8]]) -> anyhow::Result { + let mut config = + rustls::ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions()? + .with_no_client_auth() + .with_single_cert(self.chain.clone(), PrivateKeyDer::Pkcs8(self.key.clone_key()))?; + config.alpn_protocols = alpn.iter().map(|protocol| protocol.to_vec()).collect(); + Ok(config) + } +} + +/// An authority listening on loopback; every stream opening is handed to the test. +pub(super) struct TestAuthority { + /// The `agent_channel_url`, with a path prefix. + pub(super) url: Url, + /// The root that issued the server certificate. + pub(super) ca: CertificateDer<'static>, + openings: mpsc::UnboundedReceiver, + pings: Arc, + task: JoinHandle<()>, +} + +impl TestAuthority { + pub(super) async fn start(mode: Mode) -> anyhow::Result { + let pki = Pki::generate()?; + let alpn: &[&[u8]] = match mode { + Mode::Grpc | Mode::GrpcAs(_) | Mode::Http2Page(..) => &[b"h2"], + Mode::Http1WithoutAlpn => &[], + Mode::Http1Alpn => &[b"http/1.1"], + }; + let acceptor = TlsAcceptor::from(Arc::new(pki.server_config(alpn)?)); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + let (opening_sender, openings) = mpsc::unbounded_channel(); + let grpc = AgentChannelServer::new(Handler { + openings: opening_sender, + }); + let pings = Arc::new(AtomicUsize::new(0)); + let counted_pings = Arc::clone(&pings); + let task = tokio::spawn(async move { + while let Ok((tcp, _)) = listener.accept().await { + let acceptor = acceptor.clone(); + let grpc = grpc.clone(); + let pings = Arc::clone(&counted_pings); + tokio::spawn(async move { + let Ok(tls) = acceptor.accept(tcp).await else { + return; + }; + let io = PingCounter::new(tls, pings); + match mode { + Mode::Grpc => serve_grpc(io, grpc, None).await, + Mode::GrpcAs(content_type) => serve_grpc(io, grpc, Some(content_type)).await, + Mode::Http2Page(status, content_type) => serve_page(io, status, content_type).await, + Mode::Http1WithoutAlpn | Mode::Http1Alpn => answer_http1(io).await, + } + }); + } + }); + Ok(Self { + url: Url::parse(&format!("https://127.0.0.1:{port}{URL_PATH}"))?, + ca: pki.ca, + openings, + pings, + task, + }) + } + + /// Waits for the client's next stream opening. + pub(super) async fn next_opening(&mut self) -> Opening { + tokio::time::timeout(WAIT, self.openings.recv()) + .await + .expect("no stream opening arrived in time") + .expect("authority stopped") + } + + /// Returns whether a stream opening arrives within `wait`. + pub(super) async fn opening_within(&mut self, wait: Duration) -> Option { + tokio::time::timeout(wait, self.openings.recv()).await.ok().flatten() + } + + /// The number of HTTP/2 PING frames, acknowledgements excluded, that clients have sent. + pub(super) fn pings(&self) -> usize { + self.pings.load(Ordering::SeqCst) + } +} + +impl Drop for TestAuthority { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// A stream opening the test accepts or refuses. +pub(super) struct Opening { + /// The request `:path`. + pub(super) path: String, + pub(super) headers: http::HeaderMap, + inbound: Streaming, + reply: oneshot::Sender>, Status>>, + encoded: mpsc::UnboundedSender, +} + +impl Opening { + pub(super) fn signature_input(&self) -> &str { + self.headers + .get("signature-input") + .and_then(|value| value.to_str().ok()) + .expect("opening without signature-input") + } + + /// The `nonce` parameter of the opening signature. + pub(super) fn nonce(&self) -> String { + self.signature_input() + .split_once(";nonce=\"") + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(nonce, _)| nonce.to_owned()) + .expect("signature-input without nonce") + } + + /// Refuses the opening with `status`, sent as a trailers-only response. + pub(super) fn refuse(self, status: Status) { + let _ = self.reply.send(Err(status)); + } + + pub(super) fn accept(self) -> ServerStream { + let nonce = self.nonce(); + let (outbound, receiver) = mpsc::channel(16); + let _ = self.reply.send(Ok(receiver)); + ServerStream { + nonce, + inbound: self.inbound, + outbound, + encoded: self.encoded, + } + } +} + +/// The authority's side of an accepted stream. +pub(super) struct ServerStream { + /// The nonce of the opening signature. + pub(super) nonce: String, + inbound: Streaming, + outbound: mpsc::Sender>, + /// Messages the test encoded itself, sent as they are. + encoded: mpsc::UnboundedSender, +} + +impl ServerStream { + pub(super) async fn send(&self, id: &str, correlation_id: Option<&str>, payload: Payload) { + self.send_message(ServerMessage { + id: id.to_owned(), + correlation_id: correlation_id.map(str::to_owned), + payload: Some(payload), + }) + .await; + } + + /// Sends a message whose payload the client can't decode, as a payload added by a newer authority looks. + pub(super) async fn send_unknown(&self, id: &str) { + self.send_message(ServerMessage { + id: id.to_owned(), + correlation_id: None, + payload: None, + }) + .await; + } + + async fn send_message(&self, message: ServerMessage) { + self.outbound.send(Ok(message)).await.expect("stream closed"); + } + + /// Sends `message`, which the test encoded as protobuf itself, as one gRPC message ("Length-Prefixed-Message" in + /// gRPC over HTTP2). + pub(super) fn send_encoded(&self, message: &[u8]) { + let length = u32::try_from(message.len()).expect("message shorter than 4 GiB"); + let mut frame = vec![0 /* Compressed-Flag */]; + frame.extend_from_slice(&length.to_be_bytes() /* Message-Length */); + frame.extend_from_slice(message); + self.encoded.send(Bytes::from(frame)).expect("stream closed"); + } + + /// Closes the stream with `status` in its trailers. + pub(super) async fn close(self, status: Status) { + self.outbound.send(Err(status)).await.expect("stream closed"); + } + + /// Waits for the client's next message. + pub(super) async fn receive(&mut self) -> AgentMessage { + tokio::time::timeout(WAIT, self.inbound.message()) + .await + .expect("no client message arrived in time") + .expect("client stream failed") + .expect("client stream ended") + } + + /// Returns the client's next message, end of stream or error if one arrives within `wait`. + pub(super) async fn receive_within(&mut self, wait: Duration) -> Option, Status>> { + tokio::time::timeout(wait, self.inbound.message()).await.ok() + } + + /// Sends a `Challenge` and returns the challenge's envelope ID. + pub(super) async fn challenge(&self, challenge: &[u8]) -> String { + let id = uuid::Uuid::new_v4().to_string(); + self.send( + &id, + None, + Payload::Challenge(Challenge { + challenge: challenge.to_vec(), + }), + ) + .await; + id + } + + /// Runs the handshake and returns the client's `Hello`. + pub(super) async fn authenticate(&mut self) -> AgentMessage { + self.challenge(&[7; 32]).await; + let hello = self.receive().await; + self.send( + &uuid::Uuid::new_v4().to_string(), + Some(&hello.id), + Payload::Welcome(Welcome { server_time: None }), + ) + .await; + hello + } +} + +/// The request `:path` before the authority strips its URL path. +#[derive(Clone)] +struct OriginalPath(String); + +/// Where a stream's handler queues the messages its test encodes itself. +#[derive(Clone)] +struct EncodedMessages(mpsc::UnboundedSender); + +/// A response body that sends the messages a test encoded itself between the messages of the gRPC service. +struct WithEncodedMessages { + body: tonic::body::BoxBody, + encoded: mpsc::UnboundedReceiver, +} + +impl hyper::body::Body for WithEncodedMessages { + type Data = Bytes; + type Error = Status; + + fn poll_frame(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll, Status>>> { + if let Poll::Ready(Some(message)) = self.encoded.poll_recv(cx) { + return Poll::Ready(Some(Ok(Frame::data(message)))); + } + Pin::new(&mut self.body).poll_frame(cx) + } + + fn is_end_stream(&self) -> bool { + self.encoded.is_closed() && self.body.is_end_stream() + } +} + +struct Handler { + openings: mpsc::UnboundedSender, +} + +#[tonic::async_trait] +impl AgentChannel for Handler { + type ConnectStream = ReceiverStream>; + + async fn connect( + &self, + request: Request>, + ) -> Result, Status> { + let path = request + .extensions() + .get::() + .map(|path| path.0.clone()) + .unwrap_or_default(); + let encoded = request + .extensions() + .get::() + .map(|encoded| encoded.0.clone()) + .expect("a queue for encoded messages"); + let headers = request.metadata().clone().into_headers(); + let (reply, decision) = oneshot::channel(); + self.openings + .send(Opening { + path, + headers, + inbound: request.into_inner(), + reply, + encoded, + }) + .map_err(|_| Status::internal("test ended"))?; + let outbound = decision.await.map_err(|_| Status::internal("opening dropped"))??; + Ok(Response::new(ReceiverStream::new(outbound))) + } +} + +/// Serves the authority's gRPC service, replacing the content type of its responses with `content_type` if any. +async fn serve_grpc(io: IO, grpc: AgentChannelServer, content_type: Option<&'static str>) +where + IO: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let service = hyper::service::service_fn(move |mut request: http::Request| { + let mut grpc = grpc.clone(); + async move { + let path = request.uri().path().to_owned(); + if let Some(rpc_path) = path.strip_prefix(URL_PATH) { + *request.uri_mut() = rpc_path.parse().expect("valid RPC path"); + } + request.extensions_mut().insert(OriginalPath(path)); + let (encoded, encoded_messages) = mpsc::unbounded_channel(); + request.extensions_mut().insert(EncodedMessages(encoded)); + let mut response = grpc.call(request).await?; + if let Some(content_type) = content_type { + response + .headers_mut() + .insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static(content_type)); + } + Ok::<_, Infallible>(response.map(|body| WithEncodedMessages { + body, + encoded: encoded_messages, + })) + } + }); + let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(io), service) + .await; +} + +async fn serve_page(io: IO, status: http::StatusCode, content_type: &'static str) +where + IO: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let service = hyper::service::service_fn(move |_request: http::Request| async move { + let response = http::Response::builder() + .status(status) + .header(http::header::CONTENT_TYPE, content_type) + .body(tonic::body::empty_body()) + .expect("valid response"); + Ok::<_, Infallible>(response) + }); + let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(io), service) + .await; +} + +async fn answer_http1(mut io: IO) +where + IO: AsyncRead + AsyncWrite + Unpin, +{ + let _ = io + .write_all( + b"HTTP/1.1 404 Not Found\r\ncontent-type: text/html\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ) + .await; + let _ = io.shutdown().await; +} + +/// Counts the HTTP/2 PING frames a client sends, by scanning the decrypted bytes it writes. +struct PingCounter { + inner: S, + pings: Arc, + scan: FrameScan, +} + +impl PingCounter { + fn new(inner: S, pings: Arc) -> Self { + Self { + inner, + pings, + scan: FrameScan { + preface_left: 24, /* client connection preface */ + header: [0; 9], + header_len: 0, + payload_left: 0, + }, + } + } +} + +struct FrameScan { + preface_left: usize, + header: [u8; 9], + header_len: usize, + payload_left: usize, +} + +impl FrameScan { + const PING: u8 = 0x6; + const ACK: u8 = 0x1; + + fn feed(&mut self, mut bytes: &[u8], pings: &AtomicUsize) { + while !bytes.is_empty() { + let skip = if 0 < self.preface_left { + &mut self.preface_left + } else if 0 < self.payload_left { + &mut self.payload_left + } else { + let taken = bytes.len().min(self.header.len() - self.header_len); + self.header[self.header_len..self.header_len + taken].copy_from_slice(&bytes[..taken]); + self.header_len += taken; + bytes = &bytes[taken..]; + if self.header_len == self.header.len() { + let [len0, len1, len2, frame_type, flags, ..] = self.header; + if frame_type == Self::PING && flags & Self::ACK == 0 { + pings.fetch_add(1, Ordering::SeqCst); + } + self.payload_left = usize::from(len0) << 16 | usize::from(len1) << 8 | usize::from(len2); + self.header_len = 0; + } + continue; + }; + let taken = bytes.len().min(*skip); + *skip -= taken; + bytes = &bytes[taken..]; + } + } +} + +impl AsyncRead for PingCounter { + fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll> { + let before = buf.filled().len(); + let this = &mut *self; + let result = Pin::new(&mut this.inner).poll_read(cx, buf); + if let Poll::Ready(Ok(())) = result { + this.scan.feed(&buf.filled()[before..], &this.pings); + } + result + } +} + +impl AsyncWrite for PingCounter { + fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +/// Reads a JSON test vector file shared with the authority implementations. +pub(super) fn vectors() -> anyhow::Result { + serde_json::from_str(include_str!("../../../../docs/agent-identity/test-vectors.json")) + .context("parse test vectors") +} diff --git a/crates/agent-channel-client/src/tests/mod.rs b/crates/agent-channel-client/src/tests/mod.rs new file mode 100644 index 000000000..b777f6b07 --- /dev/null +++ b/crates/agent-channel-client/src/tests/mod.rs @@ -0,0 +1,1602 @@ +mod authority; +mod proxy; +mod socks; + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agent_channel_proto::server_message::Payload; +use agent_channel_proto::{Ack, ConfigUpdate, Reconnect, RenewRequested, Welcome, agent_message}; +use anyhow::anyhow; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use http::StatusCode; +use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; +use p256::pkcs8::DecodePrivateKey as _; +use rstest::rstest; +use signature::Verifier as _; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; +use tonic::{Code, Status}; + +use self::authority::{Mode, Pki, ServerStream, TestAuthority, WAIT, vectors}; +use self::proxy::TestProxy; +use self::socks::{SocksRequest, TestSocksProxy}; +use super::*; + +const CHANNEL_PROOF_DOMAIN: &[u8] = b"devolutions-agent-identity/v1/channel-proof"; + +/// A device key from the shared test vectors. +struct TestKey { + name: String, + key: SigningKey, + fail_proofs: bool, +} + +impl DeviceKey for TestKey { + fn name(&self) -> &str { + &self.name + } + + fn public_key(&self) -> VerifyingKey { + *self.key.verifying_key() + } + + fn try_sign(&self, message: &[u8]) -> signature::Result { + if self.fail_proofs && message.starts_with(CHANNEL_PROOF_DOMAIN) { + return Err(signature::Error::new()); + } + signature::Signer::::try_sign(&self.key, message) + } +} + +/// A device key and certificate from the test vectors. +struct Identity { + key: Arc, + certificate: Vec, + thumbprint: String, +} + +impl Identity { + fn from_vectors(name: &str) -> anyhow::Result { + let vectors = vectors()?; + let vector = vectors["keys"] + .as_array() + .and_then(|keys| keys.iter().find(|key| key["name"] == name)) + .ok_or_else(|| anyhow!("missing vector key {name}"))?; + let field = |field: &str| { + vector[field] + .as_str() + .map(str::to_owned) + .ok_or_else(|| anyhow!("missing vector field {field}")) + }; + let private_key = STANDARD.decode(field("private_key_pkcs8")?)?; + Ok(Self { + key: Arc::new(TestKey { + name: name.to_owned(), + key: SigningKey::from_pkcs8_der(&private_key)?, + fail_proofs: false, + }), + certificate: STANDARD.decode(field("certificate")?)?, + thumbprint: field("thumbprint")?, + }) + } + + fn target(&self, url: &Url) -> Target { + self.target_with_hello(url, hello_fields()) + } + + fn target_with_hello(&self, url: &Url, hello: Arc HelloFields + Send + Sync>) -> Target { + Target { + url: url.clone(), + key: Arc::clone(&self.key) as Arc, + leaf_certificate_der: self.certificate.clone(), + hello, + } + } +} + +fn client(authority: &TestAuthority, settings: Settings) -> anyhow::Result { + ChannelClient::new(ProxyConfig::Off, std::slice::from_ref(&authority.ca), settings) +} + +fn hello_fields() -> Arc HelloFields + Send + Sync> { + Arc::new(|| HelloFields { + metadata: HashMap::from([("hostname".to_owned(), "test-host".to_owned())]), + config_revision: 7, + }) +} + +/// Waits until `condition` holds, for at most [`WAIT`]. +async fn wait_until(what: &str, condition: impl Fn() -> bool) { + let polled = async { + while !condition() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }; + tokio::time::timeout(WAIT, polled) + .await + .unwrap_or_else(|_| panic!("{what} did not happen in time")); +} + +fn new_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +async fn next_event(channel: &mut Channel) -> Event { + tokio::time::timeout(WAIT, channel.next_event()) + .await + .expect("no channel event in time") + .expect("channel stopped") +} + +/// Waits for the next closure. +async fn next_closure(channel: &mut Channel) -> (Closure, Option) { + match next_event(channel).await { + Event::Closed { closure, reopen_after } => (closure, reopen_after), + event => panic!("expected a closure, got {event:?}"), + } +} + +const SERVER_TIME: &str = "2026-10-21T00:00:00Z"; + +/// A gRPC status as the authority closes a stream with it ([CONTRACT.agent-identity.md] §7.4). +fn closing_status(code: Code, error_code: Option<&str>) -> Status { + let mut status = Status::new(code, "closed by the test authority"); + if let Some(error_code) = error_code { + let metadata = status.metadata_mut(); + metadata.insert("error-code", error_code.parse().expect("ASCII error code")); + metadata.insert("server-time", SERVER_TIME.parse().expect("ASCII time")); + } + status +} + +async fn assert_no_event(channel: &mut Channel) { + if let Ok(event) = tokio::time::timeout(Duration::from_millis(300), channel.next_event()).await { + panic!("unexpected channel event {event:?}"); + } +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url +#[tokio::test] +async fn opens_a_signed_stream_under_the_url_path_and_waits_for_the_challenge() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let _channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + + // The URL path prefixes the RPC path, and the opening carries a `connect` signature. + let opening = authority.next_opening().await; + assert_eq!(opening.path, "/dvls/devolutions.agent.channel.v1.AgentChannel/Connect"); + let signature_input = opening.signature_input().to_owned(); + assert!(signature_input.contains(";tag=\"connect\""), "{signature_input}"); + assert!(opening.headers.contains_key("signature")); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + assert!( + signature_input.contains(&format!(";keyid=\"{}\"", identity.thumbprint)), + "{signature_input}" + ); + + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // Step 1: the agent sends nothing until it receives `Challenge`. + let mut stream = opening.accept(); + assert!(stream.receive_within(Duration::from_millis(300)).await.is_none()); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +#[tokio::test] +async fn answers_the_challenge_with_a_hello_proving_the_device_key() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let hostname = Arc::new(Mutex::new("at-connect".to_owned())); + let hello_hostname = Arc::clone(&hostname); + let hello = Arc::new(move || HelloFields { + metadata: HashMap::from([( + "hostname".to_owned(), + hello_hostname.lock().expect("hostname lock").clone(), + )]), + config_revision: 7, + }); + let _channel = + client(&authority, Settings::default())?.connect(identity.target_with_hello(&authority.url, hello))?; + let mut stream = authority.next_opening().await.accept(); + + // Metadata is collected when the `Hello` is sent, so the `Hello` carries the hostname set after the opening. + *hostname.lock().expect("hostname lock") = "at-hello".to_owned(); + let challenge = [0x5a; 32]; + let challenge_id = stream.challenge(&challenge).await; + let message = stream.receive().await; + + // Step 3. + assert_eq!(message.correlation_id.as_deref(), Some(challenge_id.as_str())); + let Some(agent_message::Payload::Hello(hello)) = message.payload else { + panic!("expected Hello, got {message:?}"); + }; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-metadata + assert_eq!( + hello.metadata, + HashMap::from([("hostname".to_owned(), "at-hello".to_owned())]) + ); + // Step 7. + assert_eq!(hello.applied_state_versions, HashMap::from([("config".to_owned(), 7)])); + // Step 3. + let mut proven = CHANNEL_PROOF_DOMAIN.to_vec(); + proven.push(0x00); + proven.extend_from_slice(&challenge); + proven.extend_from_slice(stream.nonce.as_bytes()); + assert_eq!(hello.proof.len(), 64, "raw r||s"); + identity + .key + .public_key() + .verify(&proven, &Signature::from_slice(&hello.proof)?)?; + Ok(()) +} + +// oracle: intent docs/agent-identity/INTENT.agent-identity.md §Conformance +#[test] +fn channel_proof_input_matches_the_shared_vector() -> anyhow::Result<()> { + let vectors = vectors()?; + let vector = &vectors["channel_proof"]; + let field = |name: &str| { + vector[name] + .as_str() + .ok_or_else(|| anyhow!("missing vector field {name}")) + }; + let challenge = STANDARD.decode(field("challenge")?)?; + let input = stream::channel_proof_input(&challenge, field("connect_nonce")?); + assert_eq!(hex(&input), field("message_hex")?); + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Handshake +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +#[tokio::test] +async fn authenticates_only_on_the_welcome_answering_its_hello() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + send_unexpected(&stream).await; + stream.challenge(&[1; 32]).await; + let hello = stream.receive().await; + + // Nothing counts before the challenge succeeds (INTENT.agent-identity.md, security invariant 7). + send_unexpected(&stream).await; + assert_no_event(&mut channel).await; + + stream + .send( + &new_id(), + Some(&hello.id), + Payload::Welcome(Welcome { server_time: None }), + ) + .await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + assert_no_event(&mut channel).await; + Ok(()) +} + +/// Sends a renewal request and a `Welcome` that answers no `Hello`, neither of which counts before the handshake ends. +async fn send_unexpected(stream: &ServerStream) { + stream + .send( + &new_id(), + None, + Payload::RenewRequested(RenewRequested { + reason: "admin".to_owned(), + }), + ) + .await; + stream + .send( + &new_id(), + Some(&new_id()), + Payload::Welcome(Welcome { server_time: None }), + ) + .await; +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +#[tokio::test] +async fn reports_pushes_with_their_envelope_ids_and_ignores_unknown_payloads() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + // Unknown payloads are ignored. + stream.send_unknown(&new_id()).await; + stream.send(&new_id(), Some(&new_id()), Payload::Ack(Ack {})).await; + let (renew_id, reconnect_id, config_id) = (new_id(), new_id(), new_id()); + stream + .send( + &renew_id, + None, + Payload::RenewRequested(RenewRequested { + reason: "rotation".to_owned(), + }), + ) + .await; + stream + .send( + &reconnect_id, + None, + Payload::Reconnect(Reconnect { + reason: "certificate_rotated".to_owned(), + }), + ) + .await; + let config_json = r#"{"version":1,"revision":8,"agent_channel_url":"https://host/dvls"}"#; + stream + .send( + &config_id, + None, + Payload::ConfigUpdate(ConfigUpdate { + config_json: config_json.to_owned(), + }), + ) + .await; + + assert_eq!( + next_event(&mut channel).await, + Event::RenewRequested { + id: renew_id, + reason: "rotation".to_owned() + } + ); + assert_eq!( + next_event(&mut channel).await, + Event::Reconnect { + id: reconnect_id, + reason: "certificate_rotated".to_owned() + } + ); + assert_eq!( + next_event(&mut channel).await, + Event::ConfigUpdate { + id: config_id, + config_json: config_json.to_owned() + } + ); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service +#[tokio::test] +async fn processes_a_message_as_large_as_an_envelope_may_be() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + let id = new_id(); + stream.send_encoded(&padded_renewal(&id, 65_536)); + + assert_eq!( + next_event(&mut channel).await, + Event::RenewRequested { + id, + reason: "admin".to_owned() + } + ); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn closes_without_reporting_a_message_larger_than_an_envelope_may_be() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, quick_reopening())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + stream.send_encoded(&padded_renewal(&new_id(), 65_537)); + + // Without a `RenewRequested` event, the caller has nothing to acknowledge. + let (closure, reopen_after) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert_eq!(reopen_after, Some(Duration::from_micros(15_625))); + assert!(!matches!(stream.receive_within(WAIT).await, Some(Ok(Some(_))))); + Ok(()) +} + +/// Encodes a `RenewRequested` under the envelope `id`, padded to `len` bytes with a field the agent doesn't know, as a +/// newer authority may add. +fn padded_renewal(id: &str, len: usize) -> Vec { + let mut message = encoded_renewal(id); + let padding = len - message.len() - 2 /* key of field 1000 */ - 3 /* length of the padding */; + message.extend(length_delimited(1000, &vec![0; padding])); + assert_eq!(message.len(), len); + message +} + +/// Encodes a protobuf record of wire type `LEN` (protobuf encoding, "Length-Delimited Records"). +fn length_delimited(field: u32, value: &[u8]) -> Vec { + let mut record = varint(u64::from((field << 3) | 2)); + record.extend(varint(u64::try_from(value.len()).expect("length fits 64 bits"))); + record.extend_from_slice(value); + record +} + +/// Encodes a protobuf base-128 varint (protobuf encoding, "Base 128 Varints"). +fn varint(mut value: u64) -> Vec { + let mut bytes = Vec::new(); + while 0x80 <= value { + bytes.push(u8::try_from(value & 0x7f).expect("7 bits") | 0x80); + value >>= 7; + } + bytes.push(u8::try_from(value).expect("7 bits")); + bytes +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +#[tokio::test] +async fn acknowledges_a_message_under_a_fresh_uuid() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + let hello = stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + let renew_id = new_id(); + stream + .send( + &renew_id, + None, + Payload::RenewRequested(RenewRequested { + reason: "admin".to_owned(), + }), + ) + .await; + next_event(&mut channel).await; + + channel.ack(&renew_id); + + // Step 5: `Ack` with `correlation_id` = message `id`. + let ack = stream.receive().await; + assert_eq!(ack.correlation_id.as_deref(), Some(renew_id.as_str())); + assert_eq!(ack.payload, Some(agent_message::Payload::Ack(Ack {}))); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // Envelope IDs are lowercase hyphenated UUIDs. + for id in [&hello.id, &ack.id] { + let uuid = uuid::Uuid::parse_str(id)?; + assert_eq!(&uuid.hyphenated().to_string(), id); + } + // oracle: spec RFC 9562 §1 + // A UUID is unique, so the acknowledgement's ID is neither the Hello's nor the acknowledged message's. + assert_ne!(ack.id, hello.id); + assert_ne!(ack.id, renew_id); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn pings_the_connection_at_the_keepalive_interval() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let settings = Settings { + keepalive_interval: Duration::from_millis(100), + ..Settings::default() + }; + let mut channel = client(&authority, settings)?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + let start = tokio::time::Instant::now(); + let before = authority.pings(); + tokio::time::sleep(Duration::from_secs(1)).await; + let pings = authority.pings() - before; + let window = start.elapsed(); + // oracle: reference hyper 1.10 `src/proto/h2/ping.rs`, which sends each ping one interval after the last frame read + // Pings are thus at least 100 ms apart, and the window holds at most one more ping than whole intervals. + let most = usize::try_from(window.as_millis() / 100)? + 1; + // A timer only promises not to fire early (`tokio::time::sleep`), so the lower bound lets each ping run up to one + // interval late; the 30 s default would send none. + assert!( + (5..=most).contains(&pings), + "{pings} pings in {window:?} at a 100 ms interval" + ); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[rstest] +#[case::identity_revoked( + Code::PermissionDenied, + "identity_revoked", + ClosureClass::Terminal(TerminalCode::IdentityRevoked) +)] +#[case::identity_unknown( + Code::Unauthenticated, + "identity_unknown", + ClosureClass::Terminal(TerminalCode::IdentityUnknown) +)] +#[case::certificate_expired(Code::Unauthenticated, "certificate_expired", ClosureClass::CertificateExpired)] +#[case::signature_invalid( + Code::Unauthenticated, + "signature_invalid", + ClosureClass::Transient(TransientKind::Outage) +)] +#[case::invalid_request( + Code::InvalidArgument, + "invalid_request", + ClosureClass::Transient(TransientKind::Outage) +)] +#[case::unavailable(Code::Unavailable, "unavailable", ClosureClass::Transient(TransientKind::Outage))] +#[case::unknown_code( + Code::Unauthenticated, + "unknown_to_the_agent", + ClosureClass::Transient(TransientKind::Outage) +)] +#[tokio::test] +async fn reports_a_refused_opening_with_its_status_trailers_and_class( + #[case] code: Code, + #[case] error_code: &str, + #[case] class: ClosureClass, +) -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + + // The error code classifies the closure, and every transient refusal is an outage. + authority + .next_opening() + .await + .refuse(closing_status(code, Some(error_code))); + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, class); + assert_eq!(closure.grpc_code, Some(code)); + assert_eq!(closure.error_code.as_deref(), Some(error_code)); + assert_eq!(closure.server_time.as_deref(), Some(SERVER_TIME)); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[rstest] +#[case::revoked( + Code::PermissionDenied, + Some("identity_revoked"), + ClosureClass::Terminal(TerminalCode::IdentityRevoked) +)] +#[case::expired(Code::Unauthenticated, Some("certificate_expired"), ClosureClass::CertificateExpired)] +#[case::rotated(Code::Ok, None, ClosureClass::Transient(TransientKind::Outage))] +#[case::without_error_code(Code::Internal, None, ClosureClass::Transient(TransientKind::Outage))] +#[tokio::test] +async fn reports_the_closure_of_an_authenticated_stream( + #[case] code: Code, + #[case] error_code: Option<&str>, + #[case] class: ClosureClass, +) -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + stream.close(closing_status(code, error_code)).await; + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, class); + assert_eq!(closure.grpc_code, Some(code)); + assert_eq!(closure.error_code.as_deref(), error_code); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +#[rstest] +#[case::revoked(Code::PermissionDenied, "identity_revoked")] +#[case::unknown(Code::Unauthenticated, "identity_unknown")] +#[case::expired(Code::Unauthenticated, "certificate_expired")] +#[tokio::test] +async fn stops_after_a_terminal_or_expired_certificate_closure( + #[case] code: Code, + #[case] error_code: &str, +) -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + + // The agent reconnects unless the identity is terminal, and renews after `certificate_expired` before it retries. + authority + .next_opening() + .await + .refuse(closing_status(code, Some(error_code))); + let (_, reopen_after) = next_closure(&mut channel).await; + assert_eq!(reopen_after, None); + let end = tokio::time::timeout(WAIT, channel.next_event()).await?; + assert_eq!(end, None, "the channel keeps running"); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +#[rstest] +#[case::identity_revoked(TerminalCode::IdentityRevoked, "identity_revoked")] +#[case::identity_unknown(TerminalCode::IdentityUnknown, "identity_unknown")] +fn names_each_terminal_code_as_the_contract_does(#[case] code: TerminalCode, #[case] name: &str) { + assert_eq!(code.as_str(), name); +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn a_refused_connection_is_an_outage() -> anyhow::Result<()> { + let authority = TestAuthority::start(Mode::Grpc).await?; + let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let url = Url::parse(&format!("https://127.0.0.1:{}/dvls", unused.local_addr()?.port()))?; + drop(unused); + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&url))?; + + // A network error is transient, and no authority sent a gRPC status. + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert_eq!(closure.grpc_code, None); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +// oracle: intent crates/agent-identity/INTENT.md §Trust and transport +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn an_untrusted_server_certificate_is_an_outage() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let other_root = Pki::generate()?.ca; + let client = ChannelClient::new(ProxyConfig::Off, &[other_root], Settings::default())?; + let mut channel = client.connect(identity.target(&authority.url))?; + + // A TLS error is transient, and the opening never reaches a server the agent does not trust. + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert!(authority.opening_within(Duration::ZERO).await.is_none()); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[rstest] +#[case::html_over_http2(Mode::Http2Page(StatusCode::NOT_FOUND, "text/html"))] +#[case::html_with_success_over_http2(Mode::Http2Page(StatusCode::OK, "text/html"))] +// oracle: spec gRPC Web (grpc/grpc doc/PROTOCOL-WEB.md) §Protocol differences vs gRPC over HTTP2 +#[case::grpc_web_over_http2(Mode::Http2Page(StatusCode::NOT_FOUND, "application/grpc-web"))] +#[case::http1_without_alpn(Mode::Http1WithoutAlpn)] +#[case::http1_alpn(Mode::Http1Alpn)] +#[tokio::test] +async fn a_path_that_cannot_carry_grpc_is_reported_as_such(#[case] mode: Mode) -> anyhow::Result<()> { + let authority = TestAuthority::start(mode).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::NoGrpcPath)); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +// oracle: spec RFC 9110 §15.6 +// A 5xx status reports a failure of the server, such as a reverse proxy whose upstream is down, not a path that can't +// carry gRPC. +#[rstest] +#[case::internal_server_error(StatusCode::INTERNAL_SERVER_ERROR)] +#[case::bad_gateway(StatusCode::BAD_GATEWAY)] +#[case::service_unavailable(StatusCode::SERVICE_UNAVAILABLE)] +#[case::gateway_timeout(StatusCode::GATEWAY_TIMEOUT)] +#[tokio::test] +async fn a_non_grpc_server_error_is_an_outage(#[case] status: StatusCode) -> anyhow::Result<()> { + let authority = TestAuthority::start(Mode::Http2Page(status, "text/html")).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[rstest] +// oracle: spec gRPC over HTTP2 (grpc/grpc doc/PROTOCOL-HTTP2.md) §Requests +#[case::with_a_message_format("application/grpc+proto")] +// oracle: spec RFC 9110 §8.3.1 +#[case::in_upper_case("Application/GRPC")] +#[tokio::test] +async fn authenticates_whatever_form_the_grpc_content_type_takes( + #[case] content_type: &'static str, +) -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::GrpcAs(content_type)).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + + authority.next_opening().await.accept().authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Handshake +#[rstest] +#[case::shorter(31)] +#[case::longer(33)] +#[tokio::test] +async fn closes_without_hello_when_the_challenge_is_not_32_bytes(#[case] length: usize) -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + + stream.challenge(&vec![1; length]).await; + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert!(!matches!(stream.receive_within(WAIT).await, Some(Ok(Some(_))))); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[rstest] +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +#[case::empty_id(encoded_challenge(""))] +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions +#[case::upper_case_id(encoded_challenge("0F5D2C8E-3B1A-4C6D-9E7F-1A2B3C4D5E6F"))] +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions +#[case::id_without_hyphens(encoded_challenge("0f5d2c8e3b1a4c6d9e7f1a2b3c4d5e6f"))] +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +#[case::id_longer_than_a_reply_may_echo(encoded_challenge(&long_id()))] +#[case::undecodable(truncated(encoded_challenge(&new_id())))] +#[tokio::test] +async fn closes_without_hello_on_a_challenge_it_cannot_trust(#[case] message: Vec) -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, quick_reopening())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service + assert!(message.len() <= 65_536, "the authority may send this message"); + stream.send_encoded(&message); + + assert!(!matches!(stream.receive_within(WAIT).await, Some(Ok(Some(_))))); + let (closure, reopen_after) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert_eq!(reopen_after, Some(Duration::from_micros(15_625))); + authority.next_opening().await; + Ok(()) +} + +/// Settings whose backoff jitter of 1/64 keeps the delays short and exact: 1 s becomes 15.625 ms. +fn quick_reopening() -> Settings { + Settings { + backoff: Backoff { + jitter: Arc::new(|| 1.0 / 64.0), + ..Backoff::default() + }, + ..Settings::default() + } +} + +/// An `id` that fills a `Challenge` envelope to the 65,536 bytes an envelope may take, which a reply that echoes it +/// would exceed ([CONTRACT.agent-identity.md] §7.1). +/// +/// The `Challenge` takes 1 (key of `id`) + 3 (length) + 65,496 (`id`) + 36 (`challenge` record of 32 bytes) bytes. +/// An `Ack` that echoes it takes 38 (`id` record of a UUID) + 1 (key of `correlation_id`) + 3 (length) + 65,496 +/// (`correlation_id`) + 2 (empty `ack` record) = 65,540 bytes, and a `Hello` more. +fn long_id() -> String { + "a".repeat(65_496) +} + +/// Encodes a `Challenge` of 32 bytes under the envelope `id`. +fn encoded_challenge(id: &str) -> Vec { + let mut message = length_delimited(1 /* id */, id.as_bytes()); + message.extend(length_delimited( + 14, /* challenge */ + &length_delimited(1 /* challenge */, &[1; 32]), + )); + message +} + +/// Removes the last byte of an encoded message, which leaves its last record shorter than its length says. +fn truncated(mut message: Vec) -> Vec { + message.pop(); + message +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[rstest] +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +#[case::empty_id(encoded_renewal(""))] +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions +#[case::upper_case_id(encoded_renewal("0F5D2C8E-3B1A-4C6D-9E7F-1A2B3C4D5E6F"))] +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions +#[case::id_without_hyphens(encoded_renewal("0f5d2c8e3b1a4c6d9e7f1a2b3c4d5e6f"))] +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +#[case::id_longer_than_an_ack_may_echo(encoded_renewal(&long_id()))] +#[case::undecodable(truncated(encoded_renewal(&new_id())))] +#[tokio::test] +async fn closes_without_reporting_a_message_it_cannot_trust(#[case] message: Vec) -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, quick_reopening())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service + assert!(message.len() <= 65_536, "the authority may send this message"); + stream.send_encoded(&message); + + // Without a `RenewRequested` event, the caller has nothing to acknowledge. + let (closure, reopen_after) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert_eq!(reopen_after, Some(Duration::from_micros(15_625))); + assert!(!matches!(stream.receive_within(WAIT).await, Some(Ok(Some(_))))); + authority.next_opening().await; + Ok(()) +} + +/// Encodes a `RenewRequested` under the envelope `id`. +fn encoded_renewal(id: &str) -> Vec { + let mut message = length_delimited(1 /* id */, id.as_bytes()); + message.extend(length_delimited( + 12, /* renew_requested */ + &length_delimited(1 /* reason */, b"admin"), + )); + message +} + +// oracle: intent crates/device-key/INTENT.md §Signing +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn a_proof_signing_failure_closes_the_stream_as_an_outage() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let mut identity = Identity::from_vectors("device-a")?; + identity.key = Arc::new(TestKey { + name: identity.key.name.clone(), + key: identity.key.key.clone(), + fail_proofs: true, + }); + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + + // Signing can fail, a failure the contract does not name is transient, and without a proof there is no `Hello`. + stream.challenge(&[1; 32]).await; + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert!(!matches!(stream.receive_within(WAIT).await, Some(Ok(Some(_))))); + Ok(()) +} + +/// The `created` parameter of an opening signature. +fn created(opening: &authority::Opening) -> i64 { + opening + .signature_input() + .split_once(";created=") + .and_then(|(_, rest)| rest.split(';').next()) + .and_then(|created| created.parse().ok()) + .expect("signature-input without created") +} + +fn clock_skew_refusal(server_time: OffsetDateTime) -> Status { + let mut status = Status::new(Code::Unauthenticated, "clock skew"); + let metadata = status.metadata_mut(); + metadata.insert("error-code", "clock_skew".parse().expect("ASCII error code")); + metadata.insert( + "server-time", + server_time + .format(&Rfc3339) + .expect("RFC 3339 time") + .parse() + .expect("ASCII time"), + ); + status +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures +#[tokio::test] +async fn retries_a_clock_skew_refusal_once_at_the_server_time() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let local_time = OffsetDateTime::now_utc(); + let server_time = local_time + Duration::from_secs(7200); + + authority.next_opening().await.refuse(clock_skew_refusal(server_time)); + + // The retry offsets `created` by `server_time − local_time`, from the `server-time` trailer. + // The agent reads its clock after `local_time` and signs before the retry arrives, so `created` exceeds the server + // time by at most the whole seconds that passed meanwhile. + let retry = authority.next_opening().await; + let elapsed = OffsetDateTime::now_utc().unix_timestamp() - local_time.unix_timestamp(); + let retry_created = created(&retry); + let expected = server_time.unix_timestamp(); + assert!( + (expected..=expected + elapsed).contains(&retry_created), + "created {retry_created}, server time {expected}, {elapsed} s elapsed" + ); + retry.accept().authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn a_second_clock_skew_refusal_is_transient() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let server_time = OffsetDateTime::now_utc() + Duration::from_secs(7200); + + authority.next_opening().await.refuse(clock_skew_refusal(server_time)); + authority.next_opening().await.refuse(clock_skew_refusal(server_time)); + + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert_eq!(closure.error_code.as_deref(), Some("clock_skew")); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn reopens_after_each_transient_closure_once_its_growing_delay_has_passed() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + // A jitter of 1/64 keeps the delays short and exact: 1 s becomes 15.625 ms. + let settings = Settings { + backoff: Backoff { + jitter: Arc::new(|| 1.0 / 64.0), + ..Backoff::default() + }, + ..Settings::default() + }; + let mut channel = client(&authority, settings)?.connect(identity.target(&authority.url))?; + + // A timer never fires early (`tokio::time::sleep`), so each reopening comes at least its delay after the closure. + let mut opening = authority.next_opening().await; + for delay in [15_625, 31_250, 62_500].map(Duration::from_micros) { + let refused = tokio::time::Instant::now(); + opening.refuse(closing_status(Code::Unavailable, Some("unavailable"))); + let (_, reopen_after) = next_closure(&mut channel).await; + assert_eq!(reopen_after, Some(delay)); + opening = authority.next_opening().await; + assert!(delay <= refused.elapsed()); + } + Ok(()) +} + +/// Settings that reopen quickly, count a stream as long-lived after `stable_after`, and draw a spread of 40 ms. +fn quick_reopening_stable_after(stable_after: Duration) -> Settings { + let settings = quick_reopening(); + Settings { + backoff: Backoff { + stable_after, + spread: Arc::new(|| Duration::from_millis(40)), + ..settings.backoff + }, + ..settings + } +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn keeps_backing_off_after_a_stream_that_drops_soon_after_authenticating() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let settings = quick_reopening_stable_after(Duration::from_secs(3)); + let mut channel = client(&authority, settings)?.connect(identity.target(&authority.url))?; + authority + .next_opening() + .await + .refuse(closing_status(Code::Unavailable, Some("unavailable"))); + assert_eq!(next_closure(&mut channel).await.1, Some(Duration::from_micros(15_625))); + + // The opening outlasts the stable time, which counts only from `Welcome`. + let mut stream = authority.next_opening().await.accept(); + stream.challenge(&[7; 32]).await; + let hello = stream.receive().await; + // The 3 s stable time leaves a scheduling stall between `Welcome` and the closure room to pass. + tokio::time::sleep(Duration::from_millis(3500)).await; + stream + .send( + &new_id(), + Some(&hello.id), + Payload::Welcome(Welcome { server_time: None }), + ) + .await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + stream + .close(closing_status(Code::Unavailable, Some("unavailable"))) + .await; + + assert_eq!(next_closure(&mut channel).await.1, Some(Duration::from_micros(31_250))); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn spreads_the_reconnect_after_a_stream_that_stayed_authenticated() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let settings = quick_reopening_stable_after(Duration::from_millis(300)); + let mut channel = client(&authority, settings)?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + tokio::time::sleep(Duration::from_millis(400)).await; + stream + .close(closing_status(Code::Unavailable, Some("unavailable"))) + .await; + + assert_eq!(next_closure(&mut channel).await.1, Some(Duration::from_millis(40))); + authority.next_opening().await; + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Handshake +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn an_opening_without_welcome_in_time_is_an_outage() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + // The client must send its `Hello` before the opening times out, which a loaded machine has taken more than 300 ms + // to do; the closure must still come well within `WAIT`. + let settings = Settings { + opening_timeout: Duration::from_secs(2), + ..Settings::default() + }; + let mut channel = client(&authority, settings)?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.challenge(&[1; 32]).await; + stream.receive().await; + + // A network error, which no gRPC status explains. + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert_eq!(closure.grpc_code, None); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal +// oracle: intent crates/agent-channel-client/INTENT.md §Closing +#[tokio::test] +async fn opens_a_new_stream_while_the_current_one_stays_open() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let client = client(&authority, Settings::default())?; + let mut current = client.connect(Identity::from_vectors("device-a")?.target(&authority.url))?; + let mut current_stream = authority.next_opening().await.accept(); + current_stream.authenticate().await; + assert_eq!(next_event(&mut current).await, Event::Authenticated); + + // Step 6 of §7.3 and step 4 of §8.1: the new stream, here for a renewed key, opens before the old one closes. + let mut next = client.connect(Identity::from_vectors("device-b")?.target(&authority.url))?; + let mut next_stream = authority.next_opening().await.accept(); + next_stream.authenticate().await; + assert_eq!(next_event(&mut next).await, Event::Authenticated); + assert!( + current_stream + .receive_within(Duration::from_millis(300)) + .await + .is_none(), + "the current stream ended before the caller closed it" + ); + + drop(current); + assert!(matches!( + current_stream.receive_within(WAIT).await, + Some(Ok(None) | Err(_)) + )); + assert!(next_stream.receive_within(Duration::from_millis(300)).await.is_none()); + Ok(()) +} + +fn manual_proxy(proxy: &str, exclude: &[&str]) -> anyhow::Result { + Ok(ProxyConfig::Manual(http_client_proxy::ManualProxyConfig { + https: Some(Url::parse(proxy)?), + exclude: exclude.iter().map(|pattern| (*pattern).to_owned()).collect(), + ..Default::default() + })) +} + +/// The value of the header `name` in an HTTP/1.1 request head. +fn header<'head>(head: &'head str, name: &str) -> Option<&'head str> { + head.lines() + .filter_map(|line| line.split_once(':')) + .find(|(field, _)| field.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.trim()) +} + +fn authority_address(authority: &TestAuthority) -> String { + format!("127.0.0.1:{}", authority.url.port().expect("authority port")) +} + +// oracle: intent crates/agent-identity/INTENT.md §Trust and transport +// oracle: reference reqwest 0.12.28 `IntoProxy`, which reads a proxy URL without a scheme as an `http://` URL +// Identity requests go through such a proxy, so the channel must too. +#[tokio::test] +async fn tunnels_through_a_proxy_set_without_a_scheme_as_an_http_proxy() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let proxy = TestProxy::start(None).await?; + let port = proxy.host.rsplit_once(':').ok_or_else(|| anyhow!("proxy port"))?.1; + let identity = Identity::from_vectors("device-a")?; + let client = ChannelClient::new( + manual_proxy(&format!("localhost:{port}"), &[])?, + std::slice::from_ref(&authority.ca), + Settings::default(), + )?; + let mut channel = client.connect(identity.target(&authority.url))?; + authority.next_opening().await.accept().authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + assert_eq!(proxy.heads().len(), 1); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[rstest] +// oracle: reference hyper-util 0.1.20 proxy matcher, which percent-decodes the user info for reqwest +// The user info decodes to `agent@corp` and `p:ss`. +#[case::user_and_password("agent%40corp:p%3Ass", "agent@corp:p:ss")] +// oracle: reference hyper-util 0.1.20 `test_parse_http_auth_without_password`, the proxy parser of reqwest +#[case::user_only("Aladdin", "Aladdin:")] +#[tokio::test] +async fn tunnels_through_an_http_proxy_with_the_credentials_of_its_url( + #[case] user_info: &str, + #[case] credentials: &str, +) -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let proxy = TestProxy::start(None).await?; + let identity = Identity::from_vectors("device-a")?; + let proxy_config = manual_proxy(&format!("http://{user_info}@{}", proxy.host), &[])?; + let client = ChannelClient::new(proxy_config, std::slice::from_ref(&authority.ca), Settings::default())?; + let mut channel = client.connect(identity.target(&authority.url))?; + authority.next_opening().await.accept().authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + let heads = proxy.heads(); + assert_eq!(heads.len(), 1); + let head = &heads[0]; + // oracle: spec RFC 9110 §9.3.6 + assert!( + head.starts_with(&format!("CONNECT {} HTTP/1.1\r\n", authority_address(&authority))), + "{head}" + ); + // oracle: spec RFC 7617 §2 + // Basic credentials are the Base64 of the user ID, a colon and the password. + let authorization = format!("Basic {}", STANDARD.encode(credentials)); + assert_eq!(header(head, "proxy-authorization"), Some(authorization.as_str())); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn tunnels_through_an_https_proxy() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let proxy_pki = Pki::generate()?; + // oracle: spec RFC 7301 §3.2 + // A proxy that also offers HTTP/2 would select it if the agent offered it, and then expect HTTP/2. + let proxy = TestProxy::start(Some(proxy_pki.server_config(&[b"h2", b"http/1.1"])?)).await?; + let identity = Identity::from_vectors("device-a")?; + let proxy_config = manual_proxy(&format!("https://{}", proxy.host), &[])?; + let roots = [authority.ca.clone(), proxy_pki.ca.clone()]; + let mut channel = + ChannelClient::new(proxy_config, &roots, Settings::default())?.connect(identity.target(&authority.url))?; + authority.next_opening().await.accept().authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + let heads = proxy.heads(); + assert_eq!(heads.len(), 1); + // oracle: spec RFC 9110 §9.3.6 + assert!( + heads[0].starts_with(&format!("CONNECT {} HTTP/1.1\r\n", authority_address(&authority))), + "{}", + heads[0] + ); + assert_eq!(header(&heads[0], "proxy-authorization"), None); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn connects_directly_to_a_host_the_proxy_excludes() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let proxy = TestProxy::start(None).await?; + let identity = Identity::from_vectors("device-a")?; + let proxy_config = manual_proxy(&format!("http://{}", proxy.host), &["127.0.0.1"])?; + let client = ChannelClient::new(proxy_config, std::slice::from_ref(&authority.ca), Settings::default())?; + let mut channel = client.connect(identity.target(&authority.url))?; + authority.next_opening().await.accept().authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + assert!(proxy.heads().is_empty()); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +// oracle: reference reqwest 0.12.28 `connect_socks`, which resolves the name locally for `socks5` but not `socks5h` +#[rstest] +#[case::socks5("socks5://agent%40corp:p%3Ass@", Some(("agent@corp", "p:ss")), None)] +#[case::socks5h("socks5h://", None, Some("localhost"))] +#[tokio::test] +async fn tunnels_through_a_socks5_proxy_resolving_the_name_where_its_scheme_says( + #[case] proxy_prefix: &str, + #[case] credentials: Option<(&str, &str)>, + #[case] domain: Option<&str>, +) -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let proxy = TestSocksProxy::start().await?; + let identity = Identity::from_vectors("device-a")?; + let mut url = authority.url.clone(); + url.set_host(Some("localhost"))?; + let proxy_config = manual_proxy(&format!("{proxy_prefix}{}", proxy.host), &[])?; + let client = ChannelClient::new(proxy_config, std::slice::from_ref(&authority.ca), Settings::default())?; + let mut channel = client.connect(identity.target(&url))?; + authority.next_opening().await.accept().authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + let expected = SocksRequest { + credentials: credentials.map(|(user, password)| (user.to_owned(), password.to_owned())), + domain: domain.map(str::to_owned), + }; + assert_eq!(proxy.requests(), [expected]); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +// oracle: reference reqwest 0.12.28 `connect_socks`, which resolves the name locally for `socks4` +#[tokio::test] +async fn sends_a_socks4_proxy_the_address_of_the_authority() -> anyhow::Result<()> { + let authority = TestAuthority::start(Mode::Grpc).await?; + let proxy = TestSocksProxy::start().await?; + let identity = Identity::from_vectors("device-a")?; + let mut url = authority.url.clone(); + url.set_host(Some("localhost"))?; + let proxy_config = manual_proxy(&format!("socks4://{}", proxy.host), &[])?; + let client = ChannelClient::new(proxy_config, std::slice::from_ref(&authority.ca), Settings::default())?; + let _channel = client.connect(identity.target(&url))?; + + // Only the request counts: the NUL that hyper-util adds after the user ID reaches the authority through the tunnel, + // as the known limitation in `Transport::tunnel` says. + wait_until("a SOCKS request", || !proxy.requests().is_empty()).await; + let expected = SocksRequest { + credentials: None, + domain: None, + }; + assert_eq!(proxy.requests().first(), Some(&expected)); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +#[tokio::test] +async fn an_unsupported_proxy_scheme_is_an_outage_without_a_connection() -> anyhow::Result<()> { + let authority = TestAuthority::start(Mode::Grpc).await?; + let proxy = TestProxy::start(None).await?; + let identity = Identity::from_vectors("device-a")?; + let proxy_config = manual_proxy(&format!("ftp://{}", proxy.host), &[])?; + let client = ChannelClient::new(proxy_config, std::slice::from_ref(&authority.ca), Settings::default())?; + let mut channel = client.connect(identity.target(&authority.url))?; + + // The channel connects only through the configured proxy, which can't carry it here, so it fails without a + // connection, neither through the proxy nor around it; a failure the contract does not name is transient. + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert!(proxy.heads().is_empty()); + Ok(()) +} + +fn unreadable_system_proxy(_: &Url) -> anyhow::Result> { + anyhow::bail!("system proxy settings unreadable") +} + +fn unparseable_system_proxy(_: &Url) -> anyhow::Result> { + // The port is out of range. + Ok(Some("http://proxy.test:99999".to_owned())) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +#[rstest] +#[case::detection_fails(unreadable_system_proxy)] +#[case::unparseable_proxy(unparseable_system_proxy)] +#[tokio::test] +async fn a_system_proxy_the_channel_cannot_read_is_an_outage_without_a_connection( + #[case] system_proxy: fn(&Url) -> anyhow::Result>, +) -> anyhow::Result<()> { + // The authority counts the connections it receives and closes each one. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let url = Url::parse(&format!("https://127.0.0.1:{}/dvls", listener.local_addr()?.port()))?; + let connections = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&connections); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + counter.fetch_add(1, Ordering::SeqCst); + drop(stream); + } + }); + let identity = Identity::from_vectors("device-a")?; + let mut client = ChannelClient::new(ProxyConfig::System, &[], Settings::default())?; + Arc::get_mut(&mut client.shared) + .ok_or_else(|| anyhow!("client shared"))? + .transport + .system_proxy = system_proxy; + let mut channel = client.connect(identity.target(&url))?; + + let (closure, _) = next_closure(&mut channel).await; + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert_eq!(connections.load(Ordering::SeqCst), 0); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url +#[tokio::test] +async fn refuses_a_channel_url_without_tls() -> anyhow::Result<()> { + let authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut url = authority.url.clone(); + url.set_scheme("http").expect("http is a valid scheme"); + + // The channel URL is an `https` URL. + assert!( + client(&authority, Settings::default())? + .connect(identity.target(&url)) + .is_err() + ); + Ok(()) +} + +#[test] +fn the_default_timings_are_the_documented_ones() { + let settings = Settings::default(); + // oracle: intent crates/agent-channel-client/INTENT.md §Connection + assert_eq!(settings.keepalive_interval, Duration::from_secs(30)); + // oracle: intent crates/agent-channel-client/INTENT.md §Handshake + assert_eq!(settings.opening_timeout, Duration::from_secs(30)); +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Closing +#[tokio::test] +async fn sends_the_acknowledgements_queued_before_the_channel_is_dropped() -> anyhow::Result<()> { + // The agent stores a config, replies `Ack`, then applies it, which may close the stream (contract §7.3, step 7). + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + let update_id = new_id(); + stream + .send( + &update_id, + None, + Payload::ConfigUpdate(ConfigUpdate { + config_json: r#"{"version":1,"revision":2}"#.to_owned(), + }), + ) + .await; + next_event(&mut channel).await; + + channel.ack(&update_id); + drop(channel); + + let ack = stream.receive_within(WAIT).await; + assert!( + matches!(&ack, Some(Ok(Some(message))) if message.correlation_id.as_deref() == Some(update_id.as_str())), + "{ack:?}" + ); + // The agent then ends its side, which the authority reads as the end of the stream rather than a reset. + let end = stream.receive_within(WAIT).await; + assert!(matches!(end, Some(Ok(None))), "{end:?}"); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Closing +#[tokio::test] +async fn dropping_the_channel_stops_an_opening_at_once() -> anyhow::Result<()> { + // An opening signed with a key that a `confirm` retired draws the terminal `identity_unknown` (contract §6, step + // 4), so the caller's drop must end it. + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + + drop(channel); + + assert!(matches!(stream.receive_within(WAIT).await, Some(Ok(None) | Err(_)))); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Closing +#[tokio::test] +async fn dropping_the_channel_stops_it_from_reopening() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let settings = Settings { + backoff: Backoff { + jitter: Arc::new(|| 0.5), + ..Backoff::default() + }, + ..Settings::default() + }; + let mut channel = client(&authority, settings)?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + stream + .close(closing_status(Code::Unavailable, Some("unavailable"))) + .await; + let (_, reopen_after) = next_closure(&mut channel).await; + assert_eq!(reopen_after, Some(Duration::from_millis(500))); + + drop(channel); + + // Without the drop, the channel would reopen 500 ms after the closure, well within this wait. + assert!(authority.opening_within(Duration::from_secs(2)).await.is_none()); + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Closing +#[tokio::test] +async fn dropping_the_channel_ends_its_wait_to_reopen_at_once() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + // A jitter of 1000 makes the first wait 1000 s, which the cap shortens to 5 min, far beyond the test's wait. + let settings = Settings { + backoff: Backoff { + jitter: Arc::new(|| 1000.0), + ..Backoff::default() + }, + ..Settings::default() + }; + let hello = hello_fields(); + let target = identity.target_with_hello(&authority.url, Arc::clone(&hello)); + let mut channel = client(&authority, settings)?.connect(target)?; + authority + .next_opening() + .await + .refuse(closing_status(Code::Unavailable, Some("unavailable"))); + let (_, reopen_after) = next_closure(&mut channel).await; + assert_eq!(reopen_after, Some(Duration::from_secs(300))); + + drop(channel); + + // A stopped channel no longer holds its target. + wait_until("the release of the target", || Arc::strong_count(&hello) == 1).await; + Ok(()) +} + +// oracle: intent crates/agent-channel-client/INTENT.md §Closing +#[tokio::test] +async fn a_dropped_channel_stops_waiting_for_an_authority_that_keeps_its_side_open() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let hello = hello_fields(); + let target = identity.target_with_hello(&authority.url, Arc::clone(&hello)); + let mut channel = client(&authority, Settings::default())?.connect(target)?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + drop(channel); + + // The authority's side stays open while `stream` lives; the channel gives it 5 s, less than `WAIT`. + wait_until("the release of the target", || Arc::strong_count(&hello) == 1).await; + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence +#[tokio::test] +async fn drops_an_acknowledgement_of_a_closed_stream() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let settings = Settings { + backoff: Backoff { + jitter: Arc::new(|| 1.0 / 64.0), + ..Backoff::default() + }, + ..Settings::default() + }; + let mut channel = client(&authority, settings)?.connect(identity.target(&authority.url))?; + let renew = |reason: &str| { + Payload::RenewRequested(RenewRequested { + reason: reason.to_owned(), + }) + }; + + let mut first = authority.next_opening().await.accept(); + first.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + let old_id = new_id(); + first.send(&old_id, None, renew("admin")).await; + next_event(&mut channel).await; + first + .close(closing_status(Code::Unavailable, Some("unavailable"))) + .await; + next_closure(&mut channel).await; + channel.ack(&old_id); + + // An `Ack` replies to a message of its own stream (step 5), so the next stream carries only the new one. + let mut second = authority.next_opening().await.accept(); + second.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + let new_id = new_id(); + second.send(&new_id, None, renew("rotation")).await; + next_event(&mut channel).await; + channel.ack(&new_id); + assert_eq!(second.receive().await.correlation_id, Some(new_id)); + Ok(()) +} + +/// Checks that a closure with gRPC status OK keeps its trailers and, with an unknown error code, stays transient. +fn assert_ok_closure_with_trailers(closure: &Closure) { + assert_eq!(closure.class, ClosureClass::Transient(TransientKind::Outage)); + assert_eq!(closure.grpc_code, Some(Code::Ok)); + assert_eq!(closure.error_code.as_deref(), Some("future_code")); + assert_eq!(closure.server_time.as_deref(), Some(SERVER_TIME)); +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn reports_the_trailers_of_an_opening_refused_with_ok() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + + authority + .next_opening() + .await + .refuse(closing_status(Code::Ok, Some("future_code"))); + assert_ok_closure_with_trailers(&next_closure(&mut channel).await.0); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn reports_the_trailers_of_a_stream_closed_with_ok_before_welcome() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.challenge(&[1; 32]).await; + stream.receive().await; + + stream.close(closing_status(Code::Ok, Some("future_code"))).await; + assert_ok_closure_with_trailers(&next_closure(&mut channel).await.0); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors +// oracle: intent crates/agent-channel-client/INTENT.md §Connection +#[tokio::test] +async fn reports_the_trailers_of_a_stream_closed_with_ok_after_welcome() -> anyhow::Result<()> { + let mut authority = TestAuthority::start(Mode::Grpc).await?; + let identity = Identity::from_vectors("device-a")?; + let mut channel = client(&authority, Settings::default())?.connect(identity.target(&authority.url))?; + let mut stream = authority.next_opening().await.accept(); + stream.authenticate().await; + assert_eq!(next_event(&mut channel).await, Event::Authenticated); + + // A retired certificate's streams are closed with OK. + stream.close(closing_status(Code::Ok, Some("future_code"))).await; + assert_ok_closure_with_trailers(&next_closure(&mut channel).await.0); + Ok(()) +} diff --git a/crates/agent-channel-client/src/tests/proxy.rs b/crates/agent-channel-client/src/tests/proxy.rs new file mode 100644 index 000000000..169a2b65a --- /dev/null +++ b/crates/agent-channel-client/src/tests/proxy.rs @@ -0,0 +1,93 @@ +//! An HTTP CONNECT proxy for the channel tests. + +use std::sync::{Arc, Mutex}; + +use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; + +/// A proxy listening on loopback that tunnels every `CONNECT` and records its request head. +pub(super) struct TestProxy { + /// The `host:port` the proxy listens on. + pub(super) host: String, + heads: Arc>>, + task: JoinHandle<()>, +} + +impl TestProxy { + /// Starts a proxy; with `tls`, clients reach it over TLS, as an `https://` proxy. + /// + /// Over TLS, a client that negotiates HTTP/2 through ALPN gets no tunnel, since its HTTP/1.1 `CONNECT` is not the + /// HTTP/2 the proxy then expects. + pub(super) async fn start(tls: Option) -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let host = listener.local_addr()?.to_string(); + let acceptor = tls.map(|config| TlsAcceptor::from(Arc::new(config))); + let heads = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&heads); + let task = tokio::spawn(async move { + while let Ok((tcp, _)) = listener.accept().await { + let acceptor = acceptor.clone(); + let recorded = Arc::clone(&recorded); + tokio::spawn(async move { + match acceptor { + Some(acceptor) => { + if let Ok(tls) = acceptor.accept(tcp).await + && tls.get_ref().1.alpn_protocol() != Some(b"h2".as_slice()) + { + tunnel(tls, &recorded).await; + } + } + None => tunnel(tcp, &recorded).await, + } + }); + } + }); + Ok(Self { host, heads, task }) + } + + /// The request heads of the tunnels clients asked for. + pub(super) fn heads(&self) -> Vec { + self.heads.lock().expect("proxy heads lock").clone() + } +} + +impl Drop for TestProxy { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn tunnel(mut client: S, heads: &Mutex>) +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut head = Vec::new(); + let mut chunk = [0; 1024]; + while !head.ends_with(b"\r\n\r\n") { + match client.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(read) => head.extend_from_slice(&chunk[..read]), + } + } + let head = String::from_utf8_lossy(&head).into_owned(); + heads.lock().expect("proxy heads lock").push(head.clone()); + let Some(target) = head + .strip_prefix("CONNECT ") + .and_then(|rest| rest.split_once(' ')) + .map(|(target, _)| target.to_owned()) + else { + return; + }; + let Ok(mut upstream) = TcpStream::connect(target).await else { + return; + }; + if client + .write_all(b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .is_ok() + { + let _ = tokio::io::copy_bidirectional(&mut client, &mut upstream).await; + } +} diff --git a/crates/agent-channel-client/src/tests/socks.rs b/crates/agent-channel-client/src/tests/socks.rs new file mode 100644 index 000000000..6c6d50b08 --- /dev/null +++ b/crates/agent-channel-client/src/tests/socks.rs @@ -0,0 +1,149 @@ +//! A SOCKS proxy for the channel tests: SOCKS5 (RFC 1928, with RFC 1929 user/password authentication), and SOCKS4 +//! with its 4A extension for names. + +use std::net::Ipv4Addr; +use std::sync::{Arc, Mutex}; + +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::JoinHandle; + +/// One `CONNECT` request the proxy received. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SocksRequest { + /// The user name and password of a user/password authentication. + pub(super) credentials: Option<(String, String)>, + /// The destination name, when the client left resolution to the proxy; `None` for an address. + pub(super) domain: Option, +} + +/// A SOCKS proxy listening on loopback that connects every request to its port on `127.0.0.1`. +pub(super) struct TestSocksProxy { + /// The `host:port` the proxy listens on. + pub(super) host: String, + requests: Arc>>, + task: JoinHandle<()>, +} + +impl TestSocksProxy { + pub(super) async fn start() -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let host = listener.local_addr()?.to_string(); + let requests = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&requests); + let task = tokio::spawn(async move { + while let Ok((client, _)) = listener.accept().await { + let recorded = Arc::clone(&recorded); + tokio::spawn(async move { + let _ = serve(client, &recorded).await; + }); + } + }); + Ok(Self { host, requests, task }) + } + + pub(super) fn requests(&self) -> Vec { + self.requests.lock().expect("SOCKS requests lock").clone() + } +} + +impl Drop for TestSocksProxy { + fn drop(&mut self) { + self.task.abort(); + } +} + +const VERSION: u8 = 5; +const SOCKS4_VERSION: u8 = 4; +const CONNECT: u8 = 1; +const SOCKS4_GRANTED: u8 = 90; +const NO_AUTHENTICATION: u8 = 0x00; +const USER_PASSWORD: u8 = 0x02; +const IPV4: u8 = 0x01; +const DOMAIN: u8 = 0x03; +const IPV6: u8 = 0x04; + +async fn serve(mut client: TcpStream, requests: &Mutex>) -> anyhow::Result<()> { + match client.read_u8().await? { + VERSION => {} + SOCKS4_VERSION => return serve_socks4(client, requests).await, + version => anyhow::bail!("unknown SOCKS version {version}"), + } + let mut methods = vec![0; usize::from(client.read_u8().await?)]; + client.read_exact(&mut methods).await?; + let credentials = if methods.contains(&USER_PASSWORD) { + client.write_all(&[VERSION, USER_PASSWORD]).await?; + anyhow::ensure!(client.read_u8().await? == 1, "unknown authentication version"); + let user = read_length_prefixed(&mut client).await?; + let password = read_length_prefixed(&mut client).await?; + client.write_all(&[1, 0x00]).await?; + Some((user, password)) + } else { + anyhow::ensure!(methods.contains(&NO_AUTHENTICATION), "no supported method"); + client.write_all(&[VERSION, NO_AUTHENTICATION]).await?; + None + }; + let mut header = [0; 4]; + client.read_exact(&mut header).await?; + anyhow::ensure!(header[..2] == [VERSION, CONNECT], "not a CONNECT request"); + let domain = match header[3] { + IPV4 => { + client.read_exact(&mut [0; 4]).await?; + None + } + DOMAIN => Some(read_length_prefixed(&mut client).await?), + IPV6 => { + client.read_exact(&mut [0; 16]).await?; + None + } + address_type => anyhow::bail!("unknown address type {address_type}"), + }; + let port = client.read_u16().await?; + requests + .lock() + .expect("SOCKS requests lock") + .push(SocksRequest { credentials, domain }); + let mut upstream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).await?; + client.write_all(&[VERSION, 0x00, 0x00, IPV4, 0, 0, 0, 0, 0, 0]).await?; + tokio::io::copy_bidirectional(&mut client, &mut upstream).await?; + Ok(()) +} + +async fn read_length_prefixed(client: &mut TcpStream) -> anyhow::Result { + let mut bytes = vec![0; usize::from(client.read_u8().await?)]; + client.read_exact(&mut bytes).await?; + Ok(String::from_utf8(bytes)?) +} + +/// Serves a SOCKS4 `CONNECT` whose version byte `serve` has read; an address `0.0.0.x` with a nonzero `x` announces a +/// SOCKS4A name after the user ID. +async fn serve_socks4(mut client: TcpStream, requests: &Mutex>) -> anyhow::Result<()> { + anyhow::ensure!(client.read_u8().await? == CONNECT, "not a CONNECT request"); + let port = client.read_u16().await?; + let mut address = [0; 4]; + client.read_exact(&mut address).await?; + let user = read_null_terminated(&mut client).await?; + let domain = match address { + [0, 0, 0, last] if last != 0 => Some(read_null_terminated(&mut client).await?), + _ => None, + }; + let credentials = (!user.is_empty()).then(|| (user, String::new())); + requests + .lock() + .expect("SOCKS requests lock") + .push(SocksRequest { credentials, domain }); + let mut upstream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).await?; + client.write_all(&[0, SOCKS4_GRANTED, 0, 0, 0, 0, 0, 0]).await?; + tokio::io::copy_bidirectional(&mut client, &mut upstream).await?; + Ok(()) +} + +async fn read_null_terminated(client: &mut TcpStream) -> anyhow::Result { + let mut bytes = Vec::new(); + loop { + match client.read_u8().await? { + 0 => return Ok(String::from_utf8(bytes)?), + byte => bytes.push(byte), + } + } +} diff --git a/crates/agent-channel-client/src/transport.rs b/crates/agent-channel-client/src/transport.rs new file mode 100644 index 000000000..0e8ae0bf8 --- /dev/null +++ b/crates/agent-channel-client/src/transport.rs @@ -0,0 +1,359 @@ +//! The TLS and HTTP/2 connection that carries one stream. + +use std::error::Error; +use std::fmt; +use std::future::{Future, poll_fn}; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use anyhow::Context as _; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use http::{HeaderValue, Request, Response, Uri}; +use http_client_proxy::ProxyConfig; +use hyper::body::Incoming; +use hyper::client::conn::http2; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::client::legacy::connect::proxy::{SocksV4, SocksV5, Tunnel}; +use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer}; +use percent_encoding::percent_decode_str; +use rustls::pki_types::{CertificateDer, ServerName}; +use rustls::{AlertDescription, RootCertStore}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::TcpStream; +use tokio_rustls::TlsConnector; +use tokio_rustls::client::TlsStream; +use tonic::body::BoxBody; +use tower::Service; +use url::{Host, Url}; + +use crate::closure::{Closure, TransientKind, error_chain}; + +const ALPN_H2: &[u8] = b"h2"; +/// The port of a SOCKS proxy URL without one (RFC 1928 §3). +const SOCKS_PORT: u16 = 1080; +/// How long a keepalive ping may go unanswered before the connection counts as lost. +// UNTESTED: an unanswered keepalive ping fails the connection after this timeout. +// That turns a silently dropped connection, for example after a NAT idle timeout, into a closure the channel reopens. +// When changing the HTTP/2 builder, keep a keepalive timeout and `keep_alive_while_idle`. +const KEEPALIVE_ACK_TIMEOUT: Duration = Duration::from_secs(20); + +pub(crate) struct Transport { + /// Verifies authorities and offers HTTP/2. + tls: Arc, + /// Verifies HTTPS proxies. + proxy_tls: Arc, + proxy: ProxyConfig, + /// Reads the system proxy for a URL; tests replace it, since the real settings are machine state. + pub(crate) system_proxy: fn(&Url) -> anyhow::Result>, +} + +/// A connection the authority's TLS session runs over: direct, or tunnelled through a proxy. +trait Io: AsyncRead + AsyncWrite + Send + Unpin {} + +impl Io for T {} + +type BoxError = Box; + +impl Transport { + pub(crate) fn new(proxy: ProxyConfig, extra_trusted_roots: &[CertificateDer<'_>]) -> anyhow::Result { + let mut roots = RootCertStore::empty(); + // UNTESTED: authorities and HTTPS proxies are trusted through the OS trust store. + // The tests trust only their own roots, since adding one to the OS store would change the machine. + // When changing this, check a connection to an authority whose certificate chains to an OS root. + roots.add_parsable_certificates(rustls_native_certs::load_native_certs().certs); + for root in extra_trusted_roots { + roots.add(root.clone().into_owned()).context("add extra trusted root")?; + } + let roots = Arc::new(roots); + let config = |alpn_protocols: Vec>| -> anyhow::Result> { + let mut config = + rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .context("configure TLS versions")? + .with_root_certificates(Arc::clone(&roots)) + .with_no_client_auth(); + config.alpn_protocols = alpn_protocols; + Ok(Arc::new(config)) + }; + Ok(Self { + tls: config(vec![ALPN_H2.to_vec()])?, + proxy_tls: config(Vec::new())?, + proxy, + system_proxy: http_client_proxy::system_proxy_for_url, + }) + } + + /// Connects to the authority of `url`, through the proxy the agent's proxy settings select for it, and completes + /// the HTTP/2 handshake. + pub(crate) async fn connect(&self, url: &Url, keepalive_interval: Duration) -> Result { + let (server_name, origin) = endpoint(url).map_err(|error| Closure::outage(format!("{error:#}")))?; + let proxy = http_client_proxy::try_proxy_for_url(url, &self.proxy, self.system_proxy) + .map_err(|error| Closure::outage(format!("system proxy: {error:#}")))?; + let io: Box = match proxy { + Some(proxy) => self.tunnel(&proxy, origin).await?, + None => Box::new(tcp(origin).await.map_err(outage("connect"))?), + }; + let tls = TlsConnector::from(Arc::clone(&self.tls)) + .connect(server_name, io) + .await + .map_err(|error| { + // A server that supports no protocol the client offers refuses ALPN with this alert (RFC 7301 §3.2). + let refused_h2 = matches!( + error.get_ref().and_then(|inner| inner.downcast_ref::()), + Some(rustls::Error::AlertReceived(AlertDescription::NoApplicationProtocol)) + ); + if refused_h2 { + Closure::transient(TransientKind::NoGrpcPath, "the server refused HTTP/2") + } else { + outage("TLS handshake")(error) + } + })?; + if tls.get_ref().1.alpn_protocol() != Some(ALPN_H2) { + return Err(Closure::transient( + TransientKind::NoGrpcPath, + "the server did not negotiate HTTP/2", + )); + } + let (send_request, connection) = http2::Builder::new(TokioExecutor::new()) + .timer(TokioTimer::new()) + .keep_alive_interval(keepalive_interval) + .keep_alive_timeout(KEEPALIVE_ACK_TIMEOUT) + .keep_alive_while_idle(true) + .handshake(TokioIo::new(tls)) + .await + .map_err(outage("HTTP/2 handshake"))?; + tokio::spawn(async move { + let _ = connection.await; + }); + Ok(Http2Service(send_request)) + } + + /// Opens a tunnel to `target` through `proxy`, with the proxy credentials of its user info, as reqwest does for + /// the agent's identity requests: HTTP CONNECT for `http` and `https` proxies, and SOCKS for `socks4`, + /// `socks4a`, `socks5` and `socks5h` proxies. + async fn tunnel(&self, proxy: &Url, target: Uri) -> Result, Closure> { + let scheme = proxy.scheme(); + let is_socks = matches!(scheme, "socks4" | "socks4a" | "socks5" | "socks5h"); + let port = if is_socks { + proxy.port().or(Some(SOCKS_PORT)) + } else { + proxy.port_or_known_default() + }; + let proxy_endpoint = match (proxy.host_str(), port) { + (Some(host), Some(port)) => format!("{scheme}://{host}:{port}") + .parse::() + .map_err(outage("proxy URL"))?, + _ => return Err(Closure::outage("proxy URL without host")), + }; + let credentials = credentials(proxy); + match scheme { + "http" => { + let tunnel = open_tunnel(http_connector(), proxy_endpoint, credentials, target).await?; + Ok(Box::new(tunnel.into_inner())) + } + "https" => { + let tls = Arc::clone(&self.proxy_tls); + let connector = tower::service_fn(move |proxy: Uri| tls_to_proxy(Arc::clone(&tls), proxy)); + let tunnel = open_tunnel(connector, proxy_endpoint, credentials, target).await?; + Ok(Box::new(tunnel.into_inner())) + } + // `socks5` and `socks4` resolve the authority's name locally; `socks5h` and `socks4a` leave it to the proxy. + "socks5" | "socks5h" => { + let target = if scheme == "socks5" { + resolve_locally(target).await? + } else { + target + }; + let mut socks = SocksV5::new(proxy_endpoint, http_connector()); + if let Some((user, password)) = credentials { + socks = socks.with_auth(user, password); + } + let tunnel = connect_through(socks, target, "SOCKS5 handshake").await?; + Ok(Box::new(tunnel.into_inner())) + } + "socks4" | "socks4a" => { + // Known limitation: hyper-util 0.1.20's `SocksV4` (`proxy/socks/v4/messages.rs`) sends an extra NUL + // after the empty user ID, so a proxy that follows SOCKS4 tunnels it to the authority, or reads an + // empty SOCKS4a name. + // The agent's identity requests go through the same `SocksV4` in reqwest, and upgrading hyper-util + // past its fix repairs both. + let target = if scheme == "socks4" { + resolve_locally(target).await? + } else { + target + }; + let socks = SocksV4::new(proxy_endpoint, http_connector()); + let tunnel = connect_through(socks, target, "SOCKS4 handshake").await?; + Ok(Box::new(tunnel.into_inner())) + } + scheme => Err(Closure::outage(format!("unsupported proxy scheme {scheme}"))), + } + } +} + +fn http_connector() -> HttpConnector { + let mut connector = HttpConnector::new(); + connector.enforce_http(false); + connector.set_nodelay(true); + connector +} + +async fn tcp(endpoint: Uri) -> Result { + let mut connector = http_connector(); + poll_fn(|cx| connector.poll_ready(cx)).await?; + Ok(connector.call(endpoint).await?.into_inner()) +} + +async fn tls_to_proxy(tls: Arc, proxy: Uri) -> Result>, BoxError> { + let host = proxy.host().ok_or("proxy URL without host")?; + let server_name = ServerName::try_from(host.trim_start_matches('[').trim_end_matches(']').to_owned())?; + let tcp = tcp(proxy).await?; + Ok(TokioIo::new(TlsConnector::from(tls).connect(server_name, tcp).await?)) +} + +/// Opens an HTTP CONNECT tunnel through the proxy that `connector` reaches, with Basic `credentials`. +async fn open_tunnel( + connector: C, + proxy: Uri, + credentials: Option<(String, String)>, + target: Uri, +) -> Result +where + C: Service, + C::Future: Send + 'static, + C::Response: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static, + C::Error: Into, +{ + let mut tunnel = Tunnel::new(proxy, connector); + if let Some((user, password)) = credentials { + let authorization = HeaderValue::try_from(format!("Basic {}", STANDARD.encode(format!("{user}:{password}")))) + .map_err(outage("proxy credentials"))?; + tunnel = tunnel.with_auth(authorization); + } + connect_through(tunnel, target, "proxy tunnel").await +} + +/// Connects to `target` through the proxy `connector`, which reports its failures as the failures of `step`. +async fn connect_through(mut connector: C, target: Uri, step: &'static str) -> Result +where + C: Service, + C::Error: Into, +{ + poll_fn(|cx| connector.poll_ready(cx)).await.map_err(outage(step))?; + connector.call(target).await.map_err(outage(step)) +} + +/// Replaces the name in `target` by one of its addresses, preferring IPv4, which every SOCKS version can carry. +async fn resolve_locally(target: Uri) -> Result { + let (Some(host), Some(port)) = (target.host(), target.port_u16()) else { + return Err(Closure::outage("channel origin without host")); + }; + let address = tokio::net::lookup_host((host.trim_matches(['[', ']']), port)) + .await + .map_err(outage("resolve the authority"))? + .min_by_key(|address| !address.is_ipv4()) + .ok_or_else(|| Closure::outage("resolve the authority: no address"))?; + format!("https://{address}") + .parse() + .map_err(outage("resolve the authority")) +} + +/// Returns the percent-decoded user name and password of `proxy`, as reqwest takes them for the agent's identity +/// requests. +fn credentials(proxy: &Url) -> Option<(String, String)> { + if proxy.username().is_empty() && proxy.password().is_none() { + return None; + } + let decode = |part: &str| percent_decode_str(part).decode_utf8_lossy().into_owned(); + Some((decode(proxy.username()), decode(proxy.password().unwrap_or_default()))) +} + +fn outage>(step: &'static str) -> impl FnOnce(E) -> Closure { + move |error| { + let error: BoxError = error.into(); + Closure::outage(error_chain(step, &*error)) + } +} + +/// Returns the TLS server name and the `https` origin of `url`. +pub(crate) fn endpoint(url: &Url) -> anyhow::Result<(ServerName<'static>, Uri)> { + let host = match url.host() { + Some(Host::Domain(domain)) => domain.to_owned(), + Some(Host::Ipv4(address)) => address.to_string(), + Some(Host::Ipv6(address)) => address.to_string(), + None => anyhow::bail!("channel URL without host"), + }; + let port = url.port_or_known_default().context("channel URL without port")?; + let origin = format!("https://{}:{port}", url.host_str().unwrap_or_default()) + .parse() + .context("build channel origin")?; + Ok((ServerName::try_from(host).context("invalid TLS server name")?, origin)) +} + +/// Sends requests on one HTTP/2 connection, and fails each response that is not gRPC with [`NotGrpc`]. +pub(crate) struct Http2Service(http2::SendRequest); + +impl Service> for Http2Service { + type Response = Response; + type Error = BoxError; + type Future = Pin, BoxError>> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.0.poll_ready(cx).map_err(Into::into) + } + + fn call(&mut self, request: Request) -> Self::Future { + let response = self.0.send_request(request); + Box::pin(async move { + let response = response.await?; + if is_grpc(&response) { + Ok(response) + } else { + Err(NotGrpc(response.status()).into()) + } + }) + } +} + +/// Returns whether `response` has a gRPC content type (`application/grpc`, optionally followed by `+` or `;`). +fn is_grpc(response: &Response) -> bool { + const GRPC: &str = "application/grpc"; + let Some(content_type) = response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + content_type + .get(..GRPC.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(GRPC)) + && matches!(content_type.as_bytes().get(GRPC.len()), None | Some(b'+' | b';')) +} + +/// A response without a gRPC content type. +#[derive(Debug)] +pub(crate) struct NotGrpc(http::StatusCode); + +impl NotGrpc { + /// Returns what the response shows: a 5xx status reports a failure of the server (RFC 9110 §15.6), such as + /// downtime behind a reverse proxy, and any other status a path that can't carry gRPC. + pub(crate) fn kind(&self) -> TransientKind { + if self.0.is_server_error() { + TransientKind::Outage + } else { + TransientKind::NoGrpcPath + } + } +} + +impl fmt::Display for NotGrpc { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "the server answered with a non-gRPC {} response", self.0) + } +} + +impl Error for NotGrpc {} diff --git a/crates/agent-channel-proto/Cargo.toml b/crates/agent-channel-proto/Cargo.toml new file mode 100644 index 000000000..493032613 --- /dev/null +++ b/crates/agent-channel-proto/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "agent-channel-proto" +version = "0.0.0" +authors = ["Devolutions Inc. "] +edition = "2024" +description = "Protobuf and gRPC definitions for the agent channel" +publish = false + +[lints] +workspace = true + +[dependencies] +prost = "0.13" +prost-types = "0.13" +tonic = { version = "0.12", default-features = false, features = ["codegen", "prost"] } + +[build-dependencies] +prost-build = "0.13" +protoc-bin-vendored = "3" +tonic-build = "0.12" diff --git a/crates/agent-channel-proto/INTENT.agent-identity.md b/crates/agent-channel-proto/INTENT.agent-identity.md new file mode 120000 index 000000000..56d20dc7a --- /dev/null +++ b/crates/agent-channel-proto/INTENT.agent-identity.md @@ -0,0 +1 @@ +../../docs/agent-identity/INTENT.agent-identity.md \ No newline at end of file diff --git a/crates/agent-channel-proto/INTENT.md b/crates/agent-channel-proto/INTENT.md new file mode 100644 index 000000000..a8eb917fd --- /dev/null +++ b/crates/agent-channel-proto/INTENT.md @@ -0,0 +1,24 @@ +This crate defines the agent channel between the agent and an authority. + +## Schema + +Its `.proto` is the single source of the channel schema for every language: this crate, and the `Devolutions.Agent.Channel` NuGet package built from the same file for authorities written in .NET. +No other copy of the `.proto` exists; the contract refers to this file. +It is also the only artifact the mock authority shares with the agent. + +## Payloads + +| Payload | Direction | Purpose | +| ---------------- | -------------------------------- | --------------------------------------------------------------------------------- | +| `Challenge` | Authority → agent, first message | Fresh random bytes, so captured opening headers can't authenticate another stream | +| `Hello` | Agent → authority | Metadata, capabilities, applied-state versions, proof of key possession | +| `Welcome` | Authority → agent | The stream is authenticated; carries the server time | +| `Ack` | Both | Acknowledgement | +| `RenewRequested` | Authority → agent | Renew now | +| `Reconnect` | Authority → agent | Reopen the stream | +| `ConfigUpdate` | Authority → agent | A newer config revision | + +## Design decisions + +- The proof of key possession rides in `Hello`, so that authenticating a stream costs no extra round trip. +- Messages inside the stream are not individually signed and rely on TLS; this is a deliberate tradeoff, which can be addressed later on. diff --git a/crates/agent-channel-proto/build.rs b/crates/agent-channel-proto/build.rs new file mode 100644 index 000000000..5678d1cb8 --- /dev/null +++ b/crates/agent-channel-proto/build.rs @@ -0,0 +1,11 @@ +fn main() { + let protoc = protoc_bin_vendored::protoc_bin_path().expect("failed to locate vendored protoc"); + + let mut config = prost_build::Config::new(); + config.protoc_executable(protoc); + + tonic_build::configure() + .build_transport(false) + .compile_protos_with_config(config, &["proto/channel.proto"], &["proto"]) + .expect("failed to compile agent channel proto"); +} diff --git a/crates/agent-channel-proto/dotnet/Devolutions.Agent.Channel/Devolutions.Agent.Channel.csproj b/crates/agent-channel-proto/dotnet/Devolutions.Agent.Channel/Devolutions.Agent.Channel.csproj new file mode 100644 index 000000000..982092e7e --- /dev/null +++ b/crates/agent-channel-proto/dotnet/Devolutions.Agent.Channel/Devolutions.Agent.Channel.csproj @@ -0,0 +1,31 @@ + + + net10.0 + enable + enable + Devolutions.Agent.Channel + Library + Devolutions Inc. + Devolutions Inc. + Devolutions Agent Channel + gRPC and Protobuf types for the Devolutions Agent channel + © Devolutions Inc. All rights reserved. + Devolutions.Agent.Channel + 2026.9.25 + MIT OR Apache-2.0 + https://github.com/Devolutions/devolutions-gateway.git + git + true + snupkg + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/agent-channel-proto/dotnet/build.ps1 b/crates/agent-channel-proto/dotnet/build.ps1 new file mode 100644 index 000000000..d99783219 --- /dev/null +++ b/crates/agent-channel-proto/dotnet/build.ps1 @@ -0,0 +1,17 @@ +#!/bin/env pwsh + +$ErrorActionPreference = "Stop" + +Push-Location -Path $PSScriptRoot + +try { + $Project = "./Devolutions.Agent.Channel/Devolutions.Agent.Channel.csproj" + + dotnet build $Project --configuration Release + if ($LASTEXITCODE -ne 0) { throw "dotnet build failed" } + + dotnet pack $Project --configuration Release --no-build + if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed" } +} finally { + Pop-Location +} \ No newline at end of file diff --git a/crates/agent-channel-proto/proto/channel.proto b/crates/agent-channel-proto/proto/channel.proto new file mode 100644 index 000000000..b633959b2 --- /dev/null +++ b/crates/agent-channel-proto/proto/channel.proto @@ -0,0 +1,65 @@ +syntax = "proto3"; +package devolutions.agent.channel.v1; + +option csharp_namespace = "Devolutions.Agent.Channel.V1"; + +import "google/protobuf/timestamp.proto"; + +service AgentChannel { + rpc Connect(stream AgentMessage) returns (stream ServerMessage); +} + +message AgentMessage { + string id = 1; + optional string correlation_id = 2; + oneof payload { + Hello hello = 10; + Ack ack = 11; + } +} + +message ServerMessage { + string id = 1; + optional string correlation_id = 2; + oneof payload { + Welcome welcome = 10; + Ack ack = 11; + RenewRequested renew_requested = 12; + Reconnect reconnect = 13; + Challenge challenge = 14; + ConfigUpdate config_update = 15; + } +} + +// The device's effective config (§5.2 shape) as a UTF-8 JSON object. +message ConfigUpdate { + string config_json = 1; +} + +message Challenge { + // 32 random bytes, fresh per stream. + bytes challenge = 1; +} + +message Hello { + map metadata = 1; + repeated string capabilities = 2; + map applied_state_versions = 3; + // Proof of key possession bound to this stream (§7.3). + bytes proof = 4; +} + +message Welcome { + google.protobuf.Timestamp server_time = 1; +} + +message Ack {} + +message RenewRequested { + // "admin" or "rotation". + string reason = 1; +} + +message Reconnect { + string reason = 1; +} diff --git a/crates/agent-channel-proto/src/lib.rs b/crates/agent-channel-proto/src/lib.rs new file mode 100644 index 000000000..e80ad7be0 --- /dev/null +++ b/crates/agent-channel-proto/src/lib.rs @@ -0,0 +1,18 @@ +//! Protobuf and gRPC definitions for the agent channel. +//! +//! The schema is `proto/channel.proto`, defined by `docs/agent-identity/CONTRACT.agent-identity.md` §7.1. + +#[allow( + unused_qualifications, + clippy::clone_on_ref_ptr, + clippy::pedantic, + reason = "generated code; the set of triggered lints depends on the proto" +)] +mod generated { + tonic::include_proto!("devolutions.agent.channel.v1"); +} + +pub use generated::*; + +/// Full gRPC service name of the channel. +pub const SERVICE_NAME: &str = "devolutions.agent.channel.v1.AgentChannel"; diff --git a/crates/agent-identity-conformance/COVERAGE.md b/crates/agent-identity-conformance/COVERAGE.md new file mode 100644 index 000000000..a0faae53c --- /dev/null +++ b/crates/agent-identity-conformance/COVERAGE.md @@ -0,0 +1,114 @@ +# Contract coverage + +Which tests check which rule of `docs/agent-identity/CONTRACT.agent-identity.md`. +The `p_*` tests check an authority and the `a_*` tests the agent; the tests named here check the contract, and the suite's other tests check each party's own intent (an authority's management API, the agent's files, configuration and CLI). + +| Rules | Checked by | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| §1 timestamps | `p_trust_anchor_lists_roots`, every error body checked (e.g. `p_error_body_shape`), every refused channel opening (e.g. `p_channel_clock_skew_refusal_carries_server_time`) | +| §1 repeated members, nesting, invalid Unicode, canonical `base64` | `p_duplicate_json_members_rejected`, `p_json_body_nesting_limit`, `p_invalid_unicode_json_strings_rejected`, `p_noncanonical_base64_csr_rejected`, `p_signature_noncanonical_base64_rejected` | +| §5.4 error body | `p_error_body_shape` | +| §5.4 precedence | `p_authentication_error_precedes_invalid_body`, `p_oversized_body_precedes_authentication` | +| §2.1 use limit, failures, concurrency | `p_token_n_uses_consumed_then_exhausted`, `p_token_concurrent_enrollment_respects_max_uses`, `p_failed_enrollment_consumes_nothing`, `p_concurrent_same_key_enroll_replays_winner` | +| §2 secret matching, §2.1 token errors, replay, tombstones, key reuse | `p_token_errors_distinct`, `p_enroll_malformed_authorization_rejected`, `p_token_secret_must_match_exactly`, `p_token_expired`, `p_enroll_idempotent_same_key`, `p_deleted_token_replays_own_key_only`, `p_enroll_revoked_same_key_rejected`, `p_enroll_certificate_key_reuse_rules`, `a_enroll_lost_response_retried_across_restart` | +| §3 metadata limits | `p_metadata_limits`, `p_channel_hello_invalid_metadata_rejected` | +| §4 certificates, §5.1 trust anchors | `p_trust_anchor_lists_roots`, `p_renew_rejects_sha384_csr`, `p_compressed_point_csr_rejected`, the `csr` vectors | +| §4 leaf extensions | the leaf profile check of `p_trust_anchor_lists_roots` (enroll), `p_renew_happy_path` and `p_renew_within_grace` (renew); the harness test `leaf_extensions_outside_the_profile_are_named` pins that the check refuses basicConstraints and names it | +| §5.2 enroll | `p_token_config_contributes_to_effective_config`, `p_channel_url_expectation`, `a_pending_file_enroll_success`, `a_untrusted_tls_server_keeps_pending_token` | +| §5.2 config revisions | `p_config_revision_monotonic_changes`, `p_config_update_pushes_higher_revision`, `a_config_unknown_fields_and_hello_revision` | +| §5.2 config version | `a_config_unsupported_version_keeps_stored_config`, `a_config_unsupported_version_on_enroll_keeps_pending` | +| §5.2 redirects, §5.4 `3xx` | `a_pending_file_kept_on_transient_error`, `a_renew_transient_failures_retry_without_state_change`, `a_check_in_transient_failures_retry_without_state_change` | +| §5.3 renew | `p_renew_happy_path`, `p_renew_idempotent_lost_response`, `p_renew_second_pending_retires_first`, `p_renew_certificate_key_reuse_rules`, `p_concurrent_same_key_renew_replays_winner`, `p_renew_within_grace`, `p_renew_beyond_grace` | +| §5.4 agent classes | `a_pending_file_deleted_on_permanent_error`, `a_pending_file_kept_on_transient_error`, `a_revocation_stops_agent`, `a_identity_unknown_recorded` | +| §5.5 confirm | `p_confirm_promotes_and_is_idempotent`, `p_confirm_retired_certificate_rejected`, `p_confirm_expired_pending_certificate_rejected`, `p_confirm_nonempty_body_rejected`, `a_renewal_lost_confirm_response_retried`, `a_confirm_barrier_blocks_channelless_traffic` | +| §5.6 check-in | `p_check_in_current_and_pending`, `p_check_in_cross_tag_signatures_rejected`, `a_check_in_transient_failures_retry_without_state_change`, `a_request_renewal_without_channel` | +| §6 signatures | the `http_signature` vectors and sequences and RFC 9421 B.2.4, through the mock's `vector_tests.rs` (verifier) and the `agent-identity-httpsig` tests (signer); `p_signature_parameter_order_accepted_*`, `p_replay_nonce_rejected`, `p_replay_window_rejected`, `p_signature_parser_wire_negatives`, `p_signature_noncanonical_base64_rejected`, `p_signature_repeated_raw_items_rejected`, `p_connect_signature_noncanonical_or_repeated_rejected`, and the other `p_signature_*` and cross-tag tests | +| §6 unexpired while `now ≤ notAfter` | `p_rotation_completes_early_at_certificate_expiry_via_freeze`, `p_rotation_grace_renewal_after_early_completion`, `p_expired_certificate_refused_at_stream_open`, `p_identity_statuses` | +| §6 step 4 key binding, §3 informational metadata | `p_identity_cannot_impersonate_another`, `p_channel_hello_cannot_impersonate_another` | +| §7.1 envelope limit | `p_channel_undecodable_first_message_rejected` and `p_channel_undecodable_message_after_welcome_rejected` send agent messages of 65,536 and 65,537 bytes; every `p_*` test that opens a channel fails on a server message over 65,536 bytes | +| §7.3 handshake | the `channel_proof` vectors, through the mock's `vector_tests.rs`; `p_handshake_barrier_holds_authentication`, `p_channel_proof_replay_fails`, `p_channel_no_hello_timeout`, `p_channel_hello_invalid_metadata_rejected`, `p_channel_undecodable_first_message_rejected` | +| §7.3 config and renewal pushes | `p_config_hello_reconciles_stale_revision`, `p_config_update_pushes_higher_revision`, `p_request_renewal_connected_and_on_connect`, `p_reconnect_push_and_handoff`, `a_config_update_disables_channel`, `a_config_unknown_fields_and_hello_revision`, `a_request_renewal_connected`, `a_request_renewal_while_offline` | +| §7.4 termination | `p_revocation_closes_stream`, `p_stream_closes_at_not_after`, `p_expired_certificate_refused_at_stream_open`, `p_confirm_reconnects_and_closes_retired_stream_at_grace`, `a_make_before_break_on_renewal`, `a_reconnect_make_before_break`, `p_channel_undecodable_message_after_welcome_rejected` | +| §7.4 revocation and deletion checked when `Hello` arrives | `p_channel_revoked_before_hello`, `p_channel_deleted_before_hello` | +| §7.4 expiry and retirement of a stream awaiting `Hello` | `p_challenged_stream_expires_on_advance`, `p_channel_retired_before_hello_completes_then_reconnects` | +| §7.4 refused openings, §6 `clock_skew` retry | every `p_*` test that expects a refused channel opening checks its `error-code` and `server-time` trailers; `p_channel_clock_skew_refusal_carries_server_time`, `p_channel_server_time_corrects_skew` | +| §8.1 agent renewal | `a_renewal_happy_path`, `a_renewal_lost_response_retried`, `a_confirm_expired_pending_renews_with_fresh_key`, `a_expired_current_renews_in_grace_or_rejects_beyond` | +| §8.2 request-renewal flag | `p_request_renewal_flag_cleared_only_on_confirm`, `p_request_renewal_flag_cleared_after_new_cert` | +| §8.3 rotation | `p_rotation_publishes_both_roots_and_issues_from_new`, `p_rotation_completes_early_when_last_identity_migrates`, `p_rotation_completes_immediately_without_old_root_identities`, `p_rotation_completes_early_at_certificate_expiry_via_freeze`, `p_rotation_grace_renewal_after_early_completion`, `p_rotation_deadline_removes_old_root`, `p_rotation_old_root_cert_renewable_after_deadline`, `p_rotation_request_renewal_pushed`, `p_rotation_pending_only_old_root_receives_push`, `a_rotation_migrates_connected_agent`, `a_rotation_migrates_through_check_in`, `a_rotation_migrates_on_schedule` | + +- The shared vectors' pass rule is in `docs/agent-identity/INTENT.agent-identity.md`, "Conformance": a signer passes when it reproduces a vector's inputs and its signature verifies under the vector's public key. +- `docs/agent-identity/verify_vectors.py` recomputes the shared vectors with an implementation-independent library. +- `a_renewal_happy_path`, `a_request_renewal_connected`, `a_request_renewal_while_offline`, `a_multi_authority` and `a_rotation_migrates_connected_agent` need leaves of at least 3/2 of a test's timeout, so that none of their certificates comes due for renewal ("Schedules" in the agent's intent) or expires (§7.4) before the test ends, and only a requested renewal or a rotation replaces one; on an authority with shorter leaves they report `SKIP` rather than excuse a closed channel or a later certificate. +- Verified by review only: the randomness of secrets, nonces and challenges, and the stability of `authority_id` across restores. +- Known gaps, with no test yet: the §1 conventions beyond the error body, timestamps, repeated members, nesting depth, invalid Unicode and canonical `base64`. + +The suite's tests of each party's own intent: + +- DVLS's management API (DVLS's Agent Identity intent, `DVLS/Server/server/Common/AgentIdentity/INTENT.md` in the RDM repository, and DVLS's OpenAPI document), reached only through the suite's DVLS module: `p_management_tokens_crud_and_states`, `p_token_expiry_default_and_maximum`, `p_management_requires_auth`, `p_management_permission_denied`, `p_rotation_start_requires_auth`, `p_rotation_without_root_conflicts`, `p_root_key_unavailable_conflicts`, `p_listing_pagination_stable_during_concurrent_enrollment`, `p_listing_views_filters_and_bounds`, `p_identity_statuses`, `p_settings_read`, `p_settings_change_bounds`, `p_settings_certificate_lifetime_applies_to_new_certificates`, `p_settings_channel_switch_withdraws_and_restores_channel_url`. + On DVLS, `p_config_hello_reconciles_stale_revision` makes an identity's revision stale by switching the agent channel off and on, the only config change that DVLS's intent gives its management API ("Certificates and channel"). +- DVLS's login (DVLS's public OpenAPI document), with which the DVLS module opens sessions for the application identities that `--app-key`, `--second-app-key` and `--unprivileged-app-key` name: the module's unit tests check that a refused session logs in again and retries its request only once, and that a session logs in again before it reaches the age of a refused one; a full run outlasts DVLS's 7-minute session lifetime. +- Friendly names (shared intent, "Enrollment"): `p_friendly_name_format`, `p_friendly_name_white_space_and_truncation`. +- The agent's local behaviour (`crates/agent-identity/INTENT.md`): + - "Identities", an unavailable key backend logs an error and stops the identity feature: `a_unavailable_key_backend_stops_identity`. + The harness test `error_log_level_is_distinct_from_message_text` pins how it finds an error-level line, with and without colour codes. + - "Pending-enrollment files", the last of two tokens for one authority to complete wins: `a_two_pending_tokens_same_authority_last_wins`. + - "Pending-enrollment files", each success deletes its pending file and in-progress record; these tests wait for both deletions after the identity is stored: `a_enroll_lost_response_retried_across_restart`, `a_two_pending_tokens_same_authority_last_wins`, `a_two_pending_tokens_different_authorities_progress_independently`. + - "Stored identities", a renewal ends with the new key current, no pending or previous key, and the old key deleted; these tests wait for that state before they stop the agent: `a_renew_transient_failures_retry_without_state_change`, `a_renewal_lost_response_retried`, `a_confirm_barrier_blocks_channelless_traffic`, `a_confirm_expired_pending_renews_with_fresh_key`, `a_expired_current_renews_in_grace_or_rejects_beyond`, `a_make_before_break_on_renewal`, `a_request_renewal_connected`, `a_request_renewal_while_offline`, `a_request_renewal_without_channel`, `a_rotation_migrates_connected_agent`, `a_rotation_migrates_through_check_in`. + `a_renewal_happy_path` restarts the agent without `renewal_after_secs`, so that the restarted agent keeps its renewed key. + - "Stored identities", writes are atomic: the audit after each agent test parses each stored `identity.json` and each tracked in-progress record, and tolerates a temporary sibling of either, because stopping the agent can cut a write short and the intent promises atomicity, not the removal of that file. + The harness test `orphan_audit_accepts_both_records_and_cleanup_is_prefix_scoped` pins that the audit tolerates such a file, still rejects one holding a private key, and still rejects a temporary sibling of a pending or key file. +- Device keys (`crates/device-key/INTENT.md`, "Operations"), no leftover copy of a private key survives a later operation in the same key store: `a_request_renewal_without_channel` checks it after a restart. + The audit after each agent test tolerates a leftover copy of a deleted key, because stopping the agent can follow its last key-store operation. + The harness test `orphan_audit_accepts_both_records_and_cleanup_is_prefix_scoped` pins that the audit tolerates such a copy, still rejects a leftover copy of a recorded key, and that the post-operation check reports it. + +Each item of the tester's "Coverage" list (`crates/agent-identity-conformance/INTENT.md`) and the tests that check it: + +| Tester intent item | Checked by | +| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tokens: use cap, including concurrent enrollments | `p_token_n_uses_consumed_then_exhausted`, `p_token_concurrent_enrollment_respects_max_uses` | +| Tokens: a failed enrollment consumes no use | `p_failed_enrollment_consumes_nothing` | +| Tokens: distinct errors for deleted, exhausted and expired tokens | `p_token_errors_distinct`, `p_token_expired` | +| Tokens: a lost enrollment response is recoverable and its replay changes nothing | `p_enroll_idempotent_same_key`, `p_deleted_token_replays_own_key_only`, `p_concurrent_same_key_enroll_replays_winner` | +| Tokens: no public key is certified twice | `p_enroll_certificate_key_reuse_rules`, `p_renew_certificate_key_reuse_rules` | +| Pending files: deleted on success and permanent errors, kept on transient ones | `a_pending_file_enroll_success`, `a_pending_file_deleted_on_permanent_error`, `a_pending_file_kept_on_transient_error` | +| Pending files: a lost response is recovered across a restart | `a_enroll_lost_response_retried_across_restart` | +| Pending files: the token never appears in logs | `a_token_never_logged` | +| Pending files: the same token again does nothing | `a_same_token_no_enrollment`, `a_same_token_after_token_exhausted_no_enrollment`, `a_same_token_after_token_invalid_no_enrollment`, `a_same_token_after_token_malformed_no_enrollment`, `a_same_token_after_identity_revoked_no_enrollment`, `a_same_token_rejected_identity_no_enrollment` | +| Pending files: a different token replaces the stored identity | `a_different_token_replaces_identity`, `a_rejected_identity_replaced`, `a_two_pending_tokens_same_authority_last_wins` | +| Pending files: files for different tokens don't block each other | `a_two_pending_tokens_different_authorities_progress_independently`, `a_bad_pending_files_do_not_reject_valid_tokens` | +| Impersonation | `p_identity_cannot_impersonate_another`, `p_channel_hello_cannot_impersonate_another` | +| Replay: a reused nonce and an exceeded window are rejected | `p_replay_nonce_rejected`, `p_replay_window_rejected` | +| Replay: a signature for one operation is rejected by every other | `p_connect_signature_replayed_to_renew`, `p_renew_signature_rejected_on_connect`, `p_confirm_cross_tag_signatures_rejected`, `p_confirm_signature_rejected_on_connect`, `p_check_in_cross_tag_signatures_rejected`, `p_check_in_signature_rejected_on_connect` | +| Replay: captured channel opening headers don't open a stream | `p_channel_proof_replay_fails` | +| Replay: too few covered components are rejected | `p_same_tag_covered_components_rejected`, `p_same_tag_connect_components_rejected` | +| Renewal: the happy path | `p_renew_happy_path`, `a_renewal_happy_path` | +| Renewal: lost `renew` and `confirm` responses are retried | `p_renew_idempotent_lost_response`, `p_confirm_promotes_and_is_idempotent`, `a_renewal_lost_response_retried`, `a_renewal_lost_confirm_response_retried` | +| Renewal: only `confirm` makes the new certificate current, then the old key is deleted | `p_confirm_promotes_and_is_idempotent`, `p_pending_cert_auth_does_not_promote`, `a_confirm_barrier_blocks_channelless_traffic`, `a_old_key_deleted_after_confirm_without_channel` | +| Renewal: make before break | `a_make_before_break_on_renewal`, `a_reconnect_make_before_break`, `p_confirm_reconnects_and_closes_retired_stream_at_grace` | +| Renewal: within grace succeeds, beyond fails | `p_renew_within_grace`, `p_renew_beyond_grace`, `a_expired_current_renews_in_grace_or_rejects_beyond` | +| Renewal: a pending certificate that expires before `confirm` is recovered | `p_confirm_expired_pending_certificate_rejected`, `a_confirm_expired_pending_renews_with_fresh_key`, `a_confirm_expired_pending_beyond_grace_rejects` | +| Renewal: a renewal request reaches connected, later-connecting and channelless identities | `p_request_renewal_connected_and_on_connect`, `a_request_renewal_connected`, `a_request_renewal_while_offline`, `a_request_renewal_without_channel` | +| Revocation: closes the stream and blocks renewal | `p_revocation_closes_stream`, `p_revocation_blocks_renew_and_connect` | +| Revocation: the agent stops reconnecting and renewing | `a_revocation_stops_agent` | +| Revocation: deletion only when revoked, then unknown | `p_delete_only_when_revoked_then_unknown`, `a_identity_unknown_recorded` | +| Channel: no stream is authenticated without the proof | `p_handshake_barrier_holds_authentication`, `p_channel_proof_replay_fails` | +| Channel: `Hello` refreshes metadata and the connected state | `p_channel_hello_updates_metadata_and_connected`, `a_channel_connected_and_metadata` | +| Channel: no channel without a channel URL | `p_channel_unavailable_no_channel_url`, `a_no_channel_when_absent` | +| Config: revisions only increase | `p_config_revision_monotonic_changes` | +| Config: changes reach the agent over the channel | `p_config_update_pushes_higher_revision`, `a_config_update_disables_channel` | +| Config: changes reach the agent through `check-in` without a channel | `a_check_in_enables_channel` (a channel URL added while the agent has none), `a_check_in_recovers_broken_channel` (the URL removed while the channel is broken) | +| Config: the agent checks in only without a working channel | `a_config_update_disables_channel`, `a_config_unknown_fields_and_hello_revision` (no `check-in` while connected); `a_check_in_enables_channel`, `a_check_in_recovers_broken_channel` (`check-in` without one) | +| Rotation: both roots published, new certificates from the new root | `p_rotation_publishes_both_roots_and_issues_from_new` | +| Rotation: migration on connect, on schedule and through `check-in` | `a_rotation_migrates_connected_agent`, `a_rotation_migrates_on_schedule`, `a_rotation_migrates_through_check_in` | +| Rotation: status counts | `p_rotation_status_counts` (at start, after a migration and after a revocation), `p_rotation_completes_early_when_last_identity_migrates` (after each migration) | +| Rotation: completes at the deadline | `p_rotation_deadline_removes_old_root` | +| Rotation: completes when no active old-root identity remains | `p_rotation_completes_early_when_last_identity_migrates`, `p_rotation_completes_immediately_without_old_root_identities`, `p_rotation_grace_renewal_after_early_completion`, `p_rotation_completes_early_at_certificate_expiry_via_freeze` | +| Rotation: old-root certificates remain renewable afterwards | `p_rotation_old_root_cert_renewable_after_deadline`, `p_rotation_grace_renewal_after_early_completion` | +| Rotation: emergency deadline | `p_rotation_emergency_deadline` | +| Management API: pagination stable during concurrent enrollment | `p_listing_pagination_stable_during_concurrent_enrollment` | +| Management API: views, filters and bounds | `p_listing_views_filters_and_bounds` | +| Management API: identity statuses, including `grace` | `p_identity_statuses`, `p_rotation_grace_renewal_after_early_completion` | +| Management API: every route requires its permission | `p_management_requires_auth`, `p_management_permission_denied`, `p_rotation_start_requires_auth` | +| Keys: non-exportable, restricted access, matching the certificate | `a_key_non_exportable` (Windows), `a_file_backend_permissions` (Unix) | +| Keys: no orphaned key | the audit after each agent test, `a_request_renewal_without_channel` | +| Multiple authorities | `a_multi_authority`, `a_two_pending_tokens_different_authorities_progress_independently` | diff --git a/crates/agent-identity-conformance/Cargo.toml b/crates/agent-identity-conformance/Cargo.toml new file mode 100644 index 000000000..44dcaad0a --- /dev/null +++ b/crates/agent-identity-conformance/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "agent-identity-conformance" +version = "0.0.0" +authors = ["Devolutions Inc. "] +edition = "2024" +description = "Independent Agent Identity V1 conformance runner" +publish = false + +[lints] +workspace = true + +[dependencies] +agent-channel-proto = { path = "../agent-channel-proto" } +anyhow = "1" +base64 = "0.22" +der = "0.8" +futures = "0.3" +http = "1" +p256 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] } +p384 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] } +prost = "0.13" +rand = "0.10" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "http2"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +tempfile = "3" +time = { version = "0.3", features = ["formatting", "parsing"] } +tokio = { version = "1.52", features = ["macros", "rt-multi-thread", "time", "process", "fs", "sync", "io-util", "net"] } +tokio-stream = "0.1" +tonic = { version = "0.12", features = ["transport", "tls", "tls-webpki-roots"] } +tower = { version = "0.5", features = ["util"] } +uuid = { version = "1", features = ["v4", "serde"] } +x509-cert = { version = "0.3", features = ["builder"] } + +[dev-dependencies] +rcgen = "0.13" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Cryptography", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_JobObjects", + "Win32_System_Memory", + "Win32_System_Threading", +] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/agent-identity-conformance/INTENT.agent-identity.md b/crates/agent-identity-conformance/INTENT.agent-identity.md new file mode 120000 index 000000000..56d20dc7a --- /dev/null +++ b/crates/agent-identity-conformance/INTENT.agent-identity.md @@ -0,0 +1 @@ +../../docs/agent-identity/INTENT.agent-identity.md \ No newline at end of file diff --git a/crates/agent-identity-conformance/INTENT.md b/crates/agent-identity-conformance/INTENT.md new file mode 100644 index 000000000..51fe4a00e --- /dev/null +++ b/crates/agent-identity-conformance/INTENT.md @@ -0,0 +1,91 @@ +This is the independent conformance suite for Agent Identity. +Its expectations derive only from these sources, never from the implementation under test: + +- [CONTRACT.agent-identity.md](../../docs/agent-identity/CONTRACT.agent-identity.md) and the shared test vectors; +- `agent-channel-proto`; +- DVLS's management API, as `agent-identity-mock` replicates it; +- the agent's CLI, configuration and files, as `devolutions-agent/src/identity/INTENT.md` and `crates/agent-identity/INTENT.md` define them. + +The mock implements the contract's agent-facing operations and channel independently of the agent. + +## Shape + +- A binary with a custom harness, like `proxy-tester`, that exits with a failure status when any test fails. +- Parameters: agent binary path, target (mock or DVLS), and one or more authorities, each with its base URL, management API credentials and what it offers, such as its leaf lifetime and whether it runs a channel. +- Each test declares what it needs from an authority and runs against the first one that offers it; when none does, it reports SKIP naming the missing capability, which counts as a missing fixture, so one run against several differently configured authorities can cover every test. +- When an agent test fails, the suite keeps that test's agent data directory and logs and prints where, because a failure seen only once in a long run against a real authority is otherwise lost. +- Credentials stay out of the suite's output, which lands in CI logs: a usage error never echoes a credential value, and an `--authority` description names the environment variable holding its secret instead of carrying it, so the description itself never holds one. +- Black box on both sides: + - it drives the real agent binary through a generated configuration, pending-enrollment files, and the `identity enroll` command; + - it drives the authority through its management API, and the mock also through its control surface. +- Management APIs are product-specific, so the suite reaches each authority's through a module for that authority: DVLS's now, which the mock replicates, and PSU's once its management API is settled. + Tests never call a management API directly. +- It depends on no agent implementation crate. +- What it observes, in order of preference: + 1. the management API; + 2. the agent's files; + 3. logs, as a last resort. +- Tests that need mock fault injection are N/A when the target is DVLS, as tests its module declares unsupported. + A run that skips a test for a missing fixture or privilege fails as incomplete unless `--allow-incomplete` is passed, so a partial run never reports success by default. + N/A is reserved for a test that does not apply to the platform, or that the authority's module declares unsupported, such as an expiry test against an authority that cannot issue short-lived certificates. +- The `testsuite` crate builds the mock and the tester, runs the mock, runs the tester against it, and requires every test to pass. + No dependency is added to `testsuite` for this. +- The suite must pass on Linux with the file backend, and on Windows with the Windows software provider backend. +- Without an explicit rotation window, the rotation tests ask for a deadline at most 75 % of the leaf lifetime ahead, rounded down and capped at 60 s, so that it falls before the rotation maximum of an authority issuing short-lived leaves; a lifetime too short to leave a whole second needs an explicit window. + +## Coverage + +- **Tokens:** + - use cap, including concurrent enrollments; + - a failed enrollment consumes no use; + - distinct errors for deleted, exhausted and expired tokens; + - a lost enrollment response is recoverable with the same token and key, even after the token is exhausted, expired or deleted, and the replay changes nothing; + - no public key is ever certified twice. +- **Pending-enrollment files:** + - consumed and deleted on success and on permanent errors, kept on transient ones; + - a lost response is recovered across an agent restart; + - the token never appears in logs; + - the same token again does nothing, even for a rejected identity or after a permanent enrollment outcome; + - a different token creates a new identity, which replaces the stored identity for that authority; + - files for different tokens don't block each other. +- **Impersonation:** identity A cannot authenticate as identity B, whether with a leaked token, with its own certificate, with any other certificate issued by the root, or by naming B in its requests. +- **Replay and signatures:** + - a reused nonce and an exceeded window are rejected; + - a signature for one operation is rejected by every other; + - captured channel opening headers don't open a stream without the key; + - a request that covers fewer components than the profile requires is rejected. +- **Renewal:** + - the happy path; + - lost `renew` and `confirm` responses are retried without lockout; + - only `confirm` makes the new certificate current, and the old key is deleted right after it; + - the channel is reopened make before break; + - renewal within the grace window succeeds, beyond it fails; + - a pending certificate that expires before `confirm` is recovered; + - a renewal request reaches a connected identity, one that connects later, and one without a channel. +- **Revocation:** + - closes the stream and blocks renewal; + - the agent stops reconnecting and renewing; + - deletion is allowed only for revoked identities, and a deleted identity is unknown. +- **Channel:** + - no stream is authenticated without the proof; + - `Hello` refreshes metadata and the connected state; + - no channel when the config has no channel URL. +- **Config:** + - revisions only increase; + - changes reach the agent over the channel, and through `check-in` without one; + - the agent checks in only without a working channel. +- **Rotation:** + - both roots are published; + - new certificates come from the new root; + - identities migrate on connect, on schedule, and through `check-in`; + - the status counts are correct; + - the rotation completes at the deadline, or as soon as no active identity remains on the old root; + - old-root certificates remain renewable afterwards; + - emergency deadline. +- **Management API:** + - pagination stays stable during concurrent enrollment; + - views, filters and bounds; + - identity statuses, including `grace`; + - every route requires its permission. +- **Keys:** non-exportable, accessible only to the agent's account and SYSTEM (root on Linux and macOS), and matching the certificate; no key is left orphaned. +- **Multiple authorities:** identities for two authorities coexist, including across agent restarts. diff --git a/crates/agent-identity-conformance/README.md b/crates/agent-identity-conformance/README.md new file mode 100644 index 000000000..253ba65d3 --- /dev/null +++ b/crates/agent-identity-conformance/README.md @@ -0,0 +1,89 @@ +# Agent Identity conformance runner + +The runner checks the [Agent Identity V1 contract] against the mock server or a DVLS target. +Build both binaries with `cargo build -p agent-identity-mock -p agent-identity-conformance`. +Start two mocks as described in the [mock README] for the complete multi-authority run, then pass each `ready.json` URL, authority ID and TLS CA path: + +```powershell +.\target\debug\agent-identity-conformance.exe ` + --target mock --base-url --app-key --app-secret ` + --authority-id --extra-trusted-root ` + --second-base-url --second-app-key --second-app-secret ` + --second-authority-id --second-extra-trusted-root ` + --agent-bin --work-dir +``` + +Use `--filter p_` for protocol-only checks or `--list` to inspect test names. +Use `--key-backend windows-software-provider|file` to select `Identity.KeyBackend` (`WindowsSoftwareProvider` or `File`); the default is Windows software provider on Windows and file elsewhere. +The runner calls each authority's management API as an application identity, with its key and secret: `--app-key` and `--app-secret` name one holding every Agent Identity permission, and `--second-app-key` and `--second-app-secret` one on the second authority; on the mock, they are the mock's own `--app-key` and `--app-secret`. +A key requires its secret, which the `AGENT_IDENTITY_CONFORMANCE_APP_SECRET`, `AGENT_IDENTITY_CONFORMANCE_SECOND_APP_SECRET` and `AGENT_IDENTITY_CONFORMANCE_UNPRIVILEGED_APP_SECRET` environment variables can supply instead, to keep secrets off the command line. +Usage errors name only known flags, known `--authority` keys and argument positions, never values, because a misplaced value may be a secret. +The runner logs in with `POST /api/v1/login` on first use and sends the `tokenId` it returns. +It logs in again before the `tokenId` lifetime elapses, assuming DVLS's 7 minutes until the API refuses a younger `tokenId`, and when the API refuses a `tokenId` it logs in once more and retries that request once. +Each re-login is logged at info level, without the `tokenId`, key or secret, and the run ends with `POST /api/v1/logout`. +On DVLS, the harness provisions the application identities, which DVLS's public API cannot create. +Supply `--disposable-dvls-target` only when rotation and settings changes, including switching the agent channel off and on, are safe, and provide `--unprivileged-app-key` and `--unprivileged-app-secret`, an application identity without any Agent Identity permission, to check permission denials. +On the mock, the runner derives the keys of application identities with fewer permissions from `--app-key`, as the [mock README] describes. +Settings tests restore the original settings when they finish. +The runner reaches each authority's management API only through the module for that authority: `src/dvls.rs` speaks DVLS's management API, as DVLS's OpenAPI document defines it and the mock replicates it. +The disposable rotation checks require no other active identities on the published root; otherwise they report `SKIP` before rotating. +`--leaf-lifetime-secs` defaults to 90 days and must match the server setting. +DVLS refuses a rotation deadline after the rotation maximum, which is at most one leaf lifetime ahead, so the rotation checks set their deadline `--dvls-rotation-window-secs` ahead, by default 60 seconds or 75% of `--leaf-lifetime-secs` rounded down to whole seconds, whichever is shorter. +A one-second leaf lifetime leaves no whole second for that default, so the runner then refuses to start without `--dvls-rotation-window-secs`. +Pass `--agent-version ` to require the agent's reported version to match; the testsuite reads the root `VERSION` file and passes it automatically. +Agent cases check the five required metadata fields and their values at each send ([Agent Identity V1 contract] §3.1). +Each generated agent config points `metadata_override_path` at a test-owned JSON file; mock-only renewal tests change its hostname between sends to verify that renew and the next Hello refresh metadata. +The DVLS expiry-boundary test needs a configured leaf lifetime of roughly 10–30 seconds. +`a_renewal_happy_path`, `a_request_renewal_connected`, `a_request_renewal_while_offline`, `a_multi_authority` and `a_rotation_migrates_connected_agent` need leaves that outlast a test: at least 135 seconds, 3/2 of a test's 90-second timeout, so that the agent's schedule, which renews a leaf two thirds into its lifetime, renews none of their certificates and none expires before the test ends. +An authority that backdates `notBefore`, or whose clock runs behind the runner's, needs longer leaves. +On an authority with shorter leaves these tests report `SKIP`; the mock's default 90-day leaves meet the need. +`--expect-channel` defaults to `true`; use `--expect-channel false` when the server intentionally omits `config.agent_channel_url`. +When no authority offers the channel, gRPC-dependent checks report `SKIP`; HTTP confirm checks and channelless renewal still run. + +`PASS` and `FAIL` report assertions, and `N/A` marks a test that does not apply to the platform or that the authority's module declares unsupported, so mock-only tests report `N/A` on DVLS. +`SKIP` means the target lacks a required fixture, privilege or channel. +Any `SKIP` makes the run incomplete and exits nonzero unless `--allow-incomplete` is set. +The final summary reports counts, per-category durations and the reason for every non-PASS test. + +The runner reports a unique `KEY NAME PREFIX` of `conformance-<32 lowercase hex digits>` for each run and writes it to `__debug__.identity.key_name_prefix` in every generated `agent.json`. +Key names must be `_`; file-backed keys live at `/identity/keys/.p8`. +Each agent test's `` is a new `identity-agent-XXXXXX` directory under `--work-dir`. +Windows paths are limited to 259 characters without long-path support, so with the file backend on Windows the runner fails before any test when `--work-dir` is longer than 79 characters, which would leave too little room for a key file path. +Only keys whose part before the first `_` equals the run prefix belong to that run. +After each agent test, the runner fails if any key with that prefix is absent from `identity.json` or `/identity/pending/.in-progress.json`, then deletes only run-prefixed machine and file-backed keys on either target. +When an agent test fails or times out, the runner keeps the `` of the agent run that failed, with the agent's `agent-stdout.log` and `agent-stderr.log`, and prints `KEPT `; it deletes the `` of a run that passed. +A kept `` holds no run-prefixed key, but may hold the test's pending enrollment tokens. +When the runner fails, the testsuite keeps its work directory, which holds the runner's `--work-dir`. +It rejects unrecorded `*.p8` files and PKCS#8 private keys anywhere under `/identity`, plus leftover temporary siblings of pending and key files; other unknown files produce warnings. +A temporary sibling of `identity.json` or of an in-progress record also produces a warning, because stopping the agent can cut that record's atomic write short. +On Unix, file-backed keys must have mode `0600` and be owned by the agent's uid. +Windows software provider keys must allow only the agent's account and SYSTEM, regardless of `acl_grant_current_user`; when the agent runs as SYSTEM, only SYSTEM is expected. +The Windows software provider stores keys as machine keys, which only an elevated administrator can create, so in an unelevated run every test that starts the agent reports `SKIP`. +The SYSTEM fixture requires an elevated administrator unless the suite already runs as SYSTEM; without this fixture, the Windows non-exportability case reports `SKIP`. +`--authority-id` and `--second-authority-id` check server and stored authority IDs when supplied; they do not control cleanup. +The runner stops after 14 minutes 30 seconds; the testsuite allows 15 minutes and terminates the runner's process tree on timeout. + +## Several authorities + +`--leaf-lifetime-secs`, `--expect-channel`, `--disposable-dvls-target` and `--dvls-rotation-window-secs` declare the profile of the authorities that `--base-url` and `--second-base-url` name. +Each `--authority ` adds an authority with its own profile, so that one DVLS run can cover checks that need differently configured authorities: + +```powershell +$env:C_APP_SECRET = '' +.\target\debug\agent-identity-conformance.exe --target dvls ` + --authority "base-url=https://localhost:15512,app-key=,app-secret-env=C_APP_SECRET,extra-trusted-root=c.pem,leaf-lifetime-secs=7776000,expect-channel=false,disposable-dvls-target=true" +``` + +A spec is comma-separated `=` items, and values cannot contain commas. +Its keys are the names, without `--`, of the flags above and of `--base-url`, `--app-key`, `--authority-id` and `--extra-trusted-root`, with the same values, plus the required `app-secret-env`, which names the environment variable that holds the application secret, to keep it off the command line. +Each test runs on the first authority, in the order of their flags, that offers what the test needs: the agent channel or none, a disposable target, leaves of at most 30 seconds, leaves that outlast a test, leaves of the certificate lifetime setting rather than a test lifetime, or a config change. +The runner reads the settings of each disposable DVLS target at the start, and counts its leaves as following the setting when its leaf lifetime equals the setting in seconds. +DVLS's management API changes a config only by switching the agent channel off and on, so a config change needs a disposable target that offers the channel. +A test that needs two authorities also takes the next one that offers what it needs. +When no authority does, the test reports `SKIP` with the first need that the first authority lacks. +The mock offers everything but an absent channel through its controls, so on the mock every test runs on the first authority, and on the second when it needs two. +A test that runs on other authorities prints `AUTHORITY ` and the flags that declare them, such as `--authority 1` for the first `--authority`, before its result. +`--unprivileged-app-key` names an application identity of the first authority, so a test that runs on another one runs without it. + +[Agent Identity V1 contract]: ../../docs/agent-identity/CONTRACT.agent-identity.md +[mock README]: ../agent-identity-mock/README.md diff --git a/crates/agent-identity-conformance/src/agent.rs b/crates/agent-identity-conformance/src/agent.rs new file mode 100644 index 000000000..e6482e6e2 --- /dev/null +++ b/crates/agent-identity-conformance/src/agent.rs @@ -0,0 +1,6508 @@ +use std::collections::HashSet; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, ensure}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use der::{Decode as _, Encode as _, Reader as _}; +#[cfg(any(unix, windows))] +use p256::ecdsa::SigningKey; +#[cfg(windows)] +use p256::ecdsa::VerifyingKey; +#[cfg(any(unix, windows))] +use p256::pkcs8::DecodePrivateKey as _; +use p256::pkcs8::EncodePublicKey as _; +use rand::RngExt as _; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use time::format_description::well_known::Rfc3339; +use tokio::io::AsyncWriteExt as _; +use tokio::process::{Child, Command}; +use x509_cert::Certificate; + +use crate::client::{Target, Token, count, decoded_certificate, expect_status, field}; +use crate::dvls::{CertificateStatus, IdentityQuery, IdentityRecord, IdentityStatus, RotationDeadline}; +use crate::{Context, KeyBackend}; + +#[cfg(windows)] +#[path = "system_fixture.rs"] +mod system_fixture; + +type CaseFuture<'a> = Pin> + Send + 'a>>; +const WAIT: Duration = Duration::from_secs(20); +const POLL: Duration = Duration::from_millis(100); +const KNOWN_METADATA_KEYS: [&str; 8] = [ + "hostname", + "fqdn", + "domain", + "os_name", + "os_version", + "arch", + "agent_version", + "machine_id", +]; +const REQUIRED_METADATA_KEYS: [&str; 5] = ["hostname", "os_name", "os_version", "arch", "agent_version"]; +/// Each agent case's data directory, under the work directory, is this prefix and [`DATA_DIR_RANDOM_LEN`] random characters. +pub(crate) const DATA_DIR_PREFIX: &str = "identity-agent-"; +pub(crate) const DATA_DIR_RANDOM_LEN: usize = 6; + +pub(crate) fn token_file_id(token: &str) -> String { + Sha256::digest(token.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub(crate) fn valid_key_prefix(prefix: &str) -> bool { + (1..=64).contains(&prefix.len()) + && prefix + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +pub(crate) fn key_belongs_to_run(name: &str, prefix: &str) -> bool { + name.split_once('_').is_some_and(|(candidate, _)| candidate == prefix) +} + +pub(crate) fn validate_key_name(prefix: &str, name: &str) -> anyhow::Result<()> { + ensure!(valid_key_prefix(prefix), "invalid device-key run prefix"); + let (_, key_uuid) = name.split_once('_').context("key name has no prefix separator")?; + ensure!( + key_belongs_to_run(name, prefix), + "key name {name} does not belong to run prefix {prefix}" + ); + let parsed = uuid::Uuid::parse_str(key_uuid).context("key name does not end with a UUID")?; + ensure!( + parsed.to_string() == key_uuid, + "key name does not end with a lowercase hyphenated UUID" + ); + Ok(()) +} + +/// The run's key name that a file name embeds, such as the name of a key-store file holding a copy of that key. +fn embedded_key_name<'a>(file_name: &'a str, prefix: &str) -> Option<&'a str> { + let start = file_name.find(&format!("{prefix}_"))?; + let name = file_name.get(start..start + prefix.len() + 1 + uuid::fmt::Hyphenated::LENGTH)?; + validate_key_name(prefix, name).is_ok().then_some(name) +} + +fn canonical_authority_dir(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .and_then(|name| uuid::Uuid::parse_str(name).ok().map(|id| id.to_string() == name)) + == Some(true) +} + +pub(crate) fn contains_pkcs8_material(contents: &[u8]) -> bool { + const PEM_MARKERS: [&[u8]; 2] = [b"-----BEGIN PRIVATE KEY-----", b"-----BEGIN ENCRYPTED PRIVATE KEY-----"]; + PEM_MARKERS + .iter() + .any(|marker| contents.windows(marker.len()).any(|window| window == *marker)) + || p256::pkcs8::PrivateKeyInfoRef::try_from(contents).is_ok() + || encrypted_pkcs8_der(contents).unwrap_or(false) +} + +fn encrypted_pkcs8_der(contents: &[u8]) -> der::Result { + let outer = <&der::asn1::SequenceRef>::from_der(contents)?; + let mut fields = der::SliceReader::new(outer.as_bytes())?; + let algorithm: &der::asn1::SequenceRef = fields.decode()?; + let encrypted: &der::asn1::OctetStringRef = fields.decode()?; + fields.finish()?; + let mut algorithm_fields = der::SliceReader::new(algorithm.as_bytes())?; + let oid: der::asn1::ObjectIdentifier = algorithm_fields.decode()?; + let oid = oid.to_string(); + Ok(!encrypted.as_bytes().is_empty() + && (oid.starts_with("1.2.840.113549.1.5.") || oid.starts_with("1.2.840.113549.1.12.1."))) +} + +fn temporary_name(name: &str, expected: &str) -> bool { + let name = name.strip_prefix('.').unwrap_or(name); + let Some(suffix) = name + .strip_prefix(expected) + .and_then(|tail| tail.strip_prefix('.').or_else(|| tail.strip_prefix('-'))) + else { + return false; + }; + suffix.starts_with("tmp") + || ["temp", "part", "new"].iter().any(|marker| { + suffix.strip_prefix(marker).is_some_and(|rest| { + rest.is_empty() + || rest.starts_with('.') + || rest.starts_with('-') + || rest.starts_with('_') + || rest.as_bytes().first().is_some_and(|byte| byte.is_ascii_digit()) + }) + }) + || [".tmp", ".temp", ".part", ".new"] + .iter() + .any(|extension| suffix.ends_with(extension)) +} + +pub(crate) fn is_temporary_sibling(path: &Path, documented: &HashSet, key_prefix: &str) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if documented.iter().any(|file| { + path.parent() == file.parent() + && file + .file_name() + .and_then(|expected| expected.to_str()) + .is_some_and(|expected| temporary_name(name, expected)) + }) { + return true; + } + let candidate = name.strip_prefix('.').unwrap_or(name); + match path + .parent() + .and_then(|parent| parent.file_name()) + .and_then(|parent| parent.to_str()) + { + Some("keys") => candidate + .split_once(".p8.") + .or_else(|| candidate.split_once(".p8-")) + .is_some_and(|(key, _)| { + validate_key_name(key_prefix, key).is_ok() && temporary_name(name, &format!("{key}.p8")) + }), + Some("pending") if candidate.len() >= 64 => { + let (hash, _) = candidate.split_at(64); + hash.bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + && [".json", ".dat", ".in-progress.json"] + .iter() + .any(|suffix| temporary_name(name, &format!("{hash}{suffix}"))) + } + _ => false, + } +} + +/// Whether a temporary sibling, as [`is_temporary_sibling`] finds it, belongs to `identity.json` or an in-progress record. +/// +/// The agent writes both records atomically, through a temporary file that it renames over the record ([agent intent], "Stored identities"). +/// That keeps the record whole when stopping the agent cuts a write short, but nothing promises that the temporary file is then removed. +/// +/// [agent intent]: ../../agent-identity/INTENT.md#stored-identities +pub(crate) fn is_record_temporary(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let candidate = name.strip_prefix('.').unwrap_or(name); + temporary_name(name, "identity.json") + || candidate + .get(..64) + .is_some_and(|hash| temporary_name(name, &format!("{hash}.in-progress.json"))) +} + +fn validate_agent_metadata(metadata: &Value, agent_version: Option<&str>) -> anyhow::Result<()> { + let values = metadata.as_object().context("agent metadata is not an object")?; + let mut total_bytes = 0; + for (key, value) in values { + ensure!( + KNOWN_METADATA_KEYS.contains(&key.as_str()), + "agent sent unknown metadata key {key}" + ); + let value = value + .as_str() + .with_context(|| format!("agent metadata {key} is not a string"))?; + ensure!(!value.is_empty(), "agent metadata {key} is empty"); + ensure!(value.len() <= 1024, "agent metadata {key} exceeds 1024 UTF-8 bytes"); + ensure!( + value + .chars() + .all(|character| character >= '\u{20}' && !('\u{7f}'..='\u{9f}').contains(&character)), + "agent metadata {key} contains a C0/C1 control character" + ); + total_bytes += key.len() + value.len(); + } + ensure!(total_bytes <= 16 * 1024, "agent metadata exceeds 16 KiB"); + for key in REQUIRED_METADATA_KEYS { + ensure!( + values + .get(key) + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()), + "agent metadata is missing required {key}" + ); + } + ensure!( + metadata["arch"] == std::env::consts::ARCH, + "agent metadata arch differs from the test platform" + ); + if let Some(expected) = agent_version { + ensure!( + metadata["agent_version"] == expected, + "agent metadata version differs from --agent-version" + ); + } + #[cfg(target_os = "linux")] + if Path::new("/etc/machine-id").exists() { + let expected = std::fs::read_to_string("/etc/machine-id").context("read Linux machine ID")?; + ensure!( + metadata["machine_id"] == expected.trim(), + "agent metadata machine_id differs from /etc/machine-id" + ); + } + Ok(()) +} + +pub(crate) fn assert_no_token_fragments(contents: &[u8], token: &str, path: &Path) -> anyhow::Result<()> { + let mut parts = token.split('.'); + let _ = parts.next(); + let bag = parts.next().context("registered token has no bag")?; + let secret = parts.next().context("registered token has no secret")?; + for fragment in [token.as_bytes(), bag.as_bytes(), secret.as_bytes()] { + if fragment.is_empty() { + continue; + } + ensure!( + !contents.windows(fragment.len()).any(|chunk| chunk == fragment), + "enrollment token leaked into {}", + path.display() + ); + } + for fragment in secret.as_bytes().windows(12) { + ensure!( + !contents.windows(12).any(|chunk| chunk == fragment), + "twelve-character enrollment secret fragment leaked into {}", + path.display() + ); + } + Ok(()) +} + +struct AgentCase { + ctx: Context, + dir: tempfile::TempDir, + child: Option, + #[cfg(windows)] + job: Option, + #[cfg(unix)] + process_group: Option, + tokens: Vec, + metadata_hostname: String, + cleaned: bool, + /// Whether dropping the case keeps its directory, with the agent's logs, as evidence: until the case passes, since a timeout drops it mid-test. + keep: bool, +} + +impl AgentCase { + async fn new(ctx: Context, renewal_after_secs: Option, run: bool) -> anyhow::Result { + let dir = tempfile::Builder::new() + .prefix(DATA_DIR_PREFIX) + .rand_bytes(DATA_DIR_RANDOM_LEN) + .tempdir_in(&ctx.work_dir) + .context("create agent data dir")?; + let metadata_hostname = "conformance-initial".to_owned(); + let metadata_override_path = dir.path().join("identity-metadata-override.json"); + std::fs::write( + &metadata_override_path, + serde_json::to_vec(&json!({ "hostname": metadata_hostname }))?, + )?; + let mut identity_debug = json!({ + "disable_jitter": true, + "backoff_max_secs": 2, + "pending_poll_interval_ms": 200, + "check_in_interval_secs": 5, + "channel_failure_check_in_after_secs": 3, + "key_name_prefix": &ctx.key_name_prefix, + "metadata_override_path": metadata_override_path + }); + if let Some(seconds) = renewal_after_secs { + identity_debug["renewal_after_secs"] = json!(seconds); + } + let first_root = ctx.target.ca_path.as_deref(); + let second_root = ctx.second.as_ref().and_then(|second| second.target.ca_path.as_deref()); + match (first_root, second_root) { + (Some(first), Some(second)) => { + let mut roots = std::fs::read(first)?; + roots.push(b'\n'); + roots.extend_from_slice(&std::fs::read(second)?); + let combined = dir.path().join("trusted-roots.pem"); + std::fs::write(&combined, roots)?; + identity_debug["extra_trusted_root"] = json!(combined); + } + (Some(path), None) | (None, Some(path)) => { + identity_debug["extra_trusted_root"] = json!(path); + } + (None, None) => {} + } + #[cfg(windows)] + { + identity_debug["acl_grant_current_user"] = json!(true); + } + let config = json!({ + "Identity": { "Enabled": true, "KeyBackend": ctx.key_backend.name() }, + "__debug__": { + "log_directives": "debug,h2=info,hyper=info,rustls=info", + "identity": identity_debug + } + }); + std::fs::write(dir.path().join("agent.json"), serde_json::to_vec_pretty(&config)?)?; + let mut case = Self { + ctx, + dir, + child: None, + #[cfg(windows)] + job: None, + #[cfg(unix)] + process_group: None, + tokens: Vec::new(), + metadata_hostname, + cleaned: false, + keep: true, + }; + if run { + case.start().await?; + } + Ok(case) + } + + fn path(&self) -> &Path { + self.dir.path() + } + + fn pending_path_for(&self, token: &str) -> PathBuf { + #[cfg(windows)] + let extension = "dat"; + #[cfg(not(windows))] + let extension = "json"; + self.path() + .join("identity") + .join("pending") + .join(format!("{}.{extension}", token_file_id(token))) + } + + fn pending_path(&self) -> PathBuf { + self.pending_path_for(self.tokens.last().expect("pending token is tracked")) + } + + fn identity_path(&self, authority_id: &str) -> PathBuf { + self.path() + .join("identity") + .join("authorities") + .join(authority_id) + .join("identity.json") + } + + fn in_progress_path_for(&self, token: &str) -> PathBuf { + self.path() + .join("identity") + .join("pending") + .join(format!("{}.in-progress.json", token_file_id(token))) + } + + fn in_progress_path(&self) -> PathBuf { + self.in_progress_path_for(self.tokens.last().expect("pending token is tracked")) + } + + fn rejection_path_for(&self, token: &str) -> PathBuf { + self.path() + .join("identity") + .join("rejected") + .join(format!("{}.json", token_file_id(token))) + } + + fn metadata_override_path(&self) -> PathBuf { + self.path().join("identity-metadata-override.json") + } + + fn set_metadata_hostname(&mut self, hostname: &str) -> anyhow::Result<()> { + let replacement = self.path().join("identity-metadata-override-next.json"); + std::fs::write(&replacement, serde_json::to_vec(&json!({ "hostname": hostname }))?)?; + std::fs::rename(&replacement, self.metadata_override_path())?; + self.metadata_hostname = hostname.to_owned(); + Ok(()) + } + + #[cfg(windows)] + fn set_acl_grant_current_user(&mut self, allowed: bool) -> anyhow::Result<()> { + ensure!( + self.child.is_none(), + "key ACL setting must be set before starting the agent" + ); + let path = self.path().join("agent.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&path)?)?; + config["__debug__"]["identity"]["acl_grant_current_user"] = json!(allowed); + std::fs::write(path, serde_json::to_vec_pretty(&config)?)?; + Ok(()) + } + + /// Raises the agent's maximum retry delay, which also caps how long it honors `Retry-After` + /// (CONTRACT.agent-identity.md §5.4). + fn set_backoff_max_secs(&mut self, secs: u64) -> anyhow::Result<()> { + ensure!( + self.child.is_none(), + "the retry cap must be set before starting the agent" + ); + let path = self.path().join("agent.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&path)?)?; + config["__debug__"]["identity"]["backoff_max_secs"] = json!(secs); + std::fs::write(path, serde_json::to_vec_pretty(&config)?)?; + Ok(()) + } + + /// Removes the renewal schedule override, so the agent renews at its scheduled time again. + fn clear_renewal_after_secs(&mut self) -> anyhow::Result<()> { + ensure!( + self.child.is_none(), + "the renewal schedule must be set before starting the agent" + ); + let path = self.path().join("agent.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&path)?)?; + config["__debug__"]["identity"] + .as_object_mut() + .context("agent configuration has no identity debug settings")? + .remove("renewal_after_secs"); + std::fs::write(path, serde_json::to_vec_pretty(&config)?)?; + Ok(()) + } + + fn key_file_path(&self, name: &str) -> PathBuf { + self.path().join("identity").join("keys").join(format!("{name}.p8")) + } + + fn in_progress(&self) -> anyhow::Result> { + self.in_progress_for(self.tokens.last().expect("pending token is tracked")) + } + + fn in_progress_for(&self, token: &str) -> anyhow::Result> { + let path = self.in_progress_path_for(token); + if !path.exists() { + return Ok(None); + } + let record: Value = serde_json::from_slice(&std::fs::read(path)?).context("read pending in-progress record")?; + ensure!(record["version"] == 1, "in-progress enrollment has wrong version"); + ensure!( + record["token_sha256"] == token_file_id(token), + "in-progress record does not belong to its pending token" + ); + validate_key_name(&self.ctx.key_name_prefix, field(&record, "key_name")?)?; + Ok(Some(record)) + } + + fn state(&self, authority_id: &str) -> anyhow::Result { + // UNTESTED: on Windows, the suite reads the agent's files with read, write and delete sharing, the default of `std::fs`, so polling never blocks the agent's replace of a record. + // A reader without delete sharing makes that replace fail until the reader closes the file, which delays the agent and keeps its temporary file meanwhile. + // When reading the agent's files through another API, keep `FILE_SHARE_DELETE`. + serde_json::from_slice(&std::fs::read(self.identity_path(authority_id))?).context("read stored identity") + } + + fn has_stored_identity(&self) -> anyhow::Result { + let authorities = self.path().join("identity").join("authorities"); + if !authorities.exists() { + return Ok(false); + } + for entry in std::fs::read_dir(authorities)? { + if entry?.path().join("identity.json").exists() { + return Ok(true); + } + } + Ok(false) + } + + fn add_token(&mut self, token: &str) { + self.tokens.push(token.to_owned()); + } + + fn observe_agent_metadata(&self, record: &IdentityRecord) -> anyhow::Result<()> { + validate_agent_metadata( + &serde_json::to_value(&record.metadata)?, + self.ctx.agent_version.as_deref(), + ) + } + + /// The identity's full management API record, after checking the agent metadata it reports. + async fn identity_record(&mut self, target: &Target, identity_id: &str) -> anyhow::Result { + let record = target.management().identity(identity_id).await?; + self.observe_agent_metadata(&record)?; + Ok(record) + } + + /// Like [`Self::identity_record`], but `None` once the identity no longer exists. + async fn find_identity_record( + &mut self, + target: &Target, + identity_id: &str, + ) -> anyhow::Result> { + let record = target.management().find_identity(identity_id).await?; + if let Some(record) = &record { + self.observe_agent_metadata(record)?; + } + Ok(record) + } + + fn write_pending(&mut self, token: &str) -> anyhow::Result<()> { + self.add_token(token); + let path = self.pending_path(); + std::fs::create_dir_all(path.parent().context("pending parent")?)?; + let contents = serde_json::to_vec(&json!({ "version": 1, "token": token }))?; + #[cfg(windows)] + { + crate::windows::write_pending(&path, &contents)?; + crate::windows::pending_acl_is_protected(&path)?; + } + #[cfg(unix)] + { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path)?; + file.write_all(&contents)?; + } + Ok(()) + } + + async fn start(&mut self) -> anyhow::Result<()> { + ensure!(self.child.is_none(), "agent is already running"); + let stdout = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(self.path().join("agent-stdout.log"))?; + let stderr = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(self.path().join("agent-stderr.log"))?; + let mut command = Command::new(&self.ctx.agent_bin); + command + .arg("run") + .env("DAGENT_CONFIG_PATH", self.path()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .kill_on_drop(true); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.as_std_mut().process_group(0); + } + let process = command.spawn().context("spawn devolutions-agent run")?; + #[cfg(windows)] + let mut process = process; + #[cfg(windows)] + { + let pid = process.id().context("agent has no process ID")?; + match crate::windows::AgentJob::attach(pid) { + Ok(job) => self.job = Some(job), + Err(error) => { + let _ = process.kill().await; + let _ = process.wait().await; + return Err(error.context("attach agent to kill-on-close job")); + } + } + } + #[cfg(unix)] + { + self.process_group = Some(i32::try_from(process.id().context("agent has no process ID")?)?); + } + self.child = Some(process); + Ok(()) + } + + async fn stop(&mut self) -> anyhow::Result<()> { + self.kill_process_tree(); + if let Some(mut child) = self.child.take() { + if child.try_wait()?.is_none() { + child.kill().await.context("stop agent")?; + } + let _ = child.wait().await; + } + Ok(()) + } + + fn kill_process_tree(&mut self) { + #[cfg(windows)] + drop(self.job.take()); + #[cfg(unix)] + if let Some(group) = self.process_group.take() { + // SAFETY: The agent was spawned as the leader of this new process group. + let _ = unsafe { libc::kill(-group, libc::SIGKILL) }; + } + } + + fn check_running(&mut self) -> anyhow::Result<()> { + if let Some(child) = self.child.as_mut() + && let Some(status) = child.try_wait()? + { + anyhow::bail!("agent exited before observing identity state ({status})"); + } + Ok(()) + } + + async fn enrolled(&mut self, target: &Target, token: &Token) -> anyhow::Result<(String, Value)> { + let start = Instant::now(); + loop { + let listing = target + .management() + .identities(&IdentityQuery::enrolled_with(token).full()) + .await?; + if let [record] = listing.items.as_slice() { + let id = record.id.clone(); + let identity_dir = self.path().join("identity").join("authorities"); + if identity_dir.exists() { + for path in std::fs::read_dir(identity_dir)? { + let authority = path?; + let json_path = authority.path().join("identity.json"); + if json_path.exists() { + let state: Value = serde_json::from_slice(&std::fs::read(json_path)?)?; + if state["identity_id"] == id && !self.pending_path_for(&token.text).exists() { + ensure!( + key_exists(self, key_name(&state, "current")?)?, + "settled enrollment has no current identity key" + ); + self.observe_agent_metadata(record)?; + if let Some(known) = target.authority_id { + ensure!( + state["authority_id"] == known.to_string(), + "agent stored an unexpected authority ID" + ); + } + return Ok((id, state)); + } + } + } + } + } + self.check_running()?; + ensure!( + start.elapsed() < WAIT, + "agent did not enroll and persist an identity within 20 seconds" + ); + tokio::time::sleep(POLL).await; + } + } + + async fn until_identity( + &mut self, + target: &Target, + identity_id: &str, + what: &str, + check: F, + ) -> anyhow::Result + where + F: Fn(&IdentityRecord) -> bool, + { + self.until_identity_within(target, identity_id, what, WAIT, check).await + } + + /// Like [`Self::until_identity`], but waits up to `wait`. + async fn until_identity_within( + &mut self, + target: &Target, + identity_id: &str, + what: &str, + wait: Duration, + check: F, + ) -> anyhow::Result + where + F: Fn(&IdentityRecord) -> bool, + { + let start = Instant::now(); + loop { + if let Some(record) = self.find_identity_record(target, identity_id).await? + && check(&record) + { + return Ok(record); + } + self.check_running()?; + ensure!( + start.elapsed() < wait, + "agent did not reach {what} within {} seconds", + wait.as_secs() + ); + tokio::time::sleep(POLL).await; + } + } + + async fn until_pending_deleted( + &mut self, + target: &Target, + pending: &str, + attempted: &Token, + expected_identities: u64, + expected_uses: u64, + ) -> anyhow::Result<()> { + let start = Instant::now(); + loop { + ensure!( + target.management().identity_count(attempted).await? == expected_identities, + "permanently invalid pending token changed its identity count" + ); + ensure!( + target.management().token(&attempted.id).await?.used_count == expected_uses, + "permanently invalid pending token consumed a use" + ); + #[cfg(windows)] + let machine_keys_gone = crate::windows::machine_keys_with_prefix(&self.ctx.key_name_prefix)?.is_empty(); + #[cfg(not(windows))] + let machine_keys_gone = true; + if !self.pending_path_for(pending).exists() + && !self.in_progress_path_for(pending).exists() + && self.rejection_path_for(pending).exists() + && !self.has_stored_identity()? + && self.file_keys_with_prefix()?.is_empty() + && machine_keys_gone + { + let marker: Value = serde_json::from_slice(&std::fs::read(self.rejection_path_for(pending))?)?; + ensure!( + marker["version"] == 1 && marker["token_sha256"] == token_file_id(pending), + "rejection marker has an invalid version or full-token hex hash" + ); + time::OffsetDateTime::parse(field(&marker, "at")?, &Rfc3339)?; + return Ok(()); + } + self.check_running()?; + ensure!( + start.elapsed() < WAIT, + "agent kept a permanently invalid pending token, in-progress record, key or identity" + ); + tokio::time::sleep(POLL).await; + } + } + + async fn until_rejected( + &mut self, + target: &Target, + authority_id: &str, + identity_id: &str, + error: &str, + ) -> anyhow::Result { + let start = Instant::now(); + loop { + let _ = self.find_identity_record(target, identity_id).await?; + let state = self.state(authority_id)?; + if state["rejected"]["code"] == error { + return Ok(state); + } + self.check_running()?; + ensure!( + start.elapsed() < WAIT, + "agent did not persist rejected identity {error}" + ); + tokio::time::sleep(POLL).await; + } + } + + async fn until_state( + &mut self, + target: &Target, + authority_id: &str, + identity_id: &str, + what: &str, + check: F, + ) -> anyhow::Result + where + F: Fn(&Value) -> bool, + { + let start = Instant::now(); + loop { + let _ = self.find_identity_record(target, identity_id).await?; + if let Ok(state) = self.state(authority_id) + && check(&state) + { + return Ok(state); + } + self.check_running()?; + ensure!(start.elapsed() < WAIT, "agent did not persist {what} within 20 seconds"); + tokio::time::sleep(POLL).await; + } + } + + /// Waits until `done` holds for the agent's files, which the agent may still be changing after the authority answered. + async fn until_files(&mut self, what: &str, done: impl Fn() -> bool) -> anyhow::Result<()> { + let start = Instant::now(); + while !done() { + self.check_running()?; + ensure!( + start.elapsed() < WAIT, + "agent did not complete {what} within 20 seconds" + ); + tokio::time::sleep(POLL).await; + } + Ok(()) + } + + /// Waits until the agent has deleted each token's pending file and in-progress record. + /// + /// On success the agent deletes both ([agent intent], "Pending-enrollment files"), but nothing orders their deletion before it stores the identity. + /// + /// [agent intent]: ../../agent-identity/INTENT.md#pending-enrollment-files + async fn until_pending_consumed(&mut self, tokens: &[&Token]) -> anyhow::Result<()> { + let paths = tokens + .iter() + .flat_map(|token| { + [ + self.pending_path_for(&token.text), + self.in_progress_path_for(&token.text), + ] + }) + .collect::>(); + self.until_files("the deletion of the pending files and in-progress records", || { + paths.iter().all(|path| !path.exists()) + }) + .await + } + + async fn finish(&mut self) -> anyhow::Result<()> { + let stopped = self.stop().await; + let layout = self.audit_identity_tree(); + let protected = self.audit_stored_keys(); + let orphaned = self.audit_orphan_keys(); + let tokens = self.audit_tokens(); + let cleaned = self.cleanup_run_keys(); + self.cleaned = cleaned.is_ok(); + stopped?; + layout?; + protected?; + orphaned?; + tokens?; + cleaned + } + + fn audit_identity_tree(&self) -> anyhow::Result<()> { + let identity = self.path().join("identity"); + let metadata = match std::fs::symlink_metadata(&identity) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + ensure!( + metadata.file_type().is_dir(), + "identity directory is not a real directory" + ); + let authorities = identity.join("authorities"); + let keys = identity.join("keys"); + let pending = identity.join("pending"); + let recorded_keys = self.recorded_key_names()?; + let recorded_files = if self.ctx.key_backend == KeyBackend::File { + recorded_keys + .iter() + .map(|name| self.key_file_path(name)) + .collect::>() + } else { + HashSet::new() + }; + let mut documented = recorded_files.clone(); + for token in &self.tokens { + documented.insert(self.pending_path_for(token)); + documented.insert(self.in_progress_path_for(token)); + } + if authorities.exists() { + for entry in std::fs::read_dir(&authorities)? { + let entry = entry?; + if entry.file_type()?.is_dir() && canonical_authority_dir(&entry.path()) { + documented.insert(entry.path().join("identity.json")); + } + } + } + + let mut directories = vec![identity]; + while let Some(directory) = directories.pop() { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + let known_directory = path == pending + || path == keys + || path == authorities + || path.parent() == Some(authorities.as_path()) && canonical_authority_dir(&path); + if file_type.is_dir() { + if !known_directory { + eprintln!("WARN unknown identity directory {}", path.display()); + } + directories.push(path); + continue; + } + ensure!( + !known_directory, + "documented identity directory {} is not a directory", + path.display() + ); + let recorded = recorded_files.contains(&path); + ensure!( + !path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("p8")) + || recorded, + "unrecorded private key file {}", + path.display() + ); + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if path.parent() == Some(keys.as_path()) + && let Some(key) = embedded_key_name(&file_name, &self.ctx.key_name_prefix) + && !recorded_keys.contains(key) + { + // A leftover copy of a deleted key survives only until a later operation in its key store (`crates/device-key/INTENT.md`, "Operations"), and the agent's last operation may have left it. + eprintln!("WARN leftover copy of deleted key {key}: {}", path.display()); + continue; + } + let temporary = is_temporary_sibling(&path, &documented, &self.ctx.key_name_prefix); + // A record's atomic write can leave its temporary file when it does not complete, as when the agent is stopped mid-write (`is_record_temporary`); any other temporary sibling is a leftover. + ensure!( + !temporary || is_record_temporary(&path), + "leftover identity temporary file {}", + path.display() + ); + if file_type.is_file() && !recorded { + ensure!( + !contains_pkcs8_material(&std::fs::read(&path)?), + "unrecorded PKCS#8 private key in {}", + path.display() + ); + } + if documented.contains(&path) { + ensure!( + file_type.is_file(), + "documented identity file {} is not a file", + path.display() + ); + } else if temporary { + eprintln!("WARN temporary file of an interrupted record write {}", path.display()); + } else { + eprintln!("WARN unknown identity file {}", path.display()); + } + } + } + Ok(()) + } + + fn file_keys_with_prefix(&self) -> anyhow::Result> { + let dir = self.path().join("identity").join("keys"); + let mut keys = HashSet::new(); + if dir.exists() { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let file_name = entry.file_name(); + let Some(name) = file_name.to_str().and_then(|name| name.strip_suffix(".p8")) else { + continue; + }; + if key_belongs_to_run(name, &self.ctx.key_name_prefix) { + ensure!( + entry.file_type()?.is_file(), + "key file {} is not a regular file", + entry.path().display() + ); + keys.insert(name.to_owned()); + } + } + } + Ok(keys) + } + + fn recorded_key_names(&self) -> anyhow::Result> { + let mut recorded = HashSet::new(); + let authorities = self.path().join("identity").join("authorities"); + if authorities.exists() { + for authority in std::fs::read_dir(authorities)? { + let authority = authority?; + if !authority.file_type()?.is_dir() || !canonical_authority_dir(&authority.path()) { + continue; + } + let path = authority.path().join("identity.json"); + if path.exists() { + let state: Value = serde_json::from_slice(&std::fs::read(&path)?) + .with_context(|| format!("read {}", path.display()))?; + for slot in ["current", "pending", "previous"] { + if let Some(name) = state["keys"][slot]["key_name"].as_str() { + validate_key_name(&self.ctx.key_name_prefix, name)?; + recorded.insert(name.to_owned()); + } + } + } + } + } + for token in &self.tokens { + if let Some(progress) = self.in_progress_for(token)? { + recorded.insert(field(&progress, "key_name")?.to_owned()); + } + } + Ok(recorded) + } + + fn audit_orphan_keys(&self) -> anyhow::Result<()> { + let recorded = self.recorded_key_names()?; + let existing = self.file_keys_with_prefix()?; + #[cfg(windows)] + let existing = { + let mut existing = existing; + existing.extend(crate::windows::machine_keys_with_prefix(&self.ctx.key_name_prefix)?); + existing + }; + let mut orphaned = existing.difference(&recorded).cloned().collect::>(); + orphaned.sort_unstable(); + ensure!( + orphaned.is_empty(), + "unrecorded identity keys with run prefix: {}", + orphaned.join(", ") + ); + Ok(()) + } + + fn cleanup_run_keys(&self) -> anyhow::Result<()> { + let files = self.file_keys_with_prefix(); + #[cfg(windows)] + let machine_cleanup = crate::windows::cleanup_machine_keys(&self.ctx.key_name_prefix); + for name in files? { + std::fs::remove_file(self.key_file_path(&name)) + .with_context(|| format!("delete run-owned file key {name}"))?; + } + #[cfg(windows)] + machine_cleanup?; + Ok(()) + } + + fn audit_stored_keys(&self) -> anyhow::Result<()> { + let authorities = self.path().join("identity").join("authorities"); + if !authorities.exists() { + return Ok(()); + } + for authority in std::fs::read_dir(authorities)? { + let authority = authority?; + if !authority.file_type()?.is_dir() || !canonical_authority_dir(&authority.path()) { + continue; + } + let path = authority.path().join("identity.json"); + if path.exists() { + let state: Value = serde_json::from_slice(&std::fs::read(&path)?)?; + let id = field(&state, "authority_id")?; + self.check_key_protection_inner(id, &state, false)?; + } + } + Ok(()) + } + + fn check_key_protection(&self, authority_id: &str, state: &Value) -> anyhow::Result<()> { + self.check_key_protection_inner(authority_id, state, true) + } + + /// Checks every key that `state` names and that exists. + /// + /// The agent records a key's name before it creates the key, and deletes the key before it removes the name ([agent intent], "Stored identities"), so a record can name a key that does not exist. + /// A missing key fails only as the current key of a settled identity (`require_current`) that the stored identity still names as current. + /// When the stored identity names another current key, a renewal has retired the missing one, and the check runs again on the stored identity. + /// + /// [agent intent]: ../../agent-identity/INTENT.md#stored-identities + fn check_key_protection_inner( + &self, + authority_id: &str, + state: &Value, + require_current: bool, + ) -> anyhow::Result<()> { + // Each further read needs another renewal to have retired the current key meanwhile, and the agent renews at most every few seconds. + const READS: usize = 3; + + let mut latest; + let mut state = state; + for _ in 0..READS { + if require_current { + ensure!( + state["keys"]["current"]["key_name"].is_string(), + "settled identity has no current key name" + ); + } + let mut missing_current = None; + for slot in ["current", "pending", "previous"] { + let Some(name) = state["keys"][slot]["key_name"].as_str() else { + continue; + }; + let exists = self.check_key_if_exists(slot, name, &state["keys"][slot])?; + if !exists && require_current && slot == "current" { + missing_current = Some(name.to_owned()); + } + } + let Some(missing) = missing_current else { + return Ok(()); + }; + latest = self.state(authority_id)?; + ensure!( + latest["keys"]["current"]["key_name"] != missing, + "settled current key {missing} is missing" + ); + state = &latest; + } + anyhow::bail!("settled current key changed on each of {READS} reads of the stored identity") + } + + /// Checks the key `name` that `slot` stores, and returns `false` if the key does not exist. + fn check_key_if_exists(&self, slot: &str, name: &str, stored: &Value) -> anyhow::Result { + validate_key_name(&self.ctx.key_name_prefix, name)?; + let expected_spki = stored["certificate_chain"] + .as_array() + .map(|chain| -> anyhow::Result> { + let leaf = chain + .first() + .and_then(Value::as_str) + .context("stored certificate chain is empty")?; + let certificate = Certificate::from_der(&decoded_certificate(leaf)?)?; + Ok(certificate.tbs_certificate().subject_public_key_info().to_der()?) + }) + .transpose()?; + match self.check_key(slot, name, expected_spki.as_deref()) { + // UNTESTED: a key deleted during its checks counts as missing, not as a failed check. + // Each check opens the key again, and a key deleted in between fails that check in a backend-specific way; the agent deletes keys during every renewal. + // When changing the checks, keep attributing a failure to the deletion only when the key is gone afterwards. + Err(_) if !key_exists(self, name)? => Ok(false), + checked => checked, + } + } + + fn check_key(&self, slot: &str, name: &str, expected_spki: Option<&[u8]>) -> anyhow::Result { + #[cfg(windows)] + if self.ctx.key_backend == KeyBackend::WindowsSoftwareProvider { + if !crate::windows::machine_key_exists(name)? { + return Ok(false); + } + crate::windows::assert_non_exportable(name)?; + crate::windows::assert_machine_key_acl(name)?; + if let Some(expected) = expected_spki { + let key = VerifyingKey::from_sec1_bytes(&crate::windows::machine_public_key(name)?)?; + ensure!( + key.to_public_key_der()?.as_bytes() == expected, + "{slot} machine key does not match its leaf SPKI" + ); + } + return Ok(true); + } + #[cfg(any(unix, windows))] + if self.ctx.key_backend == KeyBackend::File { + let path = self.key_file_path(name); + if !path.exists() { + return Ok(false); + } + #[cfg(unix)] + assert_file_key_access(&path)?; + if let Some(expected) = expected_spki { + let key = SigningKey::from_pkcs8_der(&std::fs::read(&path)?)?; + ensure!( + key.verifying_key().to_public_key_der()?.as_bytes() == expected, + "{slot} file key does not match its leaf SPKI" + ); + } + return Ok(true); + } + #[cfg(not(any(unix, windows)))] + let _ = (slot, name, expected_spki); + Ok(true) + } + + fn audit_tokens(&self) -> anyhow::Result<()> { + self.audit_tokens_excluding(None) + } + + fn audit_tokens_excluding(&self, excluded: Option<&Path>) -> anyhow::Result<()> { + let mut stack = vec![self.path().to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if excluded == Some(path.as_path()) + || self.tokens.iter().any(|token| path == self.pending_path_for(token)) + { + continue; + } + let contents = std::fs::read(&path)?; + for token in &self.tokens { + assert_no_token_fragments(&contents, token, &path)?; + } + } + } + Ok(()) + } +} + +impl Drop for AgentCase { + fn drop(&mut self) { + self.kill_process_tree(); + if let Some(mut child) = self.child.take() { + let _ = child.start_kill(); + for _ in 0..100 { + if child.try_wait().ok().flatten().is_some() { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + } + if !self.cleaned { + if let Err(error) = self.audit_identity_tree() { + eprintln!("agent-case identity tree audit failed: {error:#}"); + } + if let Err(error) = self.audit_orphan_keys() { + eprintln!("agent-case orphan audit failed: {error:#}"); + } + if let Err(error) = self.audit_tokens() { + eprintln!("agent-case cleanup audit failed: {error:#}"); + } + if let Err(error) = self.cleanup_run_keys() { + eprintln!("agent-case key cleanup failed: {error:#}"); + } + } + if self.keep { + // The run's keys are deleted above or by `finish`, and the path holds no secret. + self.dir.disable_cleanup(true); + println!("KEPT {} {}", self.ctx.test, self.path().display()); + } + } +} + +/// Runs `work` as one agent sub-case, with a new agent data directory that is audited once the agent stops. +/// +/// On the mock, every sub-case starts from reset authorities, so it inherits no fault, setting, clock offset, barrier, token or identity from an earlier sub-case of the same test. +/// A sub-case therefore sets only the faults it needs and relies on the reset for every default. +/// DVLS has no reset, so an agent sub-case that runs on it changes no settings. +/// A sub-case that fails or times out keeps its directory, which [`AgentCase`]'s drop reports. +async fn with_agent(ctx: Context, renewal_after_secs: Option, run: bool, work: F) -> anyhow::Result<()> +where + F: for<'a> FnOnce(&'a mut AgentCase) -> CaseFuture<'a>, +{ + reset_mocks(&ctx).await?; + let mut case = AgentCase::new(ctx, renewal_after_secs, run).await?; + let result = work(&mut case).await; + let audit = case.finish().await; + case.keep = result.is_err() || audit.is_err(); + match (result, audit) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) => Err(error), + (Ok(()), Err(error)) => Err(error), + (Err(error), Err(audit)) => anyhow::bail!("{error:#}; cleanup/audit: {audit:#}"), + } +} + +/// Resets each mock authority, the second one included, as `__mock__/reset` describes. +async fn reset_mocks(ctx: &Context) -> anyhow::Result<()> { + if ctx.mock() { + ctx.target.reset().await?; + if let Some(second) = &ctx.second { + second.target.reset().await?; + } + } + Ok(()) +} + +async fn new_token(case: &AgentCase, uses: u64) -> anyhow::Result { + case.ctx + .target + .management() + .create_token(uses, Duration::from_secs(3600), None) + .await +} + +async fn dropped_enrollment(case: &mut AgentCase, token: &Token) -> anyhow::Result<(String, String)> { + let target = case.ctx.target.clone(); + let before = count(&target.requests(Some(token)).await?, "enroll")?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "enroll", "pause": true })) + .await?, + 200, + )?; + target.faults(&json!({ "drop_next_response": "enroll" })).await?; + case.write_pending(&token.text)?; + let started = Instant::now(); + let mut unsettled = "agent did not commit a dropped-response enrollment and persist its key within 20 seconds"; + loop { + let requests = target.requests(Some(token)).await?; + let attempts = count(&requests, "enroll")?; + ensure!( + attempts <= before + 1 + count(&requests, "enroll_retry_503")?, + "agent retried enrollment successfully while the retry barrier was held" + ); + let listing = target + .management() + .identities(&IdentityQuery::enrolled_with(token)) + .await?; + let record = target.management().token(&token.id).await?; + if listing.total_count == 1 + && record.used_count == 1 + && let Some(progress) = case.in_progress()? + { + ensure!( + case.pending_path().exists(), + "agent deleted the pending file before receiving a response" + ); + ensure!( + !case.has_stored_identity()?, + "agent stored an identity before receiving a response" + ); + ensure!( + progress["token_sha256"] == token_file_id(&token.text), + "in-progress enrollment has the wrong full-token hash" + ); + let key = field(&progress, "key_name")?.to_owned(); + ensure!(key_exists(case, &key)?, "committed enrollment key is missing"); + // The enrollment can commit between the counter and listing reads, and the mock's README orders neither its counters nor the fault's consumption before the commit, so wait for both. + let consumed = target.control("faults", &json!({})).await?.body["drop_next_response"].is_null(); + if before < attempts && consumed { + let id = listing + .items + .first() + .context("listing omitted the identity")? + .id + .clone(); + return Ok((id, key)); + } + unsettled = if before < attempts { + "the dropped enroll response fault was not consumed" + } else { + "enrollment committed without a recorded request" + }; + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "{unsettled}"); + tokio::time::sleep(POLL).await; + } +} + +async fn assert_terminal_stops( + case: &mut AgentCase, + target: &Target, + authority: &str, + identity_id: Option<&str>, + error: &str, + since: time::OffsetDateTime, +) -> anyhow::Result<()> { + let first = case.state(authority)?; + let at = field(&first["rejected"], "at")?.to_owned(); + let timestamp = time::OffsetDateTime::parse(&at, &Rfc3339)?; + ensure!( + timestamp >= since - time::Duration::seconds(2) && timestamp <= time::OffsetDateTime::now_utc(), + "rejected.at was not newly set by this terminal error" + ); + let counters = if case.ctx.mock() { + Some(target.requests(None).await?) + } else { + None + }; + let before_record = if let Some(id) = identity_id { + Some(case.identity_record(target, id).await?) + } else { + None + }; + for phase in 0..2 { + for _ in 0..3 { + tokio::time::sleep(Duration::from_secs(2)).await; + case.check_running()?; + let state = case.state(authority)?; + ensure!( + state["rejected"]["code"] == error && state["rejected"]["at"] == at, + "terminal rejection changed during backoff observation" + ); + if let Some(before) = &counters { + let after = target.requests(None).await?; + ensure!( + after["renew"] == before["renew"] + && after["confirm"] == before["confirm"] + && after["check_in"] == before["check_in"] + && after["connect"] == before["connect"], + "rejected identity sent another signed request during backoff cycles" + ); + } + if let Some(id) = identity_id { + let now = case.identity_record(target, id).await?; + ensure!( + !now.connected + && before_record + .as_ref() + .is_some_and(|before| now.certificates == before.certificates), + "rejected identity reconnected or issued another certificate" + ); + } + } + if phase == 0 { + case.stop().await?; + case.start().await?; + } + } + Ok(()) +} + +fn assert_stored_identity( + case: &AgentCase, + token: &Token, + state: &Value, + record: &IdentityRecord, +) -> anyhow::Result<()> { + ensure!(state["version"] == 1, "stored identity has wrong version"); + ensure!( + state["identity_id"] == record.id, + "stored identity has wrong identity ID" + ); + let authority = field(state, "authority_id")?; + ensure!( + uuid::Uuid::parse_str(authority).is_ok(), + "stored identity authority is not a UUID" + ); + if let Some(known) = case.ctx.target.authority_id { + ensure!(authority == known.to_string(), "stored identity has wrong authority ID"); + } + ensure!( + field(state, "base_url")? == case.ctx.target.base_url, + "stored identity has wrong base URL" + ); + let config = state["config"].as_object().context("stored identity config missing")?; + ensure!( + config.get("version") == Some(&json!(1)), + "stored identity has wrong config version" + ); + ensure!( + config.get("revision").and_then(Value::as_u64).is_some(), + "stored identity lacks config.revision" + ); + if case.ctx.channel_available { + let channel_url = field(&state["config"], "agent_channel_url")?; + ensure!( + reqwest::Url::parse(channel_url)?.scheme() == "https", + "stored identity has an invalid config.agent_channel_url" + ); + if case.ctx.mock() { + ensure!( + channel_url == case.ctx.target.base_url, + "stored mock config.agent_channel_url does not match enrollment" + ); + } + } else { + ensure!( + config.get("agent_channel_url").is_none(), + "unavailable config.agent_channel_url was stored" + ); + } + if case.ctx.mock() { + let mut expected = json!({ "version": 1, "revision": config["revision"] }); + if case.ctx.channel_available { + expected["agent_channel_url"] = json!(case.ctx.target.base_url); + } + ensure!( + state["config"] == expected, + "agent did not store the mock's enrollment config exactly" + ); + } + ensure!(state.get("rejected").is_none(), "new identity is already rejected"); + ensure!( + state["token_sha256"] == token_file_id(&token.text), + "stored identity has wrong full-token hash" + ); + ensure!( + record.metadata_value("hostname") == Some(&*case.metadata_hostname), + "enrollment ignored the configured metadata hostname" + ); + ensure!( + state["keys"]["current"]["certificate_chain"] + .as_array() + .is_some_and(|chain| !chain.is_empty()), + "stored identity has an empty current certificate chain" + ); + ensure!( + key_exists(case, key_name(state, "current")?)?, + "enrolled identity's current key is missing" + ); + let mut names = HashSet::new(); + for slot in ["current", "pending", "previous"] { + if let Some(name) = state["keys"][slot]["key_name"].as_str() { + validate_key_name(&case.ctx.key_name_prefix, name) + .with_context(|| format!("stored {slot} key name is invalid"))?; + ensure!(names.insert(name), "stored key slots refer to the same key"); + if let Some(chain) = state["keys"][slot]["certificate_chain"].as_array() { + ensure!(!chain.is_empty(), "{slot} certificate chain is empty"); + ensure!( + chain.iter().all(|cert| cert.as_str().is_some()), + "{slot} certificate chain contains non-string entries" + ); + } + } + } + Ok(()) +} + +fn current_thumbprint(record: &IdentityRecord) -> anyhow::Result<&str> { + record + .certificate_with(CertificateStatus::Current) + .map(|cert| cert.thumbprint.as_str()) + .context("no current certificate") +} + +fn assert_check_in_triggers_renewal(requests: &Value, start: usize) -> anyhow::Result<()> { + let sequence = requests["request_sequence"] + .as_array() + .context("mock request sequence missing")?; + let steps = sequence + .iter() + .skip(start) + .filter_map(Value::as_str) + .collect::>(); + let position = |name: &str| steps.iter().position(|step| *step == name); + let (Some(check_in), Some(renew), Some(confirm)) = (position("check-in"), position("renew"), position("confirm")) + else { + anyhow::bail!("channelless request sequence omitted check-in, renew or confirm"); + }; + ensure!( + check_in < renew && renew < confirm, + "channelless renewal did not follow check-in → renew → confirm" + ); + Ok(()) +} + +fn key_name<'a>(state: &'a Value, slot: &str) -> anyhow::Result<&'a str> { + field(&state["keys"][slot], "key_name") +} + +/// The thumbprint of the leaf stored on a key slot, or `None` while the slot holds no chain. +fn stored_leaf_thumbprint(state: &Value, slot: &str) -> anyhow::Result> { + state["keys"][slot]["certificate_chain"][0] + .as_str() + .map(crate::signer::thumbprint) + .transpose() +} + +fn request_sequence(requests: &Value) -> anyhow::Result<&[Value]> { + requests["request_sequence"] + .as_array() + .map(Vec::as_slice) + .context("mock request sequence missing") +} + +/// The renew and confirm attempts, with the mock's `confirm_204` markers, recorded after `earlier`. +/// +/// Check-ins are left out: once a `confirm` has been answered, the contract does not order them against a renewal. +fn renew_and_confirm_steps_since<'a>(requests: &'a Value, earlier: &Value) -> anyhow::Result> { + Ok(request_sequence(requests)? + .iter() + .skip(request_sequence(earlier)?.len()) + .filter_map(Value::as_str) + .filter(|step| matches!(*step, "renew" | "confirm" | "confirm_204")) + .collect()) +} + +fn renew_keyids(requests: &Value) -> anyhow::Result<&[Value]> { + requests["renew_attempt_keyids"] + .as_array() + .map(Vec::as_slice) + .context("mock renew signing-key trace missing") +} + +/// The thumbprints that signed the renew attempts recorded after `earlier`, in order. +fn renew_keyids_since<'a>(requests: &'a Value, earlier: &Value) -> anyhow::Result<&'a [Value]> { + Ok(renew_keyids(requests)? + .get(renew_keyids(earlier)?.len()..) + .unwrap_or_default()) +} + +fn key_exists(case: &AgentCase, name: &str) -> anyhow::Result { + validate_key_name(&case.ctx.key_name_prefix, name)?; + #[cfg(windows)] + if case.ctx.key_backend == KeyBackend::WindowsSoftwareProvider { + return crate::windows::machine_key_exists(name); + } + Ok(case.key_file_path(name).exists()) +} + +#[cfg(unix)] +fn assert_file_key_access(path: &Path) -> anyhow::Result<()> { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + + let metadata = std::fs::metadata(path)?; + let mode = metadata.permissions().mode() & 0o777; + ensure!(mode == 0o600, "{} has mode {mode:o}, expected 0600", path.display()); + // SAFETY: Calling geteuid reads the tester's effective uid, which the spawned agent inherits. + let agent_uid = unsafe { libc::geteuid() }; + ensure!( + metadata.uid() == agent_uid, + "{} is owned by uid {}, expected agent uid {agent_uid}", + path.display(), + metadata.uid() + ); + Ok(()) +} + +async fn wait_key_deleted(case: &mut AgentCase, name: &str) -> anyhow::Result<()> { + let started = Instant::now(); + while key_exists(case, name)? { + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "old key survived successful identity replacement" + ); + tokio::time::sleep(POLL).await; + } + Ok(()) +} + +/// The earliest moment the agent's schedule may renew a certificate issued after `issued_after` with `lifetime`, or `None` beyond the clock's range. +/// +/// The agent renews `2/3 × lifetime` after the certificate's anchor ([agent intent], "Schedules"), and `disable_jitter` removes the jitter. +/// It sizes waits only, so it takes the anchor to be the issuance. +/// +/// [agent intent]: ../../agent-identity/INTENT.md#schedules +fn earliest_renewal(issued_after: Instant, lifetime: Duration) -> Option { + issued_after.checked_add(lifetime.checked_mul(2)? / 3) +} + +/// How long a step may take when the agent's schedule may renew a certificate issued after `issued_after` with `lifetime` during it. +/// +/// The step gets [`WAIT`], plus another [`WAIT`] for the renewal when [`earliest_renewal`] comes before the first one ends: `WAIT × (1 + [issued_after + 2/3 × lifetime < now + WAIT])`. +/// It is a budget only: a test that waits with it asserts nothing about the renewal. +fn renewal_aware_wait(issued_after: Instant, lifetime: Duration) -> Duration { + match earliest_renewal(issued_after, lifetime) { + Some(renewal) if renewal < Instant::now() + WAIT => 2 * WAIT, + _ => WAIT, + } +} + +/// Waits until the agent has stored a renewal that replaced `old_key`, holds no pending or previous key, and has deleted `old_key`. +/// +/// The authority answers `confirm` before the agent stores its outcome and deletes the old key (`crates/agent-identity/INTENT.md`, "Stored identities"), so stopping the agent right after the `204` could interrupt those writes. +async fn until_renewal_settled( + case: &mut AgentCase, + target: &Target, + authority: &str, + identity_id: &str, + old_key: &str, +) -> anyhow::Result { + let state = case + .until_state(target, authority, identity_id, "a settled renewal", |state| { + key_name(state, "current").is_ok_and(|name| name != old_key) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + }) + .await?; + wait_key_deleted(case, old_key).await?; + Ok(state) +} + +/// Fails if a file in the key store still holds a copy of the deleted key `name`. +/// +/// No leftover copy of a private key survives a later operation in the same key store (`crates/device-key/INTENT.md`, "Operations"), so call this only after one. +fn assert_no_copy_of_deleted_key(case: &AgentCase, name: &str) -> anyhow::Result<()> { + let keys = case.path().join("identity").join("keys"); + if !keys.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(keys)? { + let file_name = entry?.file_name(); + ensure!( + embedded_key_name(&file_name.to_string_lossy(), &case.ctx.key_name_prefix) != Some(name), + "{} still holds a copy of deleted key {name} after a later key-store operation", + file_name.to_string_lossy() + ); + } + Ok(()) +} + +async fn assert_new_auth_precedes_old_close( + target: &Target, + identity_id: &str, + old_thumb: &str, + new_thumb: &str, +) -> anyhow::Result<()> { + let started = Instant::now(); + loop { + let events = target.events(identity_id).await?; + let confirmed = events.iter().find(|event| { + event["type"] == "cert_status_changed" && event["cert_thumbprint"] == new_thumb && event["to"] == "current" + }); + let new_auth = events + .iter() + .find(|event| event["type"] == "stream_authenticated" && event["cert_thumbprint"] == new_thumb); + let old_close = events + .iter() + .find(|event| event["type"] == "stream_closed" && event["cert_thumbprint"] == old_thumb); + if let (Some(confirmed), Some(new_auth), Some(old_close)) = (confirmed, new_auth, old_close) { + ensure!( + old_close["status"] == "OK" + && count(confirmed, "seq")? < count(new_auth, "seq")? + && count(new_auth, "seq")? < count(old_close, "seq")?, + "confirm, new channel authentication and old stream close occurred out of order" + ); + return Ok(()); + } + ensure!( + started.elapsed() < WAIT, + "confirm, new authentication or old stream-closed event missing" + ); + tokio::time::sleep(POLL).await; + } +} + +pub(crate) async fn a_pending_file_enroll_success(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 2).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + ensure!( + !case.pending_path().exists(), + "pending file not deleted after enrollment" + ); + let record = case.identity_record(&target, &id).await?; + ensure!(record.id == id, "enrolled identity absent from the management API"); + assert_stored_identity(case, &token, &state, &record)?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_bad_pending_files_do_not_reject_valid_tokens(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let probe = new_token(case, 1).await?; + case.write_pending(&probe.text)?; + let _ = case.enrolled(&target, &probe).await?; + let token_a = new_token(case, 1).await?; + let token_b = new_token(case, 1).await?; + let token_c = new_token(case, 1).await?; + case.add_token(&token_a.text); + for (file_token, contents) in [ + (&token_b.text, json!({ "version": 1, "token": token_a.text })), + (&token_c.text, json!({ "version": 2, "token": token_c.text })), + ] { + case.add_token(file_token); + let path = case.pending_path_for(file_token); + std::fs::create_dir_all(path.parent().context("pending directory")?)?; + // Stage outside the watched directory so only the complete malformed record is observed. + let staged = case + .path() + .join(format!("pending-fixture-{}.staged", uuid::Uuid::new_v4())); + let encoded = serde_json::to_vec(&contents)?; + #[cfg(windows)] + crate::windows::write_pending(&staged, &encoded)?; + #[cfg(unix)] + { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&staged)?; + file.write_all(&encoded)?; + } + std::fs::rename(staged, &path)?; + #[cfg(windows)] + crate::windows::pending_acl_is_protected(&path)?; + } + tokio::time::sleep(Duration::from_secs(1)).await; + case.check_running()?; + for (name, token) in [("A", &token_a), ("B", &token_b), ("C", &token_c)] { + ensure!( + !case.rejection_path_for(&token.text).exists(), + "malformed pending file wrote a rejection marker for valid token {name}" + ); + } + case.stop().await?; + for token in [&token_a, &token_b, &token_c] { + case.write_pending(&token.text)?; + } + case.start().await?; + let started = Instant::now(); + loop { + let mut all_enrolled = true; + for (name, token) in [("A", &token_a), ("B", &token_b), ("C", &token_c)] { + ensure!( + !case.rejection_path_for(&token.text).exists(), + "pending file wrote a rejection marker for valid token {name}" + ); + let identities = target.management().identity_count(token).await?; + let uses = target.management().token(&token.id).await?.used_count; + ensure!( + identities <= 1 && uses <= 1, + "correct pending token {name} created more than one identity" + ); + all_enrolled &= identities == 1 + && uses == 1 + && !case.pending_path_for(&token.text).exists() + && !case.in_progress_path_for(&token.text).exists(); + } + if all_enrolled { + break; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "correct pending files did not each enroll within 20 seconds" + ); + tokio::time::sleep(POLL).await; + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_undecryptable_pending_file_does_not_reject_token(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + #[cfg(windows)] + { + return with_agent(ctx, None, false, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + let before = count(&target.requests(None).await?, "enroll_total")?; + case.write_pending(&token.text)?; + let pending = case.pending_path_for(&token.text); + std::fs::write(&pending, b"not a DPAPI blob")?; + crate::windows::pending_acl_is_protected(&pending)?; + case.start().await?; + let started = Instant::now(); + loop { + ensure!( + count(&target.requests(None).await?, "enroll_total")? == before, + "undecryptable pending file sent an enrollment request" + ); + if !pending.exists() { + break; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not delete the undecryptable pending file" + ); + tokio::time::sleep(POLL).await; + } + ensure!( + !case.rejection_path_for(&token.text).exists(), + "undecryptable pending file wrote a rejection marker" + ); + case.write_pending(&token.text)?; + let _ = case.enrolled(&target, &token).await?; + ensure!( + !case.rejection_path_for(&token.text).exists() + && count(&target.requests(None).await?, "enroll_total")? == before + 1, + "valid token did not enroll once after an undecryptable pending file" + ); + Ok(()) + }) + }) + .await; + } + #[cfg(not(windows))] + { + let _ = ctx; + Err(crate::NotApplicable("DPAPI pending files require Windows").into()) + } +} + +pub(crate) async fn a_untrusted_tls_server_keeps_pending_token(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: intent crates/agent-identity/INTENT.md §Trust and transport + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, false, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let second = &case + .ctx + .second + .as_ref() + .context("second mock authority required")? + .target; + let root = second.ca_path.as_ref().context("second mock TLS CA required")?; + let path = case.path().join("agent.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&path)?)?; + config["__debug__"]["identity"]["extra_trusted_root"] = json!(root); + std::fs::write(path, serde_json::to_vec_pretty(&config)?)?; + let token = new_token(case, 1).await?; + let before = count(&target.requests(None).await?, "enroll_total")?; + case.start().await?; + case.write_pending(&token.text)?; + let started = Instant::now(); + loop { + if case.in_progress_for(&token.text)?.is_some() { + break; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not process the pending token for TLS validation" + ); + tokio::time::sleep(POLL).await; + } + tokio::time::sleep(Duration::from_secs(2)).await; + case.check_running()?; + ensure!( + count(&target.requests(None).await?, "enroll_total")? == before + && case.pending_path_for(&token.text).exists() + && !case.rejection_path_for(&token.text).exists(), + "untrusted TLS did not preserve the processed pending token" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_enroll_lost_response_retried_across_restart(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + let (id, key) = dropped_enrollment(case, &token).await?; + case.stop().await?; + let requests = target.requests(Some(&token)).await?; + ensure!( + count(&requests, "enroll")? == 1 + count(&requests, "enroll_retry_503")?, + "agent received an enrollment retry before it could be restarted" + ); + ensure!( + case.in_progress()? + .as_ref() + .and_then(|record| record["key_name"].as_str()) + == Some(key.as_str()) + && case.pending_path().exists(), + "pending enrollment key was not persisted across agent stop" + ); + let before_restart = count(&requests, "enroll")?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "enroll", "pause": false })) + .await?, + 200, + )?; + case.start().await?; + let (recovered_id, state) = case.enrolled(&target, &token).await?; + ensure!(recovered_id == id, "retry created a second identity"); + ensure!( + count(&target.requests(Some(&token)).await?, "enroll")? > before_restart, + "agent did not retry enrollment after restart" + ); + ensure!( + target.management().token(&token.id).await?.used_count == 1, + "retry consumed a second token use" + ); + ensure!( + key_name(&state, "current")? == key, + "agent did not reuse the in-progress key" + ); + case.until_pending_consumed(&[&token]).await?; + let record = case.identity_record(&target, &id).await?; + assert_stored_identity(case, &token, &state, &record)?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_two_pending_tokens_same_authority_last_wins(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, false, |case| { + Box::pin(async move { + // The first token's retry must wait out the second token's enrollment. + case.set_backoff_max_secs(10)?; + case.start().await?; + let target = case.ctx.target.clone(); + let original_token = new_token(case, 1).await?; + let replacement = new_token(case, 1).await?; + target + .faults(&json!({ + "fail_next_response": { + "endpoint": "enroll", + "status": 503, + "retry_after_secs": 8 + } + })) + .await?; + case.write_pending(&original_token.text)?; + let started = Instant::now(); + let original_key = loop { + if count(&target.requests(Some(&original_token)).await?, "enroll")? >= 1 + && target.control("faults", &json!({})).await?.body["fail_next_response"].is_null() + && let Some(progress) = case.in_progress_for(&original_token.text)? + { + let name = field(&progress, "key_name")?; + if key_exists(case, name)? { + break name.to_owned(); + } + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not persist its first pending enrollment during transient backoff" + ); + tokio::time::sleep(POLL).await; + }; + ensure!( + case.pending_path_for(&original_token.text).exists(), + "first pending file vanished during transient backoff" + ); + case.write_pending(&replacement.text)?; + let (replacement_id, replacement_state) = case.enrolled(&target, &replacement).await?; + let replacement_key = key_name(&replacement_state, "current")?.to_owned(); + ensure!( + replacement_key != original_key + && replacement_state["token_sha256"] == token_file_id(&replacement.text) + && case.pending_path_for(&original_token.text).exists() + && case.in_progress_for(&original_token.text)?.is_some() + && key_exists(case, &original_key)?, + "second token displaced the first pending file or in-progress key" + ); + let (original_id, state) = case.enrolled(&target, &original_token).await?; + ensure!( + original_id != replacement_id + && state["token_sha256"] == token_file_id(&original_token.text) + && key_name(&state, "current")? == original_key, + "last completing token did not become the stored identity for its authority" + ); + wait_key_deleted(case, &replacement_key).await?; + case.until_pending_consumed(&[&original_token, &replacement]).await?; + ensure!( + target.management().identity_count(&original_token).await? == 1 + && target.management().identity_count(&replacement).await? == 1 + && target.management().token(&original_token.id).await?.used_count == 1 + && target.management().token(&replacement.id).await?.used_count == 1, + "independent pending tokens were not both consumed" + ); + let record = case.identity_record(&target, &original_id).await?; + assert_stored_identity(case, &original_token, &state, &record)?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_two_pending_tokens_different_authorities_progress_independently( + ctx: Context, +) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + // oracle: intent crates/agent-identity/INTENT.md §Identities + with_agent(ctx, None, false, |case| { + Box::pin(async move { + // The first authority's retry must wait out the second authority's enrollment. + case.set_backoff_max_secs(10)?; + case.start().await?; + let first = case.ctx.target.clone(); + let second = case.ctx.second.clone().context("second authority required")?.target; + let first_token = first + .management() + .create_token(1, Duration::from_secs(3600), None) + .await?; + let second_token = second + .management() + .create_token(1, Duration::from_secs(3600), None) + .await?; + first + .faults(&json!({ + "fail_next_response": { + "endpoint": "enroll", + "status": 503, + "retry_after_secs": 8 + } + })) + .await?; + case.write_pending(&first_token.text)?; + let started = Instant::now(); + let first_key = loop { + if count(&first.requests(Some(&first_token)).await?, "enroll")? >= 1 + && first.control("faults", &json!({})).await?.body["fail_next_response"].is_null() + && let Some(progress) = case.in_progress_for(&first_token.text)? + { + let name = field(&progress, "key_name")?; + if key_exists(case, name)? { + break name.to_owned(); + } + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "first authority did not persist a pending key during transient backoff" + ); + tokio::time::sleep(POLL).await; + }; + case.write_pending(&second_token.text)?; + let (second_id, second_state) = case.enrolled(&second, &second_token).await?; + ensure!( + case.pending_path_for(&first_token.text).exists() + && case.in_progress_for(&first_token.text)?.is_some() + && key_exists(case, &first_key)? + && key_name(&second_state, "current")? != first_key, + "first authority backoff blocked or displaced the second pending token" + ); + let (first_id, first_state) = case.enrolled(&first, &first_token).await?; + case.until_pending_consumed(&[&first_token, &second_token]).await?; + let first_authority = field(&first_state, "authority_id")?; + let second_authority = field(&second_state, "authority_id")?; + ensure!( + first_authority != second_authority + && first_state["identity_id"] == first_id + && second_state["identity_id"] == second_id + && case.state(first_authority)?["identity_id"] == first_id + && case.state(second_authority)?["identity_id"] == second_id + && first.management().token(&first_token.id).await?.used_count == 1 + && second.management().token(&second_token.id).await?.used_count == 1, + "independent authorities did not both enroll and retain their keys" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_enroll_revoked_before_response_is_permanent(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + let (id, key) = dropped_enrollment(case, &token).await?; + target.management().revoke(&id).await?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "enroll", "pause": false })) + .await?, + 200, + )?; + let started = Instant::now(); + let attempts = loop { + let requests = target.requests(Some(&token)).await?; + let attempts = count(&requests, "enroll")?; + let rejections = count(&requests, "enroll_identity_revoked")?; + ensure!( + rejections <= 1, + "agent retried enrollment again after receiving identity_revoked" + ); + let conditions = [ + (rejections == 1, "identity_revoked recorded"), + (!case.pending_path().exists(), "pending file deleted"), + (!case.in_progress_path().exists(), "in-progress record deleted"), + (!key_exists(case, &key)?, "enrollment key deleted"), + (!case.has_stored_identity()?, "no identity stored"), + ]; + if conditions.iter().all(|(met, _)| *met) { + break attempts; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not remove the revoked enrollment, in-progress record and key within 20 seconds (unmet: {})", + conditions + .iter() + .filter_map(|(met, name)| (!met).then_some(*name)) + .collect::>() + .join(", ") + ); + tokio::time::sleep(POLL).await; + }; + ensure!(attempts >= 2, "agent did not retry the revoked enrollment"); + for _ in 0..3 { + tokio::time::sleep(Duration::from_secs(2)).await; + case.check_running()?; + ensure!( + count(&target.requests(Some(&token)).await?, "enroll")? == attempts, + "agent sent another enrollment request after identity_revoked" + ); + } + case.stop().await?; + case.start().await?; + tokio::time::sleep(Duration::from_secs(2)).await; + case.check_running()?; + ensure!( + count(&target.requests(Some(&token)).await?, "enroll")? == attempts + && !case.pending_path().exists() + && !case.in_progress_path().exists() + && !key_exists(case, &key)? + && !case.has_stored_identity()?, + "permanently revoked enrollment resumed after restart" + ); + ensure!( + target.management().token(&token.id).await?.used_count == 1 + && target.management().identity_count(&token).await? == 1, + "revoked enrollment created another identity or consumed another use" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_pending_file_deleted_on_permanent_error(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + for reason in ["token_invalid", "token_exhausted", "token_expired", "token_malformed"] { + if reason == "token_expired" && !ctx.mock() { + continue; + } + with_agent(ctx.clone(), None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 2).await?; + let (pending, attempted, identities, used) = match reason { + "token_invalid" => { + let (prefix, _) = token.text.rsplit_once('.').context("token structure")?; + (format!("{prefix}.{}", URL_SAFE_NO_PAD.encode([0x49; 32])), token, 0, 0) + } + "token_exhausted" => { + let once = target + .management() + .create_token(1, Duration::from_secs(3600), None) + .await?; + let key = crate::signer::KeyPair::generate()?; + expect_status(&target.enroll(&once.text, &key, &json!({})).await?, 200)?; + (once.text.clone(), once, 1, 1) + } + "token_expired" => { + let expiring = target + .management() + .create_token(1, Duration::from_secs(2), None) + .await?; + target.advance(4).await?; + (expiring.text.clone(), expiring, 0, 0) + } + "token_malformed" => { + let mut secret = [0u8; 32]; + rand::rng().fill(&mut secret[..]); + ( + format!("dvaet1.not-base64!.{}", URL_SAFE_NO_PAD.encode(secret)), + token, + 0, + 0, + ) + } + _ => anyhow::bail!("unknown permanent error"), + }; + let ingress = if reason == "token_malformed" && case.ctx.mock() { + Some(count(&target.requests(None).await?, "enroll_total")?) + } else { + None + }; + case.write_pending(&pending)?; + case.until_pending_deleted(&target, &pending, &attempted, identities, used) + .await?; + if let Some(before) = ingress { + ensure!( + count(&target.requests(None).await?, "enroll_total")? == before, + "malformed token was sent to the server instead of rejected locally" + ); + } + Ok(()) + }) + }) + .await + .with_context(|| format!("permanent error {reason}"))?; + } + Ok(()) +} + +pub(crate) async fn a_pending_file_kept_on_transient_error(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx.clone(), None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + drop(listener); + let (prefix, secret) = token.text.rsplit_once('.').context("token structure")?; + let (_, bag) = prefix.rsplit_once('.').context("token bag")?; + let mut route: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(bag)?)?; + route["u"] = json!(format!("https://127.0.0.1:{port}/mock")); + let offline = format!( + "dvaet1.{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&route)?), + secret + ); + case.write_pending(&offline)?; + tokio::time::sleep(Duration::from_secs(2)).await; + ensure!( + target.management().identity_count(&token).await? == 0, + "unreachable enrollment created an identity" + ); + ensure!( + case.pending_path().exists(), + "unreachable enrollment deleted the pending file" + ); + case.check_running()?; + Ok(()) + }) + }) + .await?; + if ctx.mock() { + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + let initial = count(&target.requests(Some(&token)).await?, "enroll")?; + for (index, response) in [ + json!({ "endpoint": "enroll", "status": 503 }), + json!({ "endpoint": "enroll", "status": 400, "error": "invalid_request" }), + json!({ "endpoint": "enroll", "status": 429, "retry_after_secs": 3 }), + json!({ "endpoint": "enroll", "status": 302 }), + json!({ "endpoint": "enroll", "status": 503 }), + ] + .into_iter() + .enumerate() + { + target.faults(&json!({ "fail_next_response": response })).await?; + if index == 0 { + case.write_pending(&token.text)?; + } + let start = Instant::now(); + loop { + let attempts = count(&target.requests(Some(&token)).await?, "enroll")?; + ensure!( + attempts <= initial + index as u64 + 1, + "agent retried before the next transient response was configured" + ); + if attempts == initial + index as u64 + 1 + && target.control("faults", &json!({})).await?.body["fail_next_response"].is_null() + { + break; + } + case.check_running()?; + ensure!( + start.elapsed() < WAIT, + "agent did not retry the transient enrollment error" + ); + tokio::time::sleep(POLL).await; + } + let observed_failure = Instant::now(); + ensure!( + case.pending_path().exists(), + "transient HTTP error deleted the pending file" + ); + ensure!( + target.management().identity_count(&token).await? == 0 + && target.management().token(&token.id).await?.used_count == 0, + "transient HTTP error created an identity or consumed a token use" + ); + if response["retry_after_secs"] == 3 { + // Probe near the two-second cap without racing the next retry. + tokio::time::sleep_until((observed_failure + Duration::from_millis(1_700)).into()).await; + ensure!( + count(&target.requests(Some(&token)).await?, "enroll")? == initial + index as u64 + 1, + "agent retried before the capped Retry-After delay" + ); + } + if response["status"] == 302 { + tokio::time::sleep(POLL).await; + ensure!( + count(&target.requests(None).await?, "redirect_hits")? == 0, + "agent followed the injected redirect" + ); + } + case.check_running()?; + } + let _ = case.enrolled(&target, &token).await?; + ensure!( + target.management().token(&token.id).await?.used_count == 1, + "retry consumed an additional token use" + ); + ensure!( + count(&target.requests(Some(&token)).await?, "enroll")? >= initial + 6, + "agent did not survive five transient failures and retry successfully" + ); + Ok(()) + }) + }) + .await?; + } + Ok(()) +} + +/// Removes the ANSI escape sequences that colour the agent's console log, which it writes even when its output is a file. +#[cfg(unix)] +fn without_ansi_escapes(line: &str) -> String { + let mut plain = String::with_capacity(line.len()); + let mut characters = line.chars(); + while let Some(character) = characters.next() { + if character != '\u{1b}' { + plain.push(character); + continue; + } + // A control sequence ends at its first byte in `@`..=`~` (ECMA-48 §5.4). + if characters.next() == Some('[') { + characters + .by_ref() + .find(|final_character| ('@'..='~').contains(final_character)); + } + } + plain +} + +#[cfg(unix)] +fn is_error_level_log_line(line: &str) -> bool { + let line = without_ansi_escapes(line); + if let Ok(entry) = serde_json::from_str::(&line) { + return entry["level"] + .as_str() + .is_some_and(|level| level.eq_ignore_ascii_case("error")); + } + let levels = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]; + line.split_whitespace() + .take(3) + .map(|field| field.trim_matches(&['[', ']', ':'][..])) + .find(|field| levels.contains(field)) + == Some("ERROR") +} + +pub(crate) async fn a_unavailable_key_backend_stops_identity(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Identities + // oracle: intent devolutions-agent/src/identity/INTENT.md §Configuration + #[cfg(unix)] + { + let mut ctx = ctx; + ctx.key_backend = KeyBackend::WindowsSoftwareProvider; + return with_agent(ctx, None, false, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + let token_record = target.management().token(&token.id).await?; + let used_before = token_record.used_count; + ensure!(used_before == 0, "new enrollment token was already used"); + let requests_before = if case.ctx.mock() { + Some(count(&target.requests(None).await?, "enroll_total")?) + } else { + None + }; + case.write_pending(&token.text)?; + let pending = case.pending_path(); + let pending_contents = std::fs::read(&pending)?; + case.start().await?; + let check_unchanged = |case: &mut AgentCase| -> anyhow::Result<()> { + case.check_running()?; + ensure!( + std::fs::read(&pending)? == pending_contents, + "unavailable backend changed or deleted the pending file" + ); + ensure!( + !case.in_progress_path().exists() && !case.rejection_path_for(&token.text).exists(), + "unavailable backend processed the pending token" + ); + ensure!( + case.file_keys_with_prefix()?.is_empty() && !case.has_stored_identity()?, + "unavailable backend created a key or identity" + ); + Ok(()) + }; + let started = Instant::now(); + loop { + check_unchanged(case)?; + let mut logged_error = false; + for name in ["agent-stdout.log", "agent-stderr.log"] { + let contents = std::fs::read_to_string(case.path().join(name))?; + logged_error |= contents.lines().any(is_error_level_log_line); + } + if logged_error { + break; + } + ensure!( + started.elapsed() < WAIT, + "unavailable backend emitted no error-level log line within 20 seconds" + ); + tokio::time::sleep(Duration::from_millis(200)).await; + } + for _ in 0..6 { + tokio::time::sleep(Duration::from_millis(500)).await; + check_unchanged(case)?; + } + if let Some(before) = requests_before { + ensure!( + count(&target.requests(None).await?, "enroll_total")? == before, + "unavailable backend sent an enroll request" + ); + } + ensure!( + target.management().identity_count(&token).await? == 0, + "unavailable backend created an identity" + ); + let record = target.management().token(&token.id).await?; + ensure!( + record.used_count == used_before, + "unavailable backend consumed a token use" + ); + check_unchanged(case)?; + Ok(()) + }) + }) + .await; + } + #[cfg(not(unix))] + { + let _ = ctx; + Err(crate::NotApplicable("Windows software provider is available on Windows").into()) + } +} + +async fn wait_for_injected_attempt( + case: &mut AgentCase, + target: &Target, + counter: &str, + before: u64, +) -> anyhow::Result<()> { + let started = Instant::now(); + loop { + let attempts = count(&target.requests(None).await?, counter)?; + ensure!( + attempts <= before + 1, + "{counter} retried before the injected error was observed" + ); + if attempts == before + 1 && target.control("faults", &json!({})).await?.body["fail_next_response"].is_null() { + return Ok(()); + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "{counter} did not receive the injected error"); + tokio::time::sleep(POLL).await; + } +} + +pub(crate) async fn a_renew_transient_failures_retry_without_state_change(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, initial) = case.enrolled(&target, &token).await?; + let authority = field(&initial, "authority_id")?.to_owned(); + case.until_identity(&target, &id, "working channel for renewal faults", |record| { + record.connected + }) + .await?; + for status in [302, 429, 503] { + let before = target.requests(None).await?; + let record_before = case.identity_record(&target, &id).await?; + let stored_before = case.state(&authority)?; + let old_thumb = current_thumbprint(&record_before)?.to_owned(); + let old_key = key_name(&stored_before, "current")?.to_owned(); + let attempts = count(&before, "renew")?; + let mut fault = json!({ "endpoint": "renew", "status": status }); + if status == 429 { + fault["retry_after_secs"] = json!(3); + } + target.faults(&json!({ "fail_next_response": fault })).await?; + target.management().request_renewal(&id).await?; + wait_for_injected_attempt(case, &target, "renew", attempts).await?; + let failed_at = Instant::now(); + let failed = case.identity_record(&target, &id).await?; + let stored = case.state(&authority)?; + // The injected failure rejects the renewal before the authority processes it (mock README, `fail_next_response`), and only `confirm` promotes a certificate (CONTRACT.agent-identity.md §5.5). + // The request-renewal flag stays set until the identity confirms a newer certificate (§8.2). + // Metadata and the last-seen time are left out, because the agent's channel traffic legitimately updates them: the authority replaces metadata on the `Hello` of the reconnect after the previous `confirm` (§3, §8.1 step 4), and moves the last-seen time on that `Hello` and on the `Ack` of `RenewRequested` (§7.3 step 5; shared intent, "Configuration and metadata"). + ensure!( + failed.certificates == record_before.certificates && failed.renewal_requested, + "injected renew {status} changed the identity's certificates or request-renewal flag" + ); + // The old certificate remains in use after a transient renew error (§8.1, "Failure handling"), a transient error is never terminal (§5.4), and a renew response carries no config (§5.3). + ensure!( + stored["keys"]["current"] == stored_before["keys"]["current"] + && stored["keys"]["previous"] == stored_before["keys"]["previous"] + && stored["config"] == stored_before["config"] + && stored.get("rejected").is_none(), + "injected renew {status} changed the agent's committed identity state" + ); + // The agent records the pending key before sending the renewal and stores a chain only once renew succeeds (agent intent, "Stored identities"), and it retries with the same CSR (§8.1 step 2). + ensure!( + stored["keys"]["pending"]["key_name"].is_string() + && stored["keys"]["pending"]["certificate_chain"].is_null(), + "agent did not keep a pending key without a chain after the injected renew {status}" + ); + if status == 429 { + // The agent waits for `Retry-After` up to its maximum retry delay (CONTRACT.agent-identity.md §5.4), which `backoff_max_secs: 2` caps, so the retry comes after about two seconds. + tokio::time::sleep_until((failed_at + Duration::from_millis(1_500)).into()).await; + ensure!( + count(&target.requests(None).await?, "renew")? == attempts + 1, + "renew retried before the Retry-After delay capped at two seconds" + ); + } + let renewed = case + .until_identity(&target, &id, "renew retry confirmed", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != old_thumb) && record.connected + }) + .await?; + let settled = until_renewal_settled(case, &target, &authority, &id, &old_key).await?; + ensure!( + count(&target.requests(None).await?, "renew")? >= attempts + 2 + && count(&target.requests(None).await?, "redirect_hits")? == count(&before, "redirect_hits")? + && !renewed.renewal_requested + && settled.get("rejected").is_none(), + "renew {status} was followed as a redirect or not retried to completion" + ); + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_check_in_transient_failures_retry_without_state_change(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in + with_agent(ctx, None, false, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_available": false })).await?; + let config_path = case.path().join("agent.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&config_path)?)?; + config["__debug__"]["identity"]["check_in_interval_secs"] = json!(60); + std::fs::write(&config_path, serde_json::to_vec_pretty(&config)?)?; + case.start().await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, initial) = case.enrolled(&target, &token).await?; + let authority = field(&initial, "authority_id")?.to_owned(); + let started = Instant::now(); + loop { + if target + .events(&id) + .await? + .iter() + .any(|event| event["type"] == "check_in_received") + { + break; + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "initial check-in never completed"); + tokio::time::sleep(POLL).await; + } + for status in [302, 429, 503] { + case.stop().await?; + let before = target.requests(None).await?; + let record_before = case.identity_record(&target, &id).await?; + let stored_before = case.state(&authority)?; + let completed_before = target + .events(&id) + .await? + .iter() + .filter(|event| event["type"] == "check_in_received") + .count(); + let hostname = format!("check-in-after-{status}"); + case.set_metadata_hostname(&hostname)?; + let attempts = count(&before, "check_in")?; + let mut fault = json!({ "endpoint": "check-in", "status": status }); + if status == 429 { + fault["retry_after_secs"] = json!(3); + } + target.faults(&json!({ "fail_next_response": fault })).await?; + case.start().await?; + wait_for_injected_attempt(case, &target, "check_in", attempts).await?; + let failed_at = Instant::now(); + let failed = case.identity_record(&target, &id).await?; + let stored = case.state(&authority)?; + ensure!( + failed.certificates == record_before.certificates + && failed.metadata == record_before.metadata + && failed.last_seen_at == record_before.last_seen_at + && stored["config"] == stored_before["config"] + && stored["keys"] == stored_before["keys"] + && stored.get("rejected").is_none(), + "injected check-in {status} changed server or committed identity state" + ); + if status == 429 { + // The agent waits for `Retry-After` up to its maximum retry delay (CONTRACT.agent-identity.md §5.4), which `backoff_max_secs: 2` caps, so the retry comes after about two seconds. + tokio::time::sleep_until((failed_at + Duration::from_millis(1_500)).into()).await; + ensure!( + count(&target.requests(None).await?, "check_in")? == attempts + 1, + "check-in retried before the Retry-After delay capped at two seconds" + ); + } + let retry_started = Instant::now(); + loop { + let current = count(&target.requests(None).await?, "check_in")?; + ensure!( + current <= attempts + 2, + "check-in sent extra requests before the retry was observed" + ); + if current == attempts + 2 + && case.identity_record(&target, &id).await?.metadata_value("hostname") == Some(&*hostname) + { + break; + } + case.check_running()?; + ensure!( + retry_started.elapsed() < Duration::from_secs(10), + "check-in {status} was not retried before the next 60-second periodic send" + ); + tokio::time::sleep(POLL).await; + } + ensure!( + target + .events(&id) + .await? + .iter() + .filter(|event| event["type"] == "check_in_received") + .count() + == completed_before + 1 + && count(&target.requests(None).await?, "redirect_hits")? == count(&before, "redirect_hits")?, + "check-in {status} was followed as a redirect or not retried to completion" + ); + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_token_never_logged(ctx: Context) -> anyhow::Result<()> { + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let _ = case.enrolled(&target, &token).await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_same_token_no_enrollment(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 2).await?; + case.write_pending(&token.text)?; + let (id, old) = case.enrolled(&target, &token).await?; + ensure!(!case.pending_path().exists(), "initial pending file still exists"); + let uses = target.management().token(&token.id).await?.used_count; + let attempts = if case.ctx.mock() { + Some(count(&target.requests(Some(&token)).await?, "enroll")?) + } else { + None + }; + case.write_pending(&token.text)?; + let start = Instant::now(); + while case.pending_path().exists() { + ensure!( + target.management().identity_count(&token).await? == 1, + "duplicate token enrolled another identity" + ); + case.check_running()?; + ensure!(start.elapsed() < WAIT, "same token pending file was not discarded"); + tokio::time::sleep(POLL).await; + } + ensure!( + case.state(field(&old, "authority_id")?)?["identity_id"] == id, + "same token replaced identity" + ); + ensure!( + target.management().token(&token.id).await?.used_count == uses, + "same token used again" + ); + let final_identities = target + .management() + .identities(&IdentityQuery::enrolled_with(&token)) + .await?; + ensure!( + final_identities.total_count == 1 && final_identities.items.first().is_some_and(|row| row.id == id), + "same token created a new identity" + ); + if let Some(attempts) = attempts { + ensure!( + count(&target.requests(Some(&token)).await?, "enroll")? == attempts, + "same token caused another enrollment request" + ); + } + Ok(()) + }) + }) + .await +} + +#[derive(Clone, Copy)] +enum PermanentTokenOutcome { + Exhausted, + Invalid, + Malformed, +} + +async fn same_token_after_permanent_outcome(ctx: Context, outcome: PermanentTokenOutcome) -> anyhow::Result<()> { + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + let (pending, expected_identities, expected_uses) = match outcome { + PermanentTokenOutcome::Exhausted => { + let key = crate::signer::KeyPair::generate()?; + expect_status(&target.enroll(&token.text, &key, &json!({})).await?, 200)?; + (token.text.clone(), 1, 1) + } + PermanentTokenOutcome::Invalid => { + let (prefix, secret) = token.text.rsplit_once('.').context("token structure")?; + let mut bytes = URL_SAFE_NO_PAD.decode(secret)?; + bytes[0] ^= 1; + (format!("{prefix}.{}", URL_SAFE_NO_PAD.encode(bytes)), 0, 0) + } + PermanentTokenOutcome::Malformed => { + let (_, secret) = token.text.rsplit_once('.').context("token structure")?; + (format!("dvaet1.not-base64!.{secret}"), 0, 0) + } + }; + let before = if case.ctx.mock() { + Some(count(&target.requests(None).await?, "enroll_total")?) + } else { + None + }; + case.write_pending(&pending)?; + case.until_pending_deleted(&target, &pending, &token, expected_identities, expected_uses) + .await?; + if let Some(before) = before { + let expected = before + u64::from(!matches!(outcome, PermanentTokenOutcome::Malformed)); + ensure!( + count(&target.requests(None).await?, "enroll_total")? == expected, + "initial permanent outcome sent the wrong number of enrollment requests" + ); + } + assert_same_token_replay_dropped(case, &target, &token, &pending, expected_identities, expected_uses).await + }) + }) + .await +} + +async fn assert_same_token_replay_dropped( + case: &mut AgentCase, + target: &Target, + token: &Token, + pending: &str, + expected_identities: u64, + expected_uses: u64, +) -> anyhow::Result<()> { + let attempts = if case.ctx.mock() { + Some(count(&target.requests(None).await?, "enroll_total")?) + } else { + None + }; + case.write_pending(pending)?; + let started = Instant::now(); + loop { + assert_replay_unchanged(target, token, expected_identities, expected_uses, attempts).await?; + if !case.pending_path_for(pending).exists() && !case.in_progress_path_for(pending).exists() { + break; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not discard the rewritten permanently failed token within 20 seconds" + ); + tokio::time::sleep(POLL).await; + } + let settled = Instant::now(); + while settled.elapsed() < Duration::from_secs(3) { + assert_replay_unchanged(target, token, expected_identities, expected_uses, attempts).await?; + ensure!( + !case.pending_path_for(pending).exists() && !case.in_progress_path_for(pending).exists(), + "rewritten permanently failed token reappeared" + ); + case.check_running()?; + tokio::time::sleep(Duration::from_millis(250)).await; + } + Ok(()) +} + +async fn assert_replay_unchanged( + target: &Target, + token: &Token, + expected_identities: u64, + expected_uses: u64, + attempts: Option, +) -> anyhow::Result<()> { + ensure!( + target.management().identity_count(token).await? == expected_identities, + "rewritten permanently failed token created another identity" + ); + let record = target.management().token(&token.id).await?; + ensure!( + record.used_count == expected_uses, + "rewritten permanently failed token consumed another use" + ); + if let Some(attempts) = attempts { + ensure!( + count(&target.requests(None).await?, "enroll_total")? == attempts, + "rewritten permanently failed token sent an enrollment request" + ); + } + Ok(()) +} + +pub(crate) async fn a_same_token_after_token_exhausted_no_enrollment(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + same_token_after_permanent_outcome(ctx, PermanentTokenOutcome::Exhausted).await +} + +pub(crate) async fn a_same_token_after_token_invalid_no_enrollment(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + same_token_after_permanent_outcome(ctx, PermanentTokenOutcome::Invalid).await +} + +pub(crate) async fn a_same_token_after_token_malformed_no_enrollment(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + same_token_after_permanent_outcome(ctx, PermanentTokenOutcome::Malformed).await +} + +pub(crate) async fn a_same_token_after_identity_revoked_no_enrollment(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + let (id, key) = dropped_enrollment(case, &token).await?; + target.management().revoke(&id).await?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "enroll", "pause": false })) + .await?, + 200, + )?; + case.until_pending_deleted(&target, &token.text, &token, 1, 1).await?; + ensure!( + count(&target.requests(Some(&token)).await?, "enroll_identity_revoked")? == 1 + && !key_exists(case, &key)? + && target.management().identity(&id).await?.status == IdentityStatus::Revoked, + "lost response was not followed by a permanent identity_revoked outcome" + ); + assert_same_token_replay_dropped(case, &target, &token, &token.text, 1, 1).await + }) + }) + .await +} + +pub(crate) async fn a_same_token_rejected_identity_no_enrollment(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Identities + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 3).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + case.until_identity(&target, &id, "connected", |record| record.connected) + .await?; + target.management().revoke(&id).await?; + case.until_rejected(&target, &authority, &id, "identity_revoked") + .await?; + let attempts = if case.ctx.mock() { + Some(count(&target.requests(Some(&token)).await?, "enroll")?) + } else { + None + }; + case.write_pending(&token.text)?; + let start = Instant::now(); + while case.pending_path().exists() { + let _ = target.management().identity_count(&token).await?; + case.check_running()?; + ensure!(start.elapsed() < WAIT, "same token was not discarded after rejection"); + tokio::time::sleep(POLL).await; + } + ensure!( + target.management().token(&token.id).await?.used_count == 1, + "rejected identity consumed another token use" + ); + let final_identities = target + .management() + .identities(&IdentityQuery::enrolled_with(&token)) + .await?; + ensure!( + final_identities.total_count == 1 && final_identities.items.first().is_some_and(|row| row.id == id), + "same token created a new identity after rejection" + ); + ensure!( + case.state(&authority)?["rejected"]["code"] == "identity_revoked", + "rejection was cleared" + ); + if let Some(attempts) = attempts { + ensure!( + count(&target.requests(Some(&token)).await?, "enroll")? == attempts, + "rejected identity sent another enrollment request" + ); + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_different_token_replaces_identity(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let first_token = new_token(case, 1).await?; + case.write_pending(&first_token.text)?; + let (first_id, first_state) = case.enrolled(&target, &first_token).await?; + let authority = field(&first_state, "authority_id")?.to_owned(); + let original_key = key_name(&first_state, "current")?.to_owned(); + let next_token = new_token(case, 1).await?; + case.write_pending(&next_token.text)?; + let (next_id, next_state) = case.enrolled(&target, &next_token).await?; + ensure!(first_id != next_id, "different token did not replace the identity"); + ensure!( + next_state["authority_id"] == authority, + "same mock changed authority on re-enrollment" + ); + ensure!( + next_state["token_sha256"] == token_file_id(&next_token.text) + && next_state["token_sha256"] != first_state["token_sha256"], + "replacement did not store the new token hash" + ); + wait_key_deleted(case, &original_key).await?; + ensure!( + case.state(&authority)?["identity_id"] == next_id, + "stored identity not replaced" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_rejected_identity_replaced(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Identities + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let original_token = new_token(case, 1).await?; + case.write_pending(&original_token.text)?; + let (old_id, old_state) = case.enrolled(&target, &original_token).await?; + let authority = field(&old_state, "authority_id")?.to_owned(); + let old_key = key_name(&old_state, "current")?.to_owned(); + case.until_identity(&target, &old_id, "old connection", |record| record.connected) + .await?; + target.management().revoke(&old_id).await?; + case.until_rejected(&target, &authority, &old_id, "identity_revoked") + .await?; + let replacement = new_token(case, 1).await?; + case.write_pending(&replacement.text)?; + let (new_id, new_state) = case.enrolled(&target, &replacement).await?; + ensure!(new_id != old_id, "new token reused the revoked identity ID"); + ensure!( + new_state["authority_id"] == authority, + "replacement changed authorities" + ); + ensure!( + new_state.get("rejected").is_none(), + "new token retained the old rejection" + ); + ensure!( + new_state["token_sha256"] == token_file_id(&replacement.text) + && new_state["token_sha256"] != old_state["token_sha256"], + "rejected identity replacement did not store the new token hash" + ); + wait_key_deleted(case, &old_key).await?; + case.until_identity(&target, &new_id, "replacement connection", |record| record.connected) + .await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_cli_identity_enroll_writes_pending_file(ctx: Context) -> anyhow::Result<()> { + // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, false, |case| { + Box::pin(async move { + let token = new_token(case, 1).await?; + case.add_token(&token.text); + let output = Command::new(&case.ctx.agent_bin) + .args(["identity", "enroll", &token.text]) + .env("DAGENT_CONFIG_PATH", case.path()) + .kill_on_drop(true) + .output() + .await + .context("run identity enroll CLI")?; + std::fs::write(case.path().join("cli-stdout.log"), &output.stdout)?; + std::fs::write(case.path().join("cli-stderr.log"), &output.stderr)?; + ensure!(output.status.success(), "identity enroll CLI rejected a valid token"); + ensure!( + case.pending_path().exists(), + "identity enroll CLI did not write the pending file" + ); + #[cfg(windows)] + crate::windows::pending_acl_is_protected(&case.pending_path())?; + ensure!( + String::from_utf8_lossy(&output.stdout).contains(&case.pending_path().to_string_lossy().to_string()), + "identity enroll CLI did not print the pending file path" + ); + #[cfg(windows)] + let data = { + let encrypted = std::fs::read(case.pending_path())?; + crate::windows::assert_machine_scope(&encrypted)?; + crate::windows::unprotect_pending(&encrypted)? + }; + #[cfg(unix)] + let data = std::fs::read(case.pending_path())?; + let contents: Value = serde_json::from_slice(&data)?; + ensure!( + contents["version"] == 1 && contents["token"] == token.text, + "CLI pending file malformed" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(case.pending_path())?.permissions().mode() & 0o777; + ensure!(mode == 0o600, "CLI pending file is not mode 0600"); + } + std::fs::remove_file(case.pending_path())?; + let (prefix, secret) = token.text.rsplit_once('.').context("token secret")?; + let bag = prefix.rsplit_once('.').context("token bag")?.1; + let invalid = [ + ("wrong prefix", format!("dvaet2.{bag}.{secret}")), + ("missing secret", format!("dvaet1.{bag}")), + ("invalid bag", format!("dvaet1.!invalid.{secret}")), + ( + "invalid JSON bag", + format!("dvaet1.{}.{secret}", URL_SAFE_NO_PAD.encode("not-json")), + ), + ( + "bag without URL", + format!("dvaet1.{}.{secret}", URL_SAFE_NO_PAD.encode("{}")), + ), + ( + "HTTP URL", + format!( + "dvaet1.{}.{secret}", + URL_SAFE_NO_PAD.encode(r#"{"u":"http://localhost/mock"}"#) + ), + ), + ( + "URL with query", + format!( + "dvaet1.{}.{secret}", + URL_SAFE_NO_PAD.encode(r#"{"u":"https://localhost/mock?q=1"}"#) + ), + ), + ( + "URL with fragment", + format!( + "dvaet1.{}.{secret}", + URL_SAFE_NO_PAD.encode(r##"{"u":"https://localhost/mock#frag"}"##) + ), + ), + ("short secret", format!("dvaet1.{bag}.{}", &secret[..secret.len() - 1])), + ("padded secret", format!("dvaet1.{bag}.{secret}=")), + ( + "overlong token", + format!( + "dvaet1.{}.{secret}", + URL_SAFE_NO_PAD.encode(serde_json::to_vec( + &json!({ "u": format!("https://localhost/{}", "a".repeat(5000)) }) + )?) + ), + ), + ]; + for (reason, candidate) in invalid { + let result = tokio::time::timeout( + Duration::from_secs(10), + Command::new(&case.ctx.agent_bin) + .args(["identity", "enroll", &candidate]) + .env("DAGENT_CONFIG_PATH", case.path()) + .kill_on_drop(true) + .output(), + ) + .await + .with_context(|| format!("identity enroll CLI hung for {reason}"))??; + use std::io::Write as _; + std::fs::OpenOptions::new() + .append(true) + .open(case.path().join("cli-stdout.log"))? + .write_all(&result.stdout)?; + std::fs::OpenOptions::new() + .append(true) + .open(case.path().join("cli-stderr.log"))? + .write_all(&result.stderr)?; + ensure!(!result.status.success(), "identity enroll CLI accepted {reason}"); + ensure!( + !case.pending_path_for(&candidate).exists() && !case.in_progress_path_for(&candidate).exists(), + "{reason} wrote pending enrollment state" + ); + } + Ok(()) + }) + }) + .await +} + +async fn enroll_cli_from_stdin(case: &AgentCase, input: &[u8]) -> anyhow::Result { + let output = tokio::time::timeout(Duration::from_secs(10), async { + let mut child = Command::new(&case.ctx.agent_bin) + .args(["identity", "enroll", "-"]) + .env("DAGENT_CONFIG_PATH", case.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .context("start identity enroll CLI with stdin")?; + let mut stdin = child.stdin.take().context("identity enroll CLI has no stdin")?; + if let Err(error) = stdin.write_all(input).await { + ensure!( + error.kind() == std::io::ErrorKind::BrokenPipe, + "write token to identity enroll CLI stdin: {error}" + ); + } + drop(stdin); + child.wait_with_output().await.context("wait for identity enroll CLI") + }) + .await + .context("identity enroll CLI with stdin timed out")??; + use std::io::Write as _; + for (name, contents) in [("cli-stdout.log", &output.stdout), ("cli-stderr.log", &output.stderr)] { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(case.path().join(name))? + .write_all(contents)?; + } + Ok(output) +} + +fn assert_no_pending_files(case: &AgentCase) -> anyhow::Result<()> { + let dir = case.path().join("identity").join("pending"); + if dir.exists() { + ensure!( + std::fs::read_dir(dir)?.next().is_none(), + "identity enroll CLI created pending or in-progress state for invalid stdin" + ); + } + Ok(()) +} + +pub(crate) async fn a_cli_identity_enroll_stdin(ctx: Context) -> anyhow::Result<()> { + // oracle: intent devolutions-agent/src/identity/INTENT.md §CLI + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, false, |case| { + Box::pin(async move { + let token = new_token(case, 1).await?; + case.add_token(&token.text); + let output = enroll_cli_from_stdin(case, format!(" \t{} \r\n", token.text).as_bytes()).await?; + ensure!( + output.status.success(), + "identity enroll CLI rejected a valid token on stdin" + ); + let pending = case.pending_path(); + ensure!( + pending.exists(), + "identity enroll CLI did not write stdin token's pending file" + ); + ensure!( + String::from_utf8_lossy(&output.stdout).contains(&pending.to_string_lossy().to_string()), + "identity enroll CLI did not print the stdin token's pending path" + ); + #[cfg(windows)] + crate::windows::pending_acl_is_protected(&pending)?; + #[cfg(windows)] + let data = crate::windows::unprotect_pending(&std::fs::read(&pending)?)?; + #[cfg(unix)] + let data = std::fs::read(&pending)?; + let contents: Value = serde_json::from_slice(&data)?; + ensure!( + contents["version"] == 1 && contents["token"] == token.text, + "CLI did not trim stdin to exactly the enrollment token" + ); + std::fs::remove_file(pending)?; + let (prefix, secret) = token.text.rsplit_once('.').context("token structure")?; + let bag = prefix.rsplit_once('.').context("token bag")?.1; + let invalid = format!("dvaet2.{bag}.{secret}"); + for (name, input) in [ + ("invalid token", format!("{invalid}\r\n").into_bytes()), + ("immediate EOF", Vec::new()), + ("whitespace-only line", b" \t \r\n".to_vec()), + ] { + let output = enroll_cli_from_stdin(case, &input).await?; + ensure!(!output.status.success(), "identity enroll CLI accepted {name} on stdin"); + assert_no_pending_files(case)?; + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_renewal_happy_path(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: intent crates/agent-identity/INTENT.md §Schedules + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, Some(2), true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let lifetime = case.ctx.leaf_lifetime(); + // Every certificate of this identity is issued after its token is created. + let issued_after = Instant::now(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, initial) = case.enrolled(&target, &token).await?; + let authority = field(&initial, "authority_id")?.to_owned(); + let old_key = key_name(&initial, "current")?.to_owned(); + let before = case.identity_record(&target, &id).await?; + let old_thumb = current_thumbprint(&before)?.to_owned(); + let name = before.friendly_name.as_str().to_owned(); + let start = Instant::now(); + let renewed = loop { + let record = case.identity_record(&target, &id).await?; + if current_thumbprint(&record).is_ok_and(|thumb| thumb != old_thumb) { + break record; + } + if let Ok(state) = case.state(&authority) { + case.check_key_protection_inner(&authority, &state, false)?; + } + if !key_exists(case, &old_key)? { + let confirmed = current_thumbprint(&case.identity_record(&target, &id).await?) + .is_ok_and(|thumb| thumb != old_thumb); + ensure!( + confirmed, + "old key was deleted before the server confirmed its replacement" + ); + } + case.check_running()?; + ensure!( + start.elapsed() < WAIT, + "agent did not confirm a new current certificate" + ); + tokio::time::sleep(POLL).await; + }; + ensure!(renewed.friendly_name == name, "friendly name changed on renewal"); + ensure!( + renewed.certificates.as_ref().is_some_and(|items| items + .iter() + .any(|cert| cert.thumbprint == old_thumb && cert.status == CertificateStatus::Retired)), + "old certificate not retired" + ); + let final_state = case + .until_state( + &target, + &authority, + &id, + "settled new stored current certificate", + |state| { + key_name(state, "current").is_ok_and(|name| name != old_key) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + }, + ) + .await?; + ensure!( + key_name(&final_state, "current")? != old_key, + "old key remained current" + ); + ensure!( + final_state["keys"].get("pending").is_none() && final_state["keys"].get("previous").is_none(), + "renewal left pending or previous key slots after confirm" + ); + let current_chain = final_state["keys"]["current"]["certificate_chain"] + .as_array() + .context("renewed current certificate chain missing")?; + ensure!( + !current_chain.is_empty() + && crate::signer::thumbprint(current_chain[0].as_str().context("renewed leaf")?)? + == current_thumbprint(&renewed)?, + "stored current certificate does not match the authenticated new leaf" + ); + case.check_key_protection(&authority, &final_state)?; + wait_key_deleted(case, &old_key).await?; + let new_thumb = current_thumbprint(&renewed)?.to_owned(); + if case.ctx.mock() { + ensure!( + target.events(&id).await?.iter().any(|event| { + event["type"] == "cert_status_changed" + && event["cert_thumbprint"] == new_thumb + && event["to"] == "current" + }), + "old key was deleted without a server-side confirm event" + ); + assert_new_auth_precedes_old_close(&target, &id, &old_thumb, &new_thumb).await?; + } + case.stop().await?; + case.until_identity(&target, &id, "offline channel after restart", |record| { + !record.connected + }) + .await?; + // The restarted agent would renew at once, since its certificate is older than `renewal_after_secs`. + case.clear_renewal_after_secs()?; + case.start().await?; + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + // oracle: intent crates/agent-identity/INTENT.md §Schedules + // The test's leaves outlast it, so the persisted certificate is not due for renewal and the restarted agent connects with it. + let resumed = case + .until_identity_within( + &target, + &id, + "reconnected with persisted key", + renewal_aware_wait(issued_after, lifetime), + |record| record.connected && current_thumbprint(record).is_ok_and(|thumb| thumb == new_thumb), + ) + .await?; + ensure!( + resumed.issuer() == renewed.issuer(), + "agent changed issuer while loading the persisted key" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_renewal_lost_response_retried(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let old_key = key_name(&state, "current")?.to_owned(); + let before = case.identity_record(&target, &id).await?; + let old_thumb = current_thumbprint(&before)?.to_owned(); + case.until_identity(&target, &id, "channel before requested renewal", |record| { + record.connected + }) + .await?; + let counters = target.requests(None).await?; + let attempts = count(&counters, "renew")?; + let confirmed_before = count(&counters, "confirm")?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "renew", "pause": true })) + .await?, + 200, + )?; + target.faults(&json!({ "drop_next_response": "renew" })).await?; + target.management().request_renewal(&id).await?; + let started = Instant::now(); + let mut unsettled = "agent did not persist the first pending renewal"; + let (pending_thumb, pending_key) = loop { + let record = case.identity_record(&target, &id).await?; + let state = case.state(&authority)?; + let requests = target.requests(None).await?; + let renew_count = count(&requests, "renew")?; + ensure!( + renew_count <= attempts + 1 + count(&requests, "renew_retry_503")?, + "renewal retry succeeded while the retry barrier was held" + ); + let pending = record + .certificates + .as_ref() + .context("missing certificates")? + .iter() + .find(|cert| cert.status == CertificateStatus::Pending); + if renew_count > attempts + && let Some(pending) = pending + && let Ok(key) = key_name(&state, "pending") + { + // The mock's README does not order the fault's consumption before the renewal it commits. + if target.control("faults", &json!({})).await?.body["drop_next_response"].is_null() { + break (pending.thumbprint.clone(), key.to_owned()); + } + unsettled = "drop-next-renew fault was not consumed"; + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "{unsettled}"); + tokio::time::sleep(POLL).await; + }; + case.stop().await?; + let requests = target.requests(None).await?; + ensure!( + count(&requests, "renew")? == attempts + 1 + count(&requests, "renew_retry_503")?, + "renewal retry succeeded before the agent was stopped" + ); + ensure!( + key_name(&case.state(&authority)?, "pending")? == pending_key, + "pending CSR key was not persisted across stop" + ); + let before_restart = count(&requests, "renew")?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "renew", "pause": false })) + .await?, + 200, + )?; + case.start().await?; + let renewed = case + .until_identity(&target, &id, "retried renewal after restart", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb == pending_thumb) && !record.renewal_requested + }) + .await?; + ensure!( + renewed.certificates.as_ref().is_some_and(|certs| certs.len() == 2) && old_thumb != pending_thumb, + "lost renewal response created multiple new certificates" + ); + ensure!( + count(&target.requests(None).await?, "renew")? > before_restart, + "lost renewal response was not retried after restart" + ); + ensure!( + count(&target.requests(None).await?, "confirm")? > confirmed_before, + "retried renewal was not confirmed with the new key" + ); + let state = case + .until_state( + &target, + &authority, + &id, + "the retried renewal's key as current", + |state| { + key_name(state, "current").is_ok_and(|name| name == pending_key) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + }, + ) + .await?; + wait_key_deleted(case, &old_key).await?; + case.check_key_protection(&authority, &state)?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_renewal_lost_confirm_response_retried(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let old_key = key_name(&state, "current")?.to_owned(); + let old = case + .until_identity(&target, &id, "old-key channel", |record| record.connected) + .await?; + let old_thumb = current_thumbprint(&old)?.to_owned(); + let before = target.requests(None).await?; + let renew_before = count(&before, "renew")?; + let connect_before = count(&before, "connect")?; + let confirm_before = count(&before, "confirm")?; + let sequence_before = before["request_sequence"] + .as_array() + .context("mock request sequence missing")? + .len(); + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": true })) + .await?, + 200, + )?; + target.faults(&json!({ "drop_next_response": "confirm" })).await?; + target.management().request_renewal(&id).await?; + let started = Instant::now(); + let (new_thumb, pending_key) = loop { + let record = case.identity_record(&target, &id).await?; + let stored = case.state(&authority)?; + let requests = target.requests(None).await?; + if count(&requests, "confirm")? > confirm_before + && count(&requests, "confirm_retry_503")? >= 2 + && let Ok(thumb) = current_thumbprint(&record) + && thumb != old_thumb + && let Ok(pending_key) = key_name(&stored, "pending") + { + ensure!( + key_name(&stored, "current")? == old_key && key_exists(case, &old_key)?, + "agent discarded the old key before receiving confirm 204" + ); + break (thumb.to_owned(), pending_key.to_owned()); + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "agent did not retry the dropped confirm"); + tokio::time::sleep(POLL).await; + }; + ensure!( + target.control("faults", &json!({})).await?.body["drop_next_response"].is_null(), + "drop-next-confirm fault was not consumed" + ); + let blocked = target.requests(None).await?; + ensure!( + count(&blocked, "renew")? == renew_before + 1 && count(&blocked, "connect")? == connect_before, + "agent renewed again or opened a stream while confirm outcome was unknown" + ); + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": false })) + .await?, + 200, + )?; + let settled = case + .until_state( + &target, + &authority, + &id, + "confirmed new key after dropped response", + |state| { + key_name(state, "current").is_ok_and(|name| name == pending_key) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + }, + ) + .await?; + ensure!( + key_name(&settled, "current")? == pending_key, + "agent used a different key for its confirm retry" + ); + wait_key_deleted(case, &old_key).await?; + case.until_identity(&target, &id, "new channel after confirm retry", |record| { + record.connected && current_thumbprint(record).is_ok_and(|thumb| thumb == new_thumb) + }) + .await?; + let requests = target.requests(None).await?; + let sequence = requests["request_sequence"] + .as_array() + .context("mock request sequence missing")?; + let completed = sequence + .iter() + .enumerate() + .skip(sequence_before) + .filter_map(|(idx, value)| (value == "confirm_204").then_some(idx)) + .take(2) + .collect::>(); + ensure!( + completed.len() == 2, + "dropped confirm or successful retry was not processed" + ); + ensure!( + completed[1] > completed[0] + 1 + && sequence[completed[0] + 1..completed[1]] + .iter() + .all(|entry| entry == "confirm") + && count(&requests, "renew")? == renew_before + 1, + "agent sent renew or connect between the dropped confirm and its successful retry" + ); + let events = target.events(&id).await?; + ensure!( + events + .iter() + .filter(|event| { + event["type"] == "cert_status_changed" + && event["cert_thumbprint"] == new_thumb + && event["to"] == "current" + }) + .count() + == 1, + "lost confirm response promoted the pending certificate more than once" + ); + assert_new_auth_precedes_old_close(&target, &id, &old_thumb, &new_thumb).await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_confirm_barrier_blocks_channelless_traffic(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_available": false })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, initial) = case.enrolled(&target, &token).await?; + let authority = field(&initial, "authority_id")?.to_owned(); + let original = case.identity_record(&target, &id).await?; + let old_thumb = current_thumbprint(&original)?.to_owned(); + let started = Instant::now(); + loop { + if count(&target.requests(None).await?, "check_in")? > 0 { + break; + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "initial channelless check-in did not run"); + tokio::time::sleep(POLL).await; + } + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": true })) + .await?, + 200, + )?; + target + .faults(&json!({ "fail_next_response": { "endpoint": "confirm", "status": 503 } })) + .await?; + target.management().request_renewal(&id).await?; + let started = Instant::now(); + let blocked = loop { + let requests = target.requests(None).await?; + let state = case.state(&authority)?; + if count(&requests, "confirm_retry_503")? > 0 && state["keys"]["pending"]["key_name"].is_string() { + break requests; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "confirm retry never reached the channelless barrier" + ); + tokio::time::sleep(POLL).await; + }; + let sequence_start = blocked["request_sequence"] + .as_array() + .context("mock request sequence missing")? + .iter() + .position(|entry| entry == "confirm") + .context("confirm barrier observed no confirm attempt")?; + let blocked_certificates = case.identity_record(&target, &id).await?.certificates.clone(); + tokio::time::sleep(Duration::from_secs(11)).await; + case.check_running()?; + let after = target.requests(None).await?; + ensure!( + count(&after, "confirm")? > count(&blocked, "confirm")? + && count(&after, "renew")? == count(&blocked, "renew")? + && count(&after, "check_in")? == count(&blocked, "check_in")? + && count(&after, "connect")? == count(&blocked, "connect")? + && after["request_sequence"] + .as_array() + .context("mock request sequence missing")? + .iter() + .skip(sequence_start) + .all(|entry| entry == "confirm") + && case.identity_record(&target, &id).await?.certificates == blocked_certificates, + "unknown confirm outcome permitted check-in, renew, connect or certificate promotion" + ); + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": false })) + .await?, + 200, + )?; + case.until_identity(&target, &id, "channelless confirm resolved", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != old_thumb) && !record.renewal_requested + }) + .await?; + let settled = until_renewal_settled(case, &target, &authority, &id, key_name(&initial, "current")?).await?; + ensure!( + settled.get("rejected").is_none(), + "an unknown confirm outcome rejected the identity" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_confirm_expired_pending_renews_with_fresh_key(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx.clone(), Some(3), true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target + .faults(&json!({ "channel_available": false, "leaf_lifetime_secs": 30 })) + .await?; + let token = new_token(case, 1).await?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": true })) + .await?, + 200, + )?; + target.faults(&json!({ "drop_next_response": "confirm" })).await?; + let before = target.requests(None).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let old_key = key_name(&state, "current")?.to_owned(); + let old_thumb = crate::signer::thumbprint( + state["keys"]["current"]["certificate_chain"][0] + .as_str() + .context("original enrolled leaf missing")?, + )?; + let started = Instant::now(); + let (first_thumb, first_key) = loop { + let record = case.identity_record(&target, &id).await?; + let stored = case.state(&authority)?; + let requests = target.requests(None).await?; + if count(&requests, "confirm_retry_503")? > count(&before, "confirm_retry_503")? + && let Ok(thumb) = current_thumbprint(&record) + && thumb != old_thumb + && let Ok(pending_key) = key_name(&stored, "pending") + && stored_leaf_thumbprint(&stored, "pending")?.as_deref() == Some(thumb) + { + ensure!( + key_exists(case, &old_key)? && key_name(&stored, "current")? == old_key, + "lost confirm response already discarded the old local current key" + ); + break (thumb.to_owned(), pending_key.to_owned()); + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not retain its key after a dropped confirm" + ); + tokio::time::sleep(POLL).await; + }; + case.stop().await?; + ensure!( + target.control("faults", &json!({})).await?.body["drop_next_response"].is_null() + && case + .identity_record(&target, &id) + .await? + .has_certificate(&first_thumb, CertificateStatus::Current), + "dropped confirm did not promote the pending certificate server-side" + ); + let expiry = case + .identity_record(&target, &id) + .await? + .not_after() + .context("identity has no current certificate")? + .unix_timestamp(); + let now = target.control("time/advance", &json!({ "secs": 0 })).await?.body["now"] + .as_i64() + .context("mock time missing")?; + let secs = expiry - now + 1; + ensure!( + secs > 0 && secs < 60, + "pending key did not approach expiry before retry" + ); + target.advance(secs).await?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": false })) + .await?, + 200, + )?; + let restart = target.requests(None).await?; + case.start().await?; + let confirmed_record = case + .until_identity(&target, &id, "fresh-key renewal after rejected confirm", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != old_thumb && thumb != first_thumb) + && record.has_certificate(&first_thumb, CertificateStatus::Retired) + && !record.renewal_requested + }) + .await?; + let new_thumb = current_thumbprint(&confirmed_record)?.to_owned(); + let settled = case + .until_state(&target, &authority, &id, "settled fresh confirmed key", |state| { + key_name(state, "current").is_ok_and(|name| name != old_key && name != first_key) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + }) + .await?; + ensure!( + key_name(&settled, "current")? != first_key, + "agent reused the expired pending key after confirm rejected it" + ); + wait_key_deleted(case, &first_key).await?; + wait_key_deleted(case, &old_key).await?; + // `renewal_after_secs` starts the next renewal three seconds after this one, so stop at the settled state. + case.stop().await?; + let requests = target.requests(None).await?; + ensure!( + renew_and_confirm_steps_since(&requests, &restart)?.starts_with(&[ + "confirm", + "renew", + "confirm", + "confirm_204" + ]) && renew_keyids_since(&requests, &restart)?.starts_with(&[json!(first_thumb)]), + "agent did not retry confirm, renew signed with the expired pending key, then confirm a fresh CSR" + ); + let events = target.events(&id).await?; + ensure!( + events + .iter() + .any(|event| { event["type"] == "renew_received" && event["cert_thumbprint"] == first_thumb }) + && events.iter().any(|event| { + event["type"] == "cert_status_changed" + && event["cert_thumbprint"] == first_thumb + && event["from"] == "current" + && event["to"] == "retired" + }) + && events.iter().any(|event| { + event["type"] == "cert_status_changed" + && event["cert_thumbprint"] == new_thumb + && event["to"] == "current" + }), + "the failed pending certificate was not retired before confirming the new key" + ); + Ok(()) + }) + }) + .await?; + + with_agent(ctx, Some(3), true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target + .faults(&json!({ "channel_available": false, "leaf_lifetime_secs": 30 })) + .await?; + let token = new_token(case, 1).await?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": true })) + .await?, + 200, + )?; + target + .faults(&json!({ "fail_next_response": { "endpoint": "confirm", "status": 503 } })) + .await?; + let before = target.requests(None).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let old_key = key_name(&state, "current")?.to_owned(); + let old_thumb = crate::signer::thumbprint( + state["keys"]["current"]["certificate_chain"][0] + .as_str() + .context("original enrolled leaf missing")?, + )?; + let started = Instant::now(); + let (first_thumb, first_key) = loop { + let record = case.identity_record(&target, &id).await?; + let stored = case.state(&authority)?; + let requests = target.requests(None).await?; + if count(&requests, "confirm_retry_503")? > count(&before, "confirm_retry_503")? + && let Some(pending) = record + .certificates + .as_ref() + .and_then(|certs| certs.iter().find(|cert| cert.status == CertificateStatus::Pending)) + && let Ok(key) = key_name(&stored, "pending") + && stored_leaf_thumbprint(&stored, "pending")?.as_deref() == Some(pending.thumbprint.as_str()) + { + ensure!( + current_thumbprint(&record)? == old_thumb, + "injected confirm failure promoted the pending certificate" + ); + break (pending.thumbprint.clone(), key.to_owned()); + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not persist a pending key after confirm 503" + ); + tokio::time::sleep(POLL).await; + }; + case.stop().await?; + let pending = case.identity_record(&target, &id).await?; + let certificate = pending + .certificates + .as_ref() + .context("identity certificates missing")? + .iter() + .find(|cert| cert.thumbprint == first_thumb) + .context("pending certificate missing")?; + let expiry = certificate.not_after.unix_timestamp(); + let now = target.control("time/advance", &json!({ "secs": 0 })).await?.body["now"] + .as_i64() + .context("mock time missing")?; + let secs = expiry - now + 1; + ensure!( + secs > 0 && secs < 60, + "pending key did not approach expiry before retry" + ); + target.advance(secs).await?; + target + .faults(&json!({ + "fail_next_response": { + "endpoint": "renew", + "status": 401, + "error": "identity_unknown" + } + })) + .await?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": false })) + .await?, + 200, + )?; + let restart = target.requests(None).await?; + case.start().await?; + let renewed = case + .until_identity( + &target, + &id, + "fallback renewal after pending-key identity_unknown", + |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != old_thumb && thumb != first_thumb) + && record.has_certificate(&first_thumb, CertificateStatus::Retired) + }, + ) + .await?; + let settled = case + .until_state(&target, &authority, &id, "confirmed fallback key", |state| { + key_name(state, "current").is_ok_and(|name| name != old_key && name != first_key) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + && state.get("rejected").is_none() + }) + .await?; + ensure!( + stored_leaf_thumbprint(&settled, "current")?.as_deref() == Some(current_thumbprint(&renewed)?), + "agent did not persist the fallback-renewed certificate" + ); + wait_key_deleted(case, &first_key).await?; + wait_key_deleted(case, &old_key).await?; + // `renewal_after_secs` starts the next renewal three seconds after this one, so stop at the settled state. + case.stop().await?; + let requests = target.requests(None).await?; + ensure!( + renew_and_confirm_steps_since(&requests, &restart)?.starts_with(&[ + "confirm", + "renew", + "renew", + "confirm", + "confirm_204" + ]) && renew_keyids_since(&requests, &restart)?.starts_with(&[json!(first_thumb), json!(old_thumb)]) + && target + .events(&id) + .await? + .iter() + .any(|event| { event["type"] == "renew_received" && event["cert_thumbprint"] == old_thumb }), + "identity_unknown on pending-key renew did not fall back to the old current key" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_confirm_expired_pending_beyond_grace_rejects(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + with_agent(ctx, Some(3), true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target + .faults(&json!({ "channel_available": false, "leaf_lifetime_secs": 30 })) + .await?; + let token = new_token(case, 1).await?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": true })) + .await?, + 200, + )?; + target + .faults(&json!({ "fail_next_response": { "endpoint": "confirm", "status": 503 } })) + .await?; + case.write_pending(&token.text)?; + let (id, initial) = case.enrolled(&target, &token).await?; + let authority = field(&initial, "authority_id")?.to_owned(); + let old_key = key_name(&initial, "current")?.to_owned(); + let started = Instant::now(); + let (pending_thumb, pending_certificate) = loop { + let record = case.identity_record(&target, &id).await?; + let stored = case.state(&authority)?; + if count(&target.requests(None).await?, "confirm_retry_503")? > 0 + && let Some(pending) = record + .certificates + .as_ref() + .and_then(|certs| certs.iter().find(|cert| cert.status == CertificateStatus::Pending)) + && stored["keys"]["pending"]["key_name"].is_string() + { + break (pending.thumbprint.clone(), pending.clone()); + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "pending certificate was not held at confirm"); + tokio::time::sleep(POLL).await; + }; + case.stop().await?; + let before = target.requests(None).await?; + let certificates = case.identity_record(&target, &id).await?.certificates.clone(); + let not_before = pending_certificate.not_before.unix_timestamp(); + let not_after = pending_certificate.not_after.unix_timestamp(); + let now = target.control("time/advance", &json!({ "secs": 0 })).await?.body["now"] + .as_i64() + .context("mock time missing")?; + let secs = not_after + (not_after - not_before) + 1 - now; + ensure!( + secs > 0 && secs < 120, + "pending certificate did not approach its grace limit" + ); + target.advance(secs).await?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": false })) + .await?, + 200, + )?; + let since = time::OffsetDateTime::now_utc(); + case.start().await?; + let rejected = case + .until_rejected(&target, &authority, &id, "certificate_expired") + .await?; + let after = target.requests(None).await?; + let renew_index = usize::try_from(count(&before, "renew")?)?; + ensure!( + rejected["rejected"]["code"] == "certificate_expired" + && key_name(&rejected, "current")? == old_key + && after["renew_attempt_keyids"][renew_index] == pending_thumb + && count(&after, "renew")? == count(&before, "renew")? + 1 + && count(&after, "confirm")? > count(&before, "confirm")? + && case.identity_record(&target, &id).await?.certificates == certificates, + "beyond-grace pending-key renew did not reject without issuing or promoting a certificate" + ); + assert_terminal_stops(case, &target, &authority, Some(&id), "certificate_expired", since).await + }) + }) + .await +} + +pub(crate) async fn a_expired_current_renews_in_grace_or_rejects_beyond(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx.clone(), None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "leaf_lifetime_secs": 30 })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let old_key = key_name(&state, "current")?.to_owned(); + let original = case + .until_identity(&target, &id, "old certificate channel", |record| record.connected) + .await?; + let old_thumb = current_thumbprint(&original)?.to_owned(); + let expiry = original + .not_after() + .context("identity has no current certificate")? + .unix_timestamp(); + let before = target.requests(None).await?; + case.stop().await?; + case.until_identity(&target, &id, "offline expired channel", |record| !record.connected) + .await?; + let now = target.control("time/advance", &json!({ "secs": 0 })).await?.body["now"] + .as_i64() + .context("mock time missing")?; + let secs = expiry - now + 1; + ensure!(secs > 0 && secs < 60, "old certificate is not approaching expiry"); + target.advance(secs).await?; + case.start().await?; + let renewed = case + .until_identity(&target, &id, "within-grace renewal and reconnect", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != old_thumb) && record.connected + }) + .await?; + ensure!( + renewed.issuer() == original.issuer() + && count(&target.requests(None).await?, "renew")? > count(&before, "renew")? + && count(&target.requests(None).await?, "confirm")? > count(&before, "confirm")? + && target + .events(&id) + .await? + .iter() + .any(|event| { event["type"] == "renew_received" && event["cert_thumbprint"] == old_thumb }) + && case.state(&authority)?["rejected"].is_null(), + "expired-within-grace certificate did not sign renew and confirm before reconnecting" + ); + until_renewal_settled(case, &target, &authority, &id, &old_key).await?; + Ok(()) + }) + }) + .await?; + + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "leaf_lifetime_secs": 30 })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + case.until_identity(&target, &id, "old certificate channel", |record| record.connected) + .await?; + let before = count(&target.requests(None).await?, "renew")?; + case.stop().await?; + target.advance(61).await?; + let since = time::OffsetDateTime::now_utc(); + case.start().await?; + let rejected = case + .until_rejected(&target, &authority, &id, "certificate_expired") + .await?; + ensure!( + rejected["rejected"]["code"] == "certificate_expired" + && count(&target.requests(None).await?, "renew")? > before, + "agent did not mark an expired-beyond-grace identity rejected" + ); + assert_terminal_stops(case, &target, &authority, Some(&id), "certificate_expired", since).await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_channel_connected_and_metadata(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-metadata + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + if case.ctx.mock() { + expect_status(&target.control("handshake", &json!({ "pause": true })).await?, 200)?; + } + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, initial_state) = case.enrolled(&target, &token).await?; + let authority = field(&initial_state, "authority_id")?.to_owned(); + let enrolled = case.identity_record(&target, &id).await?; + ensure!( + enrolled.metadata_value("hostname") == Some(&*case.metadata_hostname), + "enrollment ignored the metadata override" + ); + let connected = if case.ctx.mock() { + ensure!( + !enrolled.connected, + "Hello authenticated before enrollment metadata was observed" + ); + let start = Instant::now(); + loop { + if count(&target.requests(None).await?, "paused_hellos")? > 0 { + break; + } + case.check_running()?; + ensure!(start.elapsed() < WAIT, "agent did not reach the held Hello proof"); + tokio::time::sleep(POLL).await; + } + let held = case.identity_record(&target, &id).await?; + ensure!( + !held.connected && held.metadata == enrolled.metadata, + "enrollment metadata changed before Hello authenticated" + ); + target.advance(2).await?; + expect_status(&target.control("handshake", &json!({ "pause": false })).await?, 200)?; + let connected = case + .until_identity(&target, &id, "authenticated Hello", |record| record.connected) + .await?; + ensure!( + count(&target.requests(None).await?, "authenticated_connects")? > 0, + "released Hello did not authenticate" + ); + connected + } else { + case.until_identity(&target, &id, "connected channel", |record| record.connected) + .await? + }; + ensure!( + connected.last_seen_at.is_some(), + "channel did not update the last-seen time" + ); + ensure!( + connected.metadata_value("hostname") == Some(&*case.metadata_hostname), + "initial Hello ignored the metadata override" + ); + for key in REQUIRED_METADATA_KEYS { + ensure!( + connected.metadata_value(key).is_some_and(|value| !value.is_empty()), + "Hello omitted required {key} metadata" + ); + } + ensure!( + case.state(&authority)?.get("rejected").is_none(), + "successful Hello rejected the stored identity" + ); + if case.ctx.mock() { + ensure!( + connected.last_seen_at.is_some() && connected.last_seen_at != enrolled.last_seen_at, + "authenticated Hello did not refresh the last-seen time" + ); + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_make_before_break_on_renewal(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, Some(2), true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let old_key = key_name(&state, "current")?.to_owned(); + let connected = case + .until_identity(&target, &id, "first connection", |record| record.connected) + .await?; + let old_thumb = current_thumbprint(&connected)?.to_owned(); + let start = Instant::now(); + let new_thumb = loop { + let record = case.identity_record(&target, &id).await?; + ensure!(record.connected, "agent disconnected during make-before-break"); + if let Ok(thumb) = current_thumbprint(&record) + && thumb != old_thumb + { + ensure!( + record.certificates.as_ref().is_some_and(|certs| certs + .iter() + .any(|cert| cert.thumbprint == old_thumb && cert.status == CertificateStatus::Retired)), + "old certificate not retired after new one connected" + ); + break thumb.to_owned(); + } + case.check_running()?; + ensure!(start.elapsed() < WAIT, "agent did not renew over a live channel"); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + if case.ctx.mock() { + assert_new_auth_precedes_old_close(&target, &id, &old_thumb, &new_thumb).await?; + } + until_renewal_settled(case, &target, &authority, &id, &old_key).await?; + // `renewal_after_secs` starts the next renewal two seconds after this one, so stop at the settled state. + case.stop().await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_old_key_deleted_after_confirm_without_channel(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, Some(8), true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let old_key = key_name(&state, "current")?.to_owned(); + let enrolled_revision = count(&state["config"], "revision")?; + let first = case + .until_identity(&target, &id, "old-root channel", |record| record.connected) + .await?; + ensure!( + first.metadata_value("hostname") == Some(&*case.metadata_hostname), + "initial Hello ignored the metadata override" + ); + let old_thumb = current_thumbprint(&first)?.to_owned(); + let renewed_hostname = "conformance-renew"; + case.set_metadata_hostname(renewed_hostname)?; + target.faults(&json!({ "channel_available": false })).await?; + case.until_state(&target, &authority, &id, "removed channel URL", |state| { + state["config"]["revision"] + .as_u64() + .is_some_and(|revision| revision > enrolled_revision) + && state["config"].get("agent_channel_url").is_none() + }) + .await?; + case.until_identity(&target, &id, "closed old channel before offline renewal", |record| { + !record.connected + }) + .await?; + target.management().request_renewal(&id).await?; + let start = Instant::now(); + let confirmed = loop { + let record = case.identity_record(&target, &id).await?; + if current_thumbprint(&record).is_ok_and(|thumb| thumb != old_thumb) { + break record; + } + if !key_exists(case, &old_key)? { + let refreshed = case.identity_record(&target, &id).await?; + ensure!( + current_thumbprint(&refreshed).is_ok_and(|thumb| thumb != old_thumb), + "old key was deleted before confirm while the channel was unavailable" + ); + } + case.check_running()?; + ensure!( + start.elapsed() < WAIT, + "agent did not confirm renewal without the channel" + ); + tokio::time::sleep(POLL).await; + }; + let new_thumb = current_thumbprint(&confirmed)?.to_owned(); + ensure!( + confirmed.metadata_value("hostname") == Some(renewed_hostname) + && !confirmed.renewal_requested + && confirmed.certificates.as_ref().is_some_and(|certs| { + certs + .iter() + .any(|cert| cert.thumbprint == old_thumb && cert.status == CertificateStatus::Retired) + }), + "HTTP renewal did not finish through confirm while the channel was unavailable" + ); + let events = target.events(&id).await?; + ensure!( + events.iter().any(|event| { + event["type"] == "cert_status_changed" + && event["cert_thumbprint"] == new_thumb + && event["to"] == "current" + }) && events + .iter() + .all(|event| { event["type"] != "stream_authenticated" || event["cert_thumbprint"] != new_thumb }), + "new key was promoted by the channel instead of confirm" + ); + let requests = target.requests(None).await?; + ensure!( + count(&requests, "renew")? >= 1 && count(&requests, "confirm")? >= 1, + "agent did not send both renew and confirm while the channel was unavailable" + ); + wait_key_deleted(case, &old_key).await?; + let stored = case + .until_state(&target, &authority, &id, "settled confirmed key", |state| { + key_name(state, "current").is_ok_and(|name| name != old_key) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + }) + .await?; + ensure!( + crate::signer::thumbprint( + stored["keys"]["current"]["certificate_chain"][0] + .as_str() + .context("confirmed leaf missing")? + )? == new_thumb, + "agent stored a different key after confirm" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_request_renewal_connected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let lifetime = case.ctx.leaf_lifetime(); + // Every certificate of this identity is issued after its token is created. + let issued_after = Instant::now(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let record = case + .until_identity_within( + &target, + &id, + "connected channel", + renewal_aware_wait(issued_after, lifetime), + |record| record.connected, + ) + .await?; + let original = current_thumbprint(&record)?.to_owned(); + let acknowledgments = if case.ctx.mock() { + Some(count(&target.requests(None).await?, "correlated_acks")?) + } else { + None + }; + target.management().request_renewal(&id).await?; + let renewed = case + .until_identity_within( + &target, + &id, + "requested renewal", + renewal_aware_wait(issued_after, lifetime), + |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != original) && !record.renewal_requested + }, + ) + .await?; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // The agent opens its next stream before closing the old one, and the test's leaves outlast it, so no expiry closes a stream. + ensure!(renewed.connected, "requested renewal broke the channel"); + if let Some(before) = acknowledgments { + let start = Instant::now(); + loop { + if count(&target.requests(None).await?, "correlated_acks")? > before { + break; + } + ensure!( + start.elapsed() < Duration::from_secs(2), + "agent did not Ack RenewRequested" + ); + tokio::time::sleep(POLL).await; + } + } + until_renewal_settled( + case, + &target, + field(&state, "authority_id")?, + &id, + key_name(&state, "current")?, + ) + .await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_request_renewal_while_offline(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §server-certificate-state + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let first = case + .until_identity(&target, &id, "initial connection", |record| record.connected) + .await?; + let original = current_thumbprint(&first)?.to_owned(); + case.stop().await?; + let _ = case + .until_identity(&target, &id, "offline channel", |record| !record.connected) + .await?; + target.management().request_renewal(&id).await?; + ensure!( + case.identity_record(&target, &id).await?.renewal_requested, + "offline renewal flag missing" + ); + case.start().await?; + // The test's leaves outlast it, so only the requested renewal replaces the original certificate, and no expiry closes a stream. + let renewed = case + .until_identity(&target, &id, "renewal after reconnect", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != original) && !record.renewal_requested + }) + .await?; + ensure!(renewed.connected, "agent did not reconnect after offline request"); + until_renewal_settled( + case, + &target, + field(&state, "authority_id")?, + &id, + key_name(&state, "current")?, + ) + .await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_request_renewal_without_channel(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + // oracle: intent crates/device-key/INTENT.md §Operations + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + if case.ctx.mock() { + target.faults(&json!({ "channel_available": false })).await?; + } else { + ensure!(!case.ctx.channel_available, "DVLS target offers an agent channel"); + } + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let initial = case.identity_record(&target, &id).await?; + ensure!( + state["config"].get("agent_channel_url").is_none() && !initial.connected, + "no-channel renewal fixture unexpectedly offers a channel" + ); + let original = current_thumbprint(&initial)?.to_owned(); + let before = if case.ctx.mock() { + Some(target.requests(None).await?) + } else { + None + }; + target.management().request_renewal(&id).await?; + ensure!( + case.identity_record(&target, &id).await?.renewal_requested, + "channelless renewal flag was not persisted" + ); + case.until_identity(&target, &id, "check-in-triggered channelless renewal", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != original) + && !record.renewal_requested + && !record.connected + }) + .await?; + if let Some(before) = before { + let sequence_start = before["request_sequence"] + .as_array() + .context("mock request sequence missing")? + .len(); + let requests = target.requests(None).await?; + ensure!( + count(&requests, "check_in")? > count(&before, "check_in")? + && count(&requests, "renew")? >= 1 + && count(&requests, "confirm")? >= 1 + && count(&requests, "connect")? == 0 + && count(&requests, "channel_attempts")? == 0 + && target.events(&id).await?.iter().any(|event| { + event["type"] == "check_in_received" && event["renewal_requested"] == true + }), + "channelless renewal did not start at check-in and finish with confirm" + ); + assert_check_in_triggers_renewal(&requests, sequence_start)?; + } + let authority = field(&state, "authority_id")?; + let old_key = key_name(&state, "current")?; + until_renewal_settled(case, &target, authority, &id, old_key).await?; + if case.ctx.mock() { + let check_ins = count(&target.requests(None).await?, "check_in")?; + case.stop().await?; + case.start().await?; + // Signing the check-in opens the current key, an operation in the key store later than the old key's deletion. + let started = Instant::now(); + while count(&target.requests(None).await?, "check_in")? == check_ins { + case.check_running()?; + ensure!(started.elapsed() < WAIT, "restarted agent did not check in"); + tokio::time::sleep(POLL).await; + } + assert_no_copy_of_deleted_key(case, old_key)?; + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_reconnect_make_before_break(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, _) = case.enrolled(&target, &token).await?; + case.until_identity(&target, &id, "first channel", |record| record.connected) + .await?; + ensure!( + case.identity_record(&target, &id).await?.metadata_value("hostname") == Some(&*case.metadata_hostname), + "first Hello did not send the current metadata override" + ); + case.set_metadata_hostname("reconnected-host")?; + let before = target.requests(None).await?; + expect_status(&target.control("reconnect", &json!({ "identity_id": id })).await?, 202)?; + let started = Instant::now(); + loop { + let current = case.identity_record(&target, &id).await?; + ensure!(current.connected, "agent disconnected before replacement stream opened"); + let observed = target.requests(None).await?; + if count(&observed, "connect")? > count(&before, "connect")? + && count(&observed, "overlap_open")? > count(&before, "overlap_open")? + && count(&observed, "authenticated_connects")? > count(&before, "authenticated_connects")? + && count(&observed, "active_streams")? == 1 + { + break; + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "agent did not make-before-break on Reconnect"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let events = target.events(&id).await?; + let authenticated = events + .iter() + .filter(|event| event["type"] == "stream_authenticated") + .collect::>(); + ensure!( + authenticated.len() >= 2, + "Reconnect never authenticated a replacement stream" + ); + ensure!( + case.identity_record(&target, &id).await?.metadata_value("hostname") == Some("reconnected-host"), + "replacement Hello reused the previous metadata override" + ); + let old_stream = field(authenticated[0], "stream_id")?; + let new_seq = count(authenticated[1], "seq")?; + ensure!( + events.iter().any(|event| { + event["type"] == "stream_closed" + && event["stream_id"] == old_stream + && event["status"] == "OK" + && event["seq"].as_u64().is_some_and(|seq| seq > new_seq) + }), + "Reconnect closed the old stream before the replacement authenticated" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_revocation_stops_agent(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: intent crates/agent-identity/INTENT.md §Identities + with_agent(ctx, Some(2), true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + case.until_identity(&target, &id, "initial connection", |record| record.connected) + .await?; + ensure!( + state.get("rejected").is_none(), + "identity was rejected before revocation" + ); + let before = time::OffsetDateTime::now_utc(); + target.management().revoke(&id).await?; + let _ = case + .until_rejected(&target, &authority, &id, "identity_revoked") + .await?; + let revoked = case.identity_record(&target, &id).await?; + ensure!(!revoked.connected, "revoked agent remains connected"); + assert_terminal_stops(case, &target, &authority, Some(&id), "identity_revoked", before).await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_identity_unknown_recorded(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: intent crates/agent-identity/INTENT.md §Identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + case.until_identity(&target, &id, "connection before reset", |record| record.connected) + .await?; + ensure!(state.get("rejected").is_none(), "identity was rejected before reset"); + let before = time::OffsetDateTime::now_utc(); + target.reset().await?; + ensure!( + case.find_identity_record(&target, &id).await?.is_none(), + "identity still exists" + ); + let rejected = case + .until_rejected(&target, &authority, &id, "identity_unknown") + .await?; + ensure!(rejected["rejected"]["at"].as_str().is_some(), "rejection time missing"); + assert_terminal_stops(case, &target, &authority, None, "identity_unknown", before).await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_signed_rejection_without_live_stream(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: intent crates/agent-identity/INTENT.md §Identities + with_agent(ctx.clone(), None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_available": false })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + ensure!( + !case.identity_record(&target, &id).await?.connected, + "signed-request rejection fixture unexpectedly opened a channel" + ); + let before = target.requests(None).await?; + ensure!( + count(&before, "renew")? == 0, + "check-in rejection fixture renewed before revocation" + ); + let since = time::OffsetDateTime::now_utc(); + target.management().revoke(&id).await?; + case.until_rejected(&target, &authority, &id, "identity_revoked") + .await?; + let after = target.requests(None).await?; + ensure!( + count(&after, "check_in")? > count(&before, "check_in")? + && count(&after, "renew")? == count(&before, "renew")?, + "agent did not record identity_revoked from check-in before any renewal" + ); + assert_terminal_stops(case, &target, &authority, Some(&id), "identity_revoked", since).await?; + Ok(()) + }) + }) + .await?; + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_broken": true })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + ensure!( + state["config"]["agent_channel_url"] == target.base_url, + "channel-open rejection fixture offers no channel" + ); + ensure!( + !case.identity_record(&target, &id).await?.connected, + "channel-open rejection fixture authenticated prematurely" + ); + let since = time::OffsetDateTime::now_utc(); + target.reset().await?; + let rejected = case + .until_rejected(&target, &authority, &id, "identity_unknown") + .await?; + ensure!( + rejected["rejected"]["code"] == "identity_unknown" + && count(&target.requests(None).await?, "connect")? > 0, + "agent did not record identity_unknown from a channel-open rejection" + ); + assert_terminal_stops(case, &target, &authority, None, "identity_unknown", since).await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_no_channel_when_absent(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in + // oracle: intent crates/agent-identity/INTENT.md §Schedules + let invalid_url_case = ctx.clone(); + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_available": false })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + ensure!( + state["config"].get("agent_channel_url").is_none(), + "agent stored config.agent_channel_url despite its absence from enrollment" + ); + let started = Instant::now(); + let first_check_in = loop { + let requests = target.requests(None).await?; + if count(&requests, "check_in")? > 0 + && target + .events(&id) + .await? + .iter() + .any(|event| event["type"] == "check_in_received") + { + break count(&requests, "check_in")?; + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "first channelless check-in did not complete"); + tokio::time::sleep(POLL).await; + }; + ensure!( + case.identity_record(&target, &id).await?.metadata_value("hostname") == Some(&*case.metadata_hostname), + "first check-in did not send the current metadata override" + ); + let first_completed = target + .events(&id) + .await? + .iter() + .filter(|event| event["type"] == "check_in_received") + .count(); + case.set_metadata_hostname("second-check-in-host")?; + let started = Instant::now(); + loop { + let attempts = count(&target.requests(None).await?, "check_in")?; + let completed = target + .events(&id) + .await? + .iter() + .filter(|event| event["type"] == "check_in_received") + .count(); + ensure!( + attempts <= first_check_in + 1 && completed <= first_completed + 1, + "multiple check-ins occurred before the first post-change send was observed" + ); + if completed == first_completed + 1 { + ensure!( + attempts == first_check_in + 1 + && case.identity_record(&target, &id).await?.metadata["hostname"] == "second-check-in-host", + "the first post-change check-in used stale metadata" + ); + break; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not complete a check-in after the metadata override changed" + ); + tokio::time::sleep(POLL).await; + } + for _ in 0..3 { + tokio::time::sleep(Duration::from_secs(2)).await; + case.check_running()?; + ensure!( + !case.identity_record(&target, &id).await?.connected, + "agent opened an unavailable channel" + ); + let requests = target.requests(None).await?; + ensure!( + count(&requests, "connect")? == 0 && count(&requests, "channel_attempts")? == 0, + "agent attempted a channel despite absent config.agent_channel_url" + ); + } + Ok(()) + }) + }) + .await?; + let insecure_url = invalid_url_case.target.base_url.replacen("https://", "http://", 1); + no_channel_for_malformed_url(invalid_url_case, "HTTP scheme", json!(true), insecure_url).await +} + +async fn no_channel_for_malformed_url( + ctx: Context, + variant: &'static str, + fault: Value, + expected_url: String, +) -> anyhow::Result<()> { + with_agent(ctx, None, false, move |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "malformed_channel_url": fault })).await?; + let config_path = case.path().join("agent.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&config_path)?)?; + config["__debug__"]["identity"]["channel_failure_check_in_after_secs"] = json!(120); + std::fs::write(&config_path, serde_json::to_vec_pretty(&config)?)?; + case.start().await?; + let before = target.requests(None).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + ensure!( + state["config"]["agent_channel_url"] == expected_url, + "mock did not inject config.agent_channel_url with {variant}" + ); + for _ in 0..4 { + tokio::time::sleep(Duration::from_secs(2)).await; + case.check_running()?; + let requests = target.requests(None).await?; + ensure!( + count(&requests, "connect")? == count(&before, "connect")? + && count(&requests, "channel_attempts")? == count(&before, "channel_attempts")?, + "agent attempted a channel with {variant} in config.agent_channel_url" + ); + ensure!( + !case.identity_record(&target, &id).await?.connected, + "agent authenticated a channel with {variant} in config.agent_channel_url" + ); + } + ensure!( + count(&target.requests(None).await?, "check_in")? > count(&before, "check_in")? + && target + .events(&id) + .await? + .iter() + .any(|event| event["type"] == "check_in_received"), + "agent did not check in for {variant} before the delayed broken-channel fallback" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_no_channel_url_with_query(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url + let expected_url = format!("{}?identity_probe=1", ctx.target.base_url); + no_channel_for_malformed_url(ctx, "query", json!("query"), expected_url).await +} + +pub(crate) async fn a_no_channel_url_with_fragment(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url + let expected_url = format!("{}#identity_probe", ctx.target.base_url); + no_channel_for_malformed_url(ctx, "fragment", json!("fragment"), expected_url).await +} + +pub(crate) async fn a_config_update_disables_channel(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: intent crates/agent-identity/INTENT.md §Schedules + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + case.until_identity(&target, &id, "initial channel", |record| record.connected) + .await?; + let before = target.requests(None).await?; + ensure!( + count(&before, "check_in")? == 0, + "agent checked in over HTTP while its channel was working" + ); + let initial_revision = count(&state["config"], "revision")?; + target.faults(&json!({ "channel_available": false })).await?; + let stored = case + .until_state(&target, &authority, &id, "disabled channel config", |state| { + state["config"]["revision"] + .as_u64() + .is_some_and(|revision| revision > initial_revision) + && state["config"].get("agent_channel_url").is_none() + }) + .await?; + let disabled_revision = count(&stored["config"], "revision")?; + case.until_identity(&target, &id, "closed channel after config update", |record| { + !record.connected + }) + .await?; + let events = target.events(&id).await?; + ensure!( + events + .iter() + .any(|event| { event["type"] == "config_update_sent" && event["revision"] == disabled_revision }) + && events + .iter() + .any(|event| event["type"] == "stream_closed" && event["status"] == "OK"), + "channel was not cleanly closed after the higher config revision" + ); + let connects = count(&target.requests(None).await?, "connect")?; + for _ in 0..3 { + tokio::time::sleep(Duration::from_secs(2)).await; + case.check_running()?; + ensure!( + count(&target.requests(None).await?, "connect")? == connects + && !case.identity_record(&target, &id).await?.connected + && count(&case.state(&authority)?["config"], "revision")? == disabled_revision, + "agent retried the channel after its URL was removed" + ); + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_check_in_enables_channel(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_available": false })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let enrolled_revision = count(&state["config"], "revision")?; + ensure!( + state["config"].get("agent_channel_url").is_none() + && !case.identity_record(&target, &id).await?.connected, + "agent was enrolled with a channel URL despite disabled availability" + ); + let start = Instant::now(); + loop { + if count(&target.requests(None).await?, "check_in")? >= 1 { + break; + } + case.check_running()?; + ensure!(start.elapsed() < WAIT, "agent did not check in without a channel URL"); + tokio::time::sleep(POLL).await; + } + let before = count(&target.requests(None).await?, "check_in")?; + target.faults(&json!({ "channel_available": true })).await?; + let stored = case + .until_state( + &target, + &authority, + &id, + "enabled channel config from check-in", + |state| { + state["config"]["revision"] + .as_u64() + .is_some_and(|revision| revision > enrolled_revision) + && state["config"]["agent_channel_url"] == target.base_url + }, + ) + .await?; + ensure!( + count(&target.requests(None).await?, "check_in")? > before, + "agent did not learn the enabled URL through check-in" + ); + case.until_identity(&target, &id, "channel opened from check-in config", |record| { + record.connected + }) + .await?; + ensure!( + stored["config"]["agent_channel_url"] == target.base_url + && target + .events(&id) + .await? + .iter() + .any(|event| event["type"] == "check_in_received"), + "agent did not persist the check-in revision before opening a channel" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_check_in_recovers_broken_channel(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in + // oracle: intent crates/agent-identity/INTENT.md §Schedules + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_broken": true })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let enrolled_revision = count(&state["config"], "revision")?; + ensure!( + state["config"]["agent_channel_url"] == target.base_url, + "broken transport incorrectly changed the effective config" + ); + let start = Instant::now(); + loop { + let requests = target.requests(None).await?; + if count(&requests, "connect")? >= 1 && count(&requests, "check_in")? >= 1 { + break; + } + case.check_running()?; + ensure!( + start.elapsed() < WAIT, + "agent did not check in after its channel remained broken" + ); + tokio::time::sleep(POLL).await; + } + ensure!( + !case.identity_record(&target, &id).await?.connected + && count(&case.state(&authority)?["config"], "revision")? == enrolled_revision, + "broken channel was treated as an effective config change" + ); + let before = count(&target.requests(None).await?, "check_in")?; + target.faults(&json!({ "channel_available": false })).await?; + let stored = case + .until_state( + &target, + &authority, + &id, + "disabled config after broken-channel check-in", + |state| { + state["config"]["revision"] + .as_u64() + .is_some_and(|revision| revision > enrolled_revision) + && state["config"].get("agent_channel_url").is_none() + }, + ) + .await?; + ensure!( + count(&target.requests(None).await?, "check_in")? > before + && stored["config"].get("agent_channel_url").is_none(), + "agent did not learn the disabled URL through check-in" + ); + let connects = count(&target.requests(None).await?, "connect")?; + for _ in 0..3 { + tokio::time::sleep(Duration::from_secs(2)).await; + case.check_running()?; + ensure!( + count(&target.requests(None).await?, "connect")? == connects + && !case.identity_record(&target, &id).await?.connected, + "agent kept reconnecting after check-in removed its channel URL" + ); + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_config_unknown_fields_and_hello_revision(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + case.until_identity(&target, &id, "initial channel", |record| record.connected) + .await?; + let initial_revision = count(&state["config"], "revision")?; + let before = target.events(&id).await?; + let authenticated_before = before + .iter() + .filter(|event| event["type"] == "stream_authenticated") + .collect::>(); + ensure!( + authenticated_before + .iter() + .any(|event| { event["applied_config_revision"] == initial_revision }) + && count(&target.requests(None).await?, "check_in")? == 0, + "working channel did not report its stored revision or checked in over HTTP" + ); + let acks_before = count(&target.requests(None).await?, "correlated_acks")?; + expect_status( + &target + .control( + "config", + &json!({ "fields": { "product_hint": { "mode": "preserved" } } }), + ) + .await?, + 200, + )?; + let stored = case + .until_state(&target, &authority, &id, "unknown pushed config field", |state| { + state["config"]["revision"] + .as_u64() + .is_some_and(|revision| revision > initial_revision) + && state["config"]["product_hint"] == json!({ "mode": "preserved" }) + }) + .await?; + let revision = count(&stored["config"], "revision")?; + let started = Instant::now(); + loop { + if count(&target.requests(None).await?, "correlated_acks")? > acks_before { + break; + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "agent did not acknowledge ConfigUpdate"); + tokio::time::sleep(POLL).await; + } + ensure!( + count(&target.requests(None).await?, "check_in")? == 0 + && target + .events(&id) + .await? + .iter() + .any(|event| { event["type"] == "config_update_sent" && event["revision"] == revision }), + "agent polled HTTP despite a working channel config update" + ); + let acks_before_stale = count(&target.requests(None).await?, "correlated_acks")?; + let injected_reply = target + .control( + "config/stale", + &json!({ "identity_id": id, "revision": initial_revision }), + ) + .await?; + expect_status(&injected_reply, 200)?; + ensure!(injected_reply.body["sent"] == 1, "stale ConfigUpdate was not delivered"); + let started = Instant::now(); + loop { + if count(&target.requests(None).await?, "correlated_acks")? > acks_before_stale { + break; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not acknowledge the stale ConfigUpdate" + ); + tokio::time::sleep(POLL).await; + } + let after_stale = case.state(&authority)?; + ensure!( + after_stale["config"] == stored["config"] + && after_stale["config"].get("mock_stale_marker").is_none() + && target.events(&id).await?.iter().any(|event| { + event["type"] == "stale_config_update_sent" && event["revision"] == initial_revision + }), + "agent applied a decreasing config revision" + ); + expect_status(&target.control("reconnect", &json!({ "identity_id": id })).await?, 202)?; + let started = Instant::now(); + loop { + let events = target.events(&id).await?; + let authenticated = events + .iter() + .filter(|event| event["type"] == "stream_authenticated") + .collect::>(); + if authenticated.len() > authenticated_before.len() { + ensure!( + authenticated + .last() + .is_some_and(|event| event["applied_config_revision"] == revision), + "reconnected Hello did not report the stored config revision" + ); + break; + } + case.check_running()?; + ensure!(started.elapsed() < WAIT, "agent did not reconnect after mock push"); + tokio::time::sleep(POLL).await; + } + ensure!( + count(&target.requests(None).await?, "check_in")? == 0 + && case.state(&authority)?["config"]["product_hint"] == json!({ "mode": "preserved" }), + "agent lost the unknown config field or checked in with a working channel" + ); + // Hold the healthy channel beyond two five-second check-in intervals. + tokio::time::sleep(Duration::from_secs(12)).await; + case.check_running()?; + ensure!( + count(&target.requests(None).await?, "check_in")? == 0 + && case.identity_record(&target, &id).await?.connected, + "agent checked in periodically despite a working channel" + ); + Ok(()) + }) + }) + .await +} + +fn completed_check_ins(events: &[Value]) -> usize { + events + .iter() + .filter(|event| event["type"] == "check_in_received") + .count() +} + +pub(crate) async fn a_config_unsupported_version_keeps_stored_config(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_available": false })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let stored = state["config"].clone(); + let stored_revision = count(&stored, "revision")?; + ensure!( + stored["version"] == 1, + "agent stored an enrollment config whose version is not 1" + ); + let started = Instant::now(); + while completed_check_ins(&target.events(&id).await?) == 0 { + case.check_running()?; + ensure!(started.elapsed() < WAIT, "agent did not check in without a channel"); + tokio::time::sleep(POLL).await; + } + target.faults(&json!({ "config_version": 2 })).await?; + let baseline = completed_check_ins(&target.events(&id).await?); + let started = Instant::now(); + while completed_check_ins(&target.events(&id).await?) < baseline + 2 { + let current = case.state(&authority)?; + ensure!( + current["config"] == stored && current.get("rejected").is_none(), + "agent replaced its stored config with an unsupported config version" + ); + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent stopped checking in after receiving an unsupported config version" + ); + tokio::time::sleep(POLL).await; + } + ensure!( + case.state(&authority)?["config"] == stored, + "agent replaced its stored config with an unsupported config version" + ); + target.faults(&json!({ "config_version": null })).await?; + let updated = case + .until_state(&target, &authority, &id, "the supported config version", |state| { + state["config"]["version"] == 1 + && state["config"]["revision"] + .as_u64() + .is_some_and(|revision| revision > stored_revision) + }) + .await?; + ensure!( + updated.get("rejected").is_none(), + "agent recorded a rejection after an unsupported config version" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_config_unsupported_version_on_enroll_keeps_pending(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target + .faults(&json!({ "channel_available": false, "config_version": 2 })) + .await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let started = Instant::now(); + while count(&target.requests(Some(&token)).await?, "enroll")? < 2 { + ensure!( + case.pending_path().exists() && !case.has_stored_identity()?, + "agent stored an identity or dropped its pending file for an unsupported config version" + ); + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not retry an enrollment answered with an unsupported config version" + ); + tokio::time::sleep(POLL).await; + } + ensure!( + case.pending_path().exists() + && !case.has_stored_identity()? + && !case.rejection_path_for(&token.text).exists(), + "agent treated an unsupported config version on enrollment as final" + ); + target.faults(&json!({ "config_version": null })).await?; + let (_, state) = case.enrolled(&target, &token).await?; + ensure!( + state["config"]["version"] == 1, + "agent stored an enrollment config whose version is not 1" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_multi_authority(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + // oracle: intent crates/agent-identity/INTENT.md §Identities + let first = case.ctx.target.clone(); + let first_lifetime = case.ctx.leaf_lifetime(); + let second = case.ctx.second.clone().context("second authority required")?; + let second_lifetime = second.leaf_lifetime(); + let second = second.target; + // Every certificate of an identity is issued after its token is created. + let first_issued_after = Instant::now(); + let first_token = first + .management() + .create_token(1, Duration::from_secs(3600), None) + .await?; + case.write_pending(&first_token.text)?; + let (first_id, first_state) = case.enrolled(&first, &first_token).await?; + let first_authority = field(&first_state, "authority_id")?.to_owned(); + case.until_identity_within( + &first, + &first_id, + "first connection", + renewal_aware_wait(first_issued_after, first_lifetime), + |record| record.connected, + ) + .await?; + let second_issued_after = Instant::now(); + let second_token = second + .management() + .create_token(1, Duration::from_secs(3600), None) + .await?; + case.write_pending(&second_token.text)?; + let (second_id, second_state) = case.enrolled(&second, &second_token).await?; + let second_authority = field(&second_state, "authority_id")?.to_owned(); + ensure!( + first_authority != second_authority, + "two mocks returned the same authority ID" + ); + case.until_identity_within( + &second, + &second_id, + "second connection", + renewal_aware_wait(second_issued_after, second_lifetime), + |record| record.connected, + ) + .await?; + // Enrolling with the second authority leaves the first identity alone, and the test's leaves outlast it, so no expiry closes its stream. + ensure!( + case.identity_record(&first, &first_id).await?.connected, + "first authority was disconnected" + ); + ensure!( + case.identity_path(&first_authority).exists(), + "first identity not stored" + ); + ensure!( + case.identity_path(&second_authority).exists(), + "second identity not stored" + ); + ensure!( + first_state["token_sha256"] == token_file_id(&first_token.text) + && second_state["token_sha256"] == token_file_id(&second_token.text), + "multi-authority token hashes are incorrect" + ); + case.stop().await?; + case.until_identity(&first, &first_id, "first authority offline", |record| !record.connected) + .await?; + case.until_identity(&second, &second_id, "second authority offline", |record| { + !record.connected + }) + .await?; + case.start().await?; + case.until_identity_within( + &first, + &first_id, + "first authority reconnected", + renewal_aware_wait(first_issued_after, first_lifetime), + |record| record.connected, + ) + .await?; + case.until_identity_within( + &second, + &second_id, + "second authority reconnected", + renewal_aware_wait(second_issued_after, second_lifetime), + |record| record.connected, + ) + .await?; + ensure!( + case.state(&first_authority)?["token_sha256"] == first_state["token_sha256"] + && case.state(&second_authority)?["token_sha256"] == second_state["token_sha256"], + "multi-authority identities changed across restart" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_key_non_exportable(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/device-key/INTENT.md §Private keys + // oracle: intent crates/device-key/INTENT.md §Access + #[cfg(windows)] + { + ensure!( + ctx.key_backend == KeyBackend::WindowsSoftwareProvider, + "Windows non-exportability requires Windows software provider backend" + ); + with_agent(ctx.clone(), None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + crate::windows::assert_non_exportable(key_name(&state, "current")?)?; + crate::windows::assert_machine_key_acl(key_name(&state, "current")?)?; + case.check_key_protection(field(&state, "authority_id")?, &state)?; + if case.ctx.channel_available { + case.until_identity(&target, &id, "signed channel Hello", |record| record.connected) + .await?; + } else { + case.set_metadata_hostname("acl-signing-probe")?; + case.until_identity(&target, &id, "signed check-in", |record| { + record.metadata_value("hostname") == Some("acl-signing-probe") + }) + .await?; + } + Ok(()) + }) + }) + .await?; + if !crate::windows::running_as_system()? { + if !crate::windows::running_as_elevated_administrator()? { + return Err(crate::Incomplete("requires an elevated administrator for the SYSTEM fixture").into()); + } + return system_fixture::run(ctx).await; + } + return with_agent(ctx, None, false, |case| { + Box::pin(async move { + case.set_acl_grant_current_user(false)?; + case.start().await?; + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let key = key_name(&state, "current")?; + crate::windows::assert_non_exportable(key)?; + crate::windows::assert_machine_key_acl(key)?; + case.check_key_protection(field(&state, "authority_id")?, &state)?; + if case.ctx.channel_available { + case.until_identity(&target, &id, "SYSTEM-only key signed channel Hello", |record| { + record.connected + }) + .await?; + } else { + case.set_metadata_hostname("system-acl-signing-probe")?; + case.until_identity(&target, &id, "SYSTEM-only key signed check-in", |record| { + record.metadata_value("hostname") == Some("system-acl-signing-probe") + }) + .await?; + } + Ok(()) + }) + }) + .await; + } + #[cfg(not(windows))] + { + let _ = ctx; + anyhow::bail!("non-exportable key test is Windows-only") + } +} + +pub(crate) async fn a_file_backend_permissions(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/device-key/INTENT.md §Access + // oracle: intent crates/device-key/INTENT.md §File backend + #[cfg(unix)] + { + ensure!( + ctx.key_backend == KeyBackend::File, + "Unix permission test requires file backend" + ); + return with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (_, state) = case.enrolled(&target, &token).await?; + let name = key_name(&state, "current")?; + let key_file = case.key_file_path(name); + assert_file_key_access(&key_file)?; + Ok(()) + }) + }) + .await; + } + #[cfg(not(unix))] + { + let _ = ctx; + anyhow::bail!("file permission test is Unix-only") + } +} + +pub(crate) async fn a_rotation_migrates_connected_agent(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-renewal + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + ensure!( + ctx.mock() || ctx.disposable_dvls_target, + "DVLS rotation requires --disposable-dvls-target" + ); + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, initial_state) = case.enrolled(&target, &token).await?; + let authority = field(&initial_state, "authority_id")?.to_owned(); + let old = case + .until_identity(&target, &id, "old-root connection", |record| record.connected) + .await?; + let old_root = old.issuer().context("identity has no current certificate")?.to_owned(); + let started = if case.ctx.mock() { + target.management().rotate(RotationDeadline::Default).await? + } else { + crate::protocol::wait_rotation_idle(&target, case.ctx.dvls_rotation_window_secs).await?; + let deadline = time::OffsetDateTime::now_utc() + + time::Duration::seconds(i64::try_from(case.ctx.dvls_rotation_window_secs)?); + target.management().rotate(RotationDeadline::At(deadline)).await? + }; + let initial = started.active_identities_on_old_root; + ensure!(initial >= 1, "the agent's old-root identity was not counted"); + // The test's leaves outlast it, so only the rotation renews the old-root certificate, and no expiry closes a stream. + let new = case + .until_identity(&target, &id, "connected new-root certificate", |record| { + record.issuer() != Some(&*old_root) + && record.connected + && record + .certificates + .as_ref() + .is_some_and(|items| items.iter().any(|cert| cert.status == CertificateStatus::Retired)) + }) + .await?; + ensure!(new.issuer() != Some(&*old_root), "agent did not migrate roots"); + let new_root = new.issuer().context("identity has no current certificate")?.to_owned(); + let stored = case + .until_state(&target, &authority, &id, "stored new-root chain", |state| { + state["keys"]["current"]["certificate_chain"] + .as_array() + .and_then(|chain| chain.last()) + .and_then(Value::as_str) + .and_then(|root| crate::signer::thumbprint(root).ok()) + .is_some_and(|issuer| issuer == new_root) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + }) + .await?; + ensure!( + stored["keys"]["current"]["certificate_chain"] + .as_array() + .is_some_and(|chain| chain.len() >= 2), + "migrated agent did not persist the new-root chain" + ); + case.stop().await?; + case.until_identity(&target, &id, "offline new-root channel", |record| !record.connected) + .await?; + let authenticated_before = if case.ctx.mock() { + Some(count(&target.requests(None).await?, "authenticated_connects")?) + } else { + None + }; + case.start().await?; + case.until_identity(&target, &id, "new-root channel after restart", |record| { + record.connected && record.issuer() == Some(&*new_root) + }) + .await?; + if let Some(before) = authenticated_before { + ensure!( + count(&target.requests(None).await?, "authenticated_connects")? > before, + "restarted agent did not authenticate its new-root channel" + ); + } + let rotation = target.management().rotation().await?; + if rotation.rotating() { + ensure!( + rotation.active_identities_on_old_root < initial, + "agent migration did not reduce the old-root count" + ); + } + if !case.ctx.mock() { + crate::protocol::wait_rotation_idle(&target, case.ctx.dvls_rotation_window_secs).await?; + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_rotation_migrates_through_check_in(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_available": false })).await?; + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + let (id, initial_state) = case.enrolled(&target, &token).await?; + let authority = field(&initial_state, "authority_id")?.to_owned(); + let before = case.identity_record(&target, &id).await?; + ensure!(!before.connected, "channel unavailable but agent connected"); + let old_root = before + .issuer() + .context("identity has no current certificate")? + .to_owned(); + let before_requests = target.requests(None).await?; + let confirms_before = count(&before_requests, "confirm")?; + let blocked_before = count(&before_requests, "confirm_retry_503")?; + let check_ins_before = count(&before_requests, "check_in")?; + let rotation_sequence_start = before_requests["request_sequence"] + .as_array() + .context("mock request sequence missing")? + .len(); + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": true })) + .await?, + 200, + )?; + target + .faults(&json!({ "fail_next_response": { "endpoint": "confirm", "status": 503 } })) + .await?; + target.management().rotate(RotationDeadline::Default).await?; + let new = case + .until_identity(&target, &id, "check-in-triggered new-root renewal", |record| { + record.certificates.as_ref().is_some_and(|certs| { + certs + .iter() + .any(|cert| cert.issuer != old_root && cert.status == CertificateStatus::Pending) + }) + }) + .await?; + ensure!( + count(&target.requests(None).await?, "check_in")? > check_ins_before + && target + .events(&id) + .await? + .iter() + .any(|event| { event["type"] == "check_in_received" && event["renewal_requested"] == true }), + "channelless rotation did not request renewal through check-in" + ); + ensure!(!new.connected, "scheduled renewal unexpectedly required a channel"); + let pending = new + .certificates + .as_ref() + .context("scheduled certificates missing")? + .iter() + .find(|cert| cert.issuer != old_root && cert.status == CertificateStatus::Pending) + .context("new-root pending certificate missing")?; + let new_root = pending.issuer.clone(); + let pending_thumb = pending.thumbprint.clone(); + let stored = case + .until_state(&target, &authority, &id, "stored pending new-root chain", |state| { + state["keys"]["pending"]["certificate_chain"] + .as_array() + .and_then(|chain| chain.last()) + .and_then(Value::as_str) + .and_then(|root| crate::signer::thumbprint(root).ok()) + .is_some_and(|issuer| issuer == new_root) + }) + .await?; + let pending_key = key_name(&stored, "pending")?.to_owned(); + let started = Instant::now(); + loop { + let requests = target.requests(None).await?; + if count(&requests, "confirm")? > confirms_before + && count(&requests, "confirm_retry_503")? > blocked_before + && target.control("faults", &json!({})).await?.body["fail_next_response"].is_null() + { + break; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not retry confirm after new-root renewal" + ); + tokio::time::sleep(POLL).await; + } + case.stop().await?; + ensure!( + key_name(&case.state(&authority)?, "pending")? == pending_key + && case + .identity_record(&target, &id) + .await? + .has_certificate(&pending_thumb, CertificateStatus::Pending), + "new-root pending key was not persisted across restart" + ); + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": false })) + .await?, + 200, + )?; + let before_restart = target.requests(None).await?; + let restart_sequence_start = before_restart["request_sequence"] + .as_array() + .context("mock request sequence missing")? + .len(); + let confirms = count(&before_restart, "confirm")?; + case.start().await?; + case.until_identity(&target, &id, "confirmed new-root key after restart", |record| { + record.issuer() == Some(&*new_root) + && current_thumbprint(record).is_ok_and(|thumb| thumb == pending_thumb) + }) + .await?; + let requests = target.requests(None).await?; + ensure!( + count(&requests, "confirm")? > confirms + && requests["request_sequence"] + .as_array() + .and_then(|sequence| sequence.get(restart_sequence_start)) + .is_some_and(|entry| entry == "confirm"), + "agent did not retry confirm first with its persisted pending key" + ); + assert_check_in_triggers_renewal(&requests, rotation_sequence_start)?; + case.until_state( + &target, + &authority, + &id, + "the confirmed new-root chain after restart", + |state| { + key_name(state, "current").is_ok_and(|name| name == pending_key) + && state["keys"]["current"]["certificate_chain"] + .as_array() + .and_then(|chain| chain.last()) + .and_then(Value::as_str) + .is_some_and(|root| crate::signer::thumbprint(root).is_ok_and(|issuer| issuer == new_root)) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + }, + ) + .await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_rotation_migrates_on_schedule(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + // oracle: intent crates/agent-identity/INTENT.md §Schedules + with_agent(ctx, Some(50), false, |case| { + Box::pin(async move { + let target = case.ctx.target.clone(); + target.faults(&json!({ "channel_available": false })).await?; + let config_path = case.path().join("agent.json"); + let mut config: Value = serde_json::from_slice(&std::fs::read(&config_path)?)?; + // Keep the next check-in later than scheduled renewal so it cannot deliver the rotation request. + config["__debug__"]["identity"]["check_in_interval_secs"] = json!(120); + std::fs::write(&config_path, serde_json::to_vec_pretty(&config)?)?; + + let token = new_token(case, 1).await?; + case.write_pending(&token.text)?; + case.start().await?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + ensure!( + state["config"].get("agent_channel_url").is_none(), + "scheduled-rotation fixture unexpectedly offers a channel" + ); + let old = case.identity_record(&target, &id).await?; + ensure!(!old.connected, "scheduled-rotation fixture connected"); + let old_root = old.issuer().context("identity has no current certificate")?.to_owned(); + let old_thumb = current_thumbprint(&old)?.to_owned(); + let started = Instant::now(); + loop { + if count(&target.requests(None).await?, "check_in")? > 0 { + break; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not check in before scheduled rotation" + ); + tokio::time::sleep(POLL).await; + } + let before = target.requests(None).await?; + ensure!( + count(&before, "renew")? == 0 && count(&before, "confirm")? == 0, + "agent renewed before rotation began" + ); + target.management().rotate(RotationDeadline::Default).await?; + let wait = Instant::now(); + let migrated = loop { + let record = case.identity_record(&target, &id).await?; + if record.issuer() != Some(&*old_root) && !record.renewal_requested && !record.connected { + break record; + } + case.check_running()?; + ensure!( + wait.elapsed() < Duration::from_secs(70), + "agent did not migrate roots on schedule before the next check-in" + ); + tokio::time::sleep(POLL).await; + }; + let new_root = migrated + .issuer() + .context("identity has no current certificate")? + .to_owned(); + let after = target.requests(None).await?; + ensure!( + count(&after, "check_in")? == count(&before, "check_in")? + && count(&after, "renew")? > count(&before, "renew")? + && count(&after, "confirm")? > count(&before, "confirm")? + && count(&after, "channel_attempts")? == 0 + && target + .events(&id) + .await? + .iter() + .any(|event| { event["type"] == "renew_received" && event["cert_thumbprint"] == old_thumb }), + "agent did not renew under the new root on schedule before another check-in" + ); + case.until_state(&target, &authority, &id, "stored scheduled new-root chain", |state| { + state["keys"]["current"]["certificate_chain"] + .as_array() + .and_then(|chain| chain.last()) + .and_then(Value::as_str) + .and_then(|root| crate::signer::thumbprint(root).ok()) + .is_some_and(|issuer| issuer == new_root) + && state["keys"].get("pending").is_none() + && state["keys"].get("previous").is_none() + }) + .await?; + Ok(()) + }) + }) + .await +} + +#[cfg(test)] +mod tests { + use p256::elliptic_curve::Generate as _; + use p256::pkcs8::EncodePrivateKey as _; + + use super::*; + + #[test] + fn key_names_require_this_runs_prefix_and_a_canonical_uuid() -> anyhow::Result<()> { + // oracle: intent crates/device-key/INTENT.md §Key name + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + // oracle: intent devolutions-agent/src/identity/INTENT.md §Configuration + let prefix = format!("conformance-{}", uuid::Uuid::new_v4().simple()); + let key_uuid = uuid::Uuid::new_v4(); + let name = format!("{prefix}_{key_uuid}"); + validate_key_name(&prefix, &name)?; + validate_key_name( + "devolutions-agent-identity", + &format!("devolutions-agent-identity_{key_uuid}"), + )?; + ensure!( + validate_key_name(&prefix, &format!("{prefix}_{}", key_uuid.to_string().to_uppercase())).is_err(), + "uppercase UUID was accepted" + ); + ensure!( + validate_key_name(&prefix, &format!("devolutions-agent-identity_{key_uuid}")).is_err(), + "a key without the run prefix was accepted" + ); + ensure!( + validate_key_name(&prefix, &format!("{prefix}_{key_uuid}.p8")).is_err(), + "a key name with a file extension was accepted" + ); + ensure!( + validate_key_name(&prefix, &format!("{prefix}{key_uuid}")).is_err(), + "a key name without the underscore separator was accepted" + ); + ensure!( + validate_key_name(&prefix, &format!("{prefix}-other_{key_uuid}")).is_err() + && !key_belongs_to_run(&format!("{prefix}-other_{key_uuid}"), &prefix), + "a longer prefix was treated as this run's prefix" + ); + for invalid in ["", "Conformance", "conformance_", "conformance.dot"] { + ensure!( + validate_key_name(invalid, &format!("{invalid}_{key_uuid}")).is_err(), + "invalid device-key prefix {invalid:?} was accepted" + ); + } + let too_long = "a".repeat(65); + let max_length = "a".repeat(64); + validate_key_name(&max_length, &format!("{max_length}_{key_uuid}"))?; + ensure!( + validate_key_name(&too_long, &format!("{too_long}_{key_uuid}")).is_err(), + "a device-key prefix longer than 64 characters was accepted" + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn error_log_level_is_distinct_from_message_text() { + // oracle: mutant a log line counts as an error when `ERROR` appears anywhere in it, including its message + assert!(is_error_level_log_line( + "2026-10-19T00:00:00Z ERROR devolutions_agent: unavailable backend" + )); + assert!(is_error_level_log_line( + r#"{"level":"error","fields":{"message":"unavailable backend"}}"# + )); + assert!(!is_error_level_log_line( + "2026-10-19T00:00:00Z INFO devolutions_agent: ERROR was returned" + )); + assert!(is_error_level_log_line( + "\u{1b}[2m2026-10-19T00:00:00Z\u{1b}[0m \u{1b}[31mERROR\u{1b}[0m \u{1b}[2mdevolutions_agent\u{1b}[0m\u{1b}[2m:\u{1b}[0m unavailable backend" + )); + assert!(!is_error_level_log_line( + "\u{1b}[2m2026-10-19T00:00:00Z\u{1b}[0m \u{1b}[32m INFO\u{1b}[0m \u{1b}[2mdevolutions_agent\u{1b}[0m\u{1b}[2m:\u{1b}[0m ERROR was returned" + )); + } + + #[tokio::test] + async fn orphan_audit_accepts_both_records_and_cleanup_is_prefix_scoped() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + // oracle: intent crates/device-key/INTENT.md §Operations + // oracle: intent devolutions-agent/src/identity/INTENT.md §Configuration + let mut case = file_backend_case().await?; + let prefix = case.ctx.key_name_prefix.clone(); + let fixture_token = "dvaet1.fixturebag.fixturesecret"; + let fixture_hash = token_file_id(fixture_token); + case.add_token(fixture_token); + let pending_name = case + .pending_path() + .file_name() + .context("pending filename")? + .to_string_lossy() + .to_string(); + ensure!( + pending_name.starts_with(&token_file_id(fixture_token)) && token_file_id(fixture_token).len() == 64, + "pending filename is not keyed by the full token SHA-256" + ); + let config: Value = serde_json::from_slice(&std::fs::read(case.path().join("agent.json"))?)?; + ensure!( + config["__debug__"]["identity"]["key_name_prefix"] == prefix, + "agent.json omitted the run prefix" + ); + ensure!( + config["__debug__"]["identity"]["metadata_override_path"] + == case.metadata_override_path().to_string_lossy().as_ref(), + "agent.json omitted the metadata override path" + ); + case.set_metadata_hostname("refreshed-host")?; + ensure!( + serde_json::from_slice::(&std::fs::read(case.metadata_override_path())?)?["hostname"] + == "refreshed-host", + "metadata override was not refreshed" + ); + let name = format!("{prefix}_{}", uuid::Uuid::new_v4()); + let foreign = format!("devolutions-agent-identity_{}", uuid::Uuid::new_v4()); + let overlapping = format!("{prefix}-other_{}", uuid::Uuid::new_v4()); + std::fs::create_dir_all(case.key_file_path(&name).parent().context("key directory")?)?; + let write_run_key = |path: &Path| -> anyhow::Result<()> { + #[cfg(unix)] + { + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt as _; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path)?; + file.write_all(b"run-owned-key")?; + } + #[cfg(not(unix))] + std::fs::write(path, b"run-owned-key")?; + Ok(()) + }; + write_run_key(&case.key_file_path(&name))?; + std::fs::write(case.key_file_path(&foreign), b"foreign-key")?; + std::fs::write(case.key_file_path(&overlapping), b"other-run-key")?; + ensure!( + case.audit_identity_tree().is_err(), + "an unexpected key file in the identity tree was accepted" + ); + ensure!( + case.audit_orphan_keys().is_err(), + "an unrecorded file-backed key was accepted" + ); + std::fs::create_dir_all(case.in_progress_path().parent().context("pending directory")?)?; + std::fs::write( + case.in_progress_path(), + serde_json::to_vec(&json!({ "version": 1, "token_sha256": fixture_hash, "key_name": name }))?, + )?; + case.audit_orphan_keys()?; + std::fs::remove_file(case.key_file_path(&foreign))?; + std::fs::remove_file(case.key_file_path(&overlapping))?; + case.audit_identity_tree()?; + case.ctx.key_backend = KeyBackend::WindowsSoftwareProvider; + ensure!( + case.audit_identity_tree().is_err(), + "Windows software provider backend accepted a file-backed key" + ); + case.ctx.key_backend = KeyBackend::File; + write_run_key(&case.key_file_path(&foreign))?; + write_run_key(&case.key_file_path(&overlapping))?; + std::fs::remove_file(case.in_progress_path())?; + let identity = case.identity_path(&uuid::Uuid::new_v4().to_string()); + std::fs::create_dir_all(identity.parent().context("authority directory")?)?; + std::fs::write( + &identity, + serde_json::to_vec(&json!({ "keys": { "current": { "key_name": name } } }))?, + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&identity, std::fs::Permissions::from_mode(0o600))?; + } + let stored: Value = serde_json::from_slice(&std::fs::read(&identity)?)?; + let authority = identity + .parent() + .and_then(|parent| parent.file_name()) + .and_then(|name| name.to_str()) + .context("fixture authority")?; + case.check_key_protection(authority, &stored)?; + std::fs::remove_file(case.key_file_path(&name))?; + ensure!( + case.check_key_protection(authority, &stored).is_err(), + "settled identity accepted a missing current key" + ); + write_run_key(&case.key_file_path(&name))?; + case.audit_orphan_keys()?; + std::fs::remove_file(&identity)?; + std::fs::write( + case.in_progress_path(), + serde_json::to_vec(&json!({ "version": 1, "token_sha256": fixture_hash, "key_name": name }))?, + )?; + case.cleanup_run_keys()?; + ensure!(!case.key_file_path(&name).exists(), "run-owned key survived cleanup"); + ensure!( + case.key_file_path(&foreign).exists(), + "cleanup deleted a key outside the run prefix" + ); + ensure!( + case.key_file_path(&overlapping).exists(), + "cleanup deleted a key with a longer prefix" + ); + std::fs::remove_file(case.key_file_path(&foreign))?; + std::fs::remove_file(case.key_file_path(&overlapping))?; + case.audit_identity_tree()?; + let unknown = case.path().join("identity").join("notes").join("unknown.bin"); + std::fs::create_dir_all(unknown.parent().context("unknown entry parent")?)?; + std::fs::write(&unknown, b"non-key data")?; + case.audit_identity_tree()?; + let unexpected_key = unknown.with_file_name("other.p8"); + std::fs::write(&unexpected_key, b"not even a PKCS#8 key")?; + ensure!( + case.audit_identity_tree().is_err(), + "nested unrecorded .p8 file was accepted" + ); + std::fs::remove_file(unexpected_key)?; + let pkcs8 = p256::SecretKey::generate_from_rng(&mut rand::rng()).to_pkcs8_der()?; + std::fs::write(&unknown, pkcs8.as_bytes())?; + ensure!( + case.audit_identity_tree().is_err(), + "unrecorded PKCS#8 DER was accepted" + ); + std::fs::write( + &unknown, + format!( + "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", + base64::engine::general_purpose::STANDARD.encode(pkcs8.as_bytes()) + ), + )?; + ensure!( + case.audit_identity_tree().is_err(), + "unrecorded PKCS#8 PEM was accepted" + ); + let encrypted_pkcs8 = base64::engine::general_purpose::STANDARD.decode( + "MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBBDtKK//RHOS2x0YBSFGGtPAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQKvLa4jZ8A4WkKva7UtVXYQSBkEduwKFQc7aMPm4ZYJQGbaBB72cZ+GZ3lsNoaJaVZcwLNBLXwUgfQEnx6o5L7LAa8/L2G+6X6uJxnxyQzH4yhvW7RqambZYzGluT8kf3uxdu/TBlAfjKRZQht0Q5niBAmut2MbKgfAk4JryXi/mKvQ0QAMDN6grm1Nlq2xa5ZgCILNnfErEQbRexpKQiSLSW9w==", + )?; + std::fs::write(&unknown, encrypted_pkcs8)?; + ensure!( + case.audit_identity_tree().is_err(), + "unrecorded encrypted PKCS#8 DER was accepted" + ); + std::fs::remove_file(unknown)?; + let unrelated = identity.with_file_name(".identity.json.notes"); + std::fs::write(&unrelated, b"not a temporary file")?; + case.audit_identity_tree()?; + std::fs::remove_file(unrelated)?; + let template = identity.with_file_name(".identity.json.template"); + std::fs::write(&template, b"also not a temporary file")?; + case.audit_identity_tree()?; + std::fs::remove_file(template)?; + // A record's atomic write can leave its temporary file when the agent is stopped mid-write. + for leftover in [ + identity.with_file_name(format!(".identity.json-{}.tmp", uuid::Uuid::new_v4())), + case.in_progress_path().with_extension("json.tmp"), + ] { + std::fs::write(&leftover, b"temp")?; + case.audit_identity_tree() + .with_context(|| format!("temporary record file {} was rejected", leftover.display()))?; + std::fs::write(&leftover, pkcs8.as_bytes())?; + ensure!( + case.audit_identity_tree().is_err(), + "a temporary record file holding a private key was accepted" + ); + std::fs::remove_file(leftover)?; + } + for leftover in [ + case.pending_path() + .with_extension(if cfg!(windows) { "dat.tmp" } else { "json.tmp" }), + case.key_file_path(&name).with_extension("p8.tmp"), + ] { + std::fs::write(&leftover, b"temp")?; + ensure!( + case.audit_identity_tree().is_err(), + "leftover pending or key-file sibling was accepted" + ); + std::fs::remove_file(leftover)?; + } + std::fs::remove_file(case.in_progress_path())?; + ensure!( + case.recorded_key_names()?.is_empty(), + "the removed key is still recorded" + ); + let untracked_pending = case + .path() + .join("identity") + .join("pending") + .join(format!("{}.json.tmp", token_file_id("untracked-token"))); + std::fs::write(&untracked_pending, b"partial temporary file")?; + ensure!( + case.audit_identity_tree().is_err(), + "temporary sibling survived after its record was removed" + ); + std::fs::remove_file(untracked_pending)?; + for copy in [ + case.key_file_path(&name).with_extension("p8.tmp"), + case.key_file_path(&name) + .with_file_name(format!(".{name}-{}.deleted", uuid::Uuid::new_v4())), + ] { + std::fs::write(©, pkcs8.as_bytes())?; + case.audit_identity_tree() + .context("a leftover copy of a deleted key before a later key-store operation was rejected")?; + ensure!( + assert_no_copy_of_deleted_key(&case, &name).is_err(), + "a leftover copy of a deleted key was not reported" + ); + std::fs::remove_file(copy)?; + } + case.audit_identity_tree()?; + let unknown_authority = case + .path() + .join("identity") + .join("authorities") + .join("not-an-authority"); + std::fs::create_dir_all(&unknown_authority)?; + std::fs::write(unknown_authority.join("identity.json"), b"not an identity")?; + case.audit_identity_tree()?; + case.audit_stored_keys()?; + case.audit_orphan_keys()?; + std::fs::remove_dir_all(unknown_authority)?; + case.finish().await?; + Ok(()) + } + + #[tokio::test] + async fn settled_key_check_accepts_a_current_key_retired_after_the_snapshot() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + let case = file_backend_case().await?; + let authority = uuid::Uuid::new_v4().to_string(); + let (retired, retired_key) = new_file_key(&case)?; + let (renewed, renewed_key) = new_file_key(&case)?; + let snapshot = json!({ "keys": { "current": key_slot(&retired, &retired_key)? } }); + // After `confirm`, the retired key moves to `previous` and is deleted before its name leaves the record. + write_identity( + &case, + &authority, + &json!({ "keys": { + "current": key_slot(&renewed, &renewed_key)?, + "previous": key_slot(&retired, &retired_key)? + } }), + )?; + std::fs::remove_file(case.key_file_path(&retired))?; + case.check_key_protection(&authority, &snapshot) + } + + #[tokio::test] + async fn settled_key_check_still_checks_the_current_key_of_the_latest_record() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Coverage + let case = file_backend_case().await?; + let authority = uuid::Uuid::new_v4().to_string(); + let (retired, retired_key) = new_file_key(&case)?; + let (renewed, renewed_key) = new_file_key(&case)?; + let other_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let snapshot = json!({ "keys": { "current": key_slot(&retired, &retired_key)? } }); + std::fs::remove_file(case.key_file_path(&retired))?; + // The two records differ only in the leaf of their current key, so only that leaf decides the outcome. + write_identity( + &case, + &authority, + &json!({ "keys": { "current": key_slot(&renewed, &renewed_key)? } }), + )?; + case.check_key_protection(&authority, &snapshot) + .context("a current key that matches the latest record's leaf was refused")?; + write_identity( + &case, + &authority, + &json!({ "keys": { "current": key_slot(&renewed, &other_key)? } }), + )?; + ensure!( + case.check_key_protection(&authority, &snapshot).is_err(), + "a current key that does not match the latest record's leaf was accepted" + ); + Ok(()) + } + + /// A failed or timed-out case must leave its directory, with the agent's logs, for diagnosis, while a passing one must not leave clutter. + #[tokio::test] + async fn a_case_keeps_its_directory_only_until_it_passes() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + let mut failed = file_backend_case().await?; + failed.keep = true; + let kept = failed.path().to_path_buf(); + std::fs::write(kept.join("agent-stdout.log"), b"evidence")?; + drop(failed); + let survived = std::fs::read(kept.join("agent-stdout.log")); + std::fs::remove_dir_all(&kept)?; + assert_eq!(survived?, b"evidence"); + let passed = file_backend_case().await?; + let removed = passed.path().to_path_buf(); + drop(passed); + assert!(!removed.exists(), "a passing case left {}", removed.display()); + Ok(()) + } + + #[test] + fn agent_metadata_requires_c14_fields_and_stable_platform_values() -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §agent-metadata + let mut metadata = json!({ + "hostname": "test-host", + "os_name": "Windows", + "os_version": "11", + "arch": std::env::consts::ARCH, + "agent_version": "2026.3.0" + }); + #[cfg(target_os = "linux")] + if Path::new("/etc/machine-id").exists() { + metadata["machine_id"] = json!(std::fs::read_to_string("/etc/machine-id")?.trim()); + } + validate_agent_metadata(&metadata, Some("2026.3.0"))?; + for key in REQUIRED_METADATA_KEYS { + let mut missing = metadata.clone(); + missing.as_object_mut().context("fixture metadata")?.remove(key); + ensure!( + validate_agent_metadata(&missing, Some("2026.3.0")).is_err(), + "missing {key} was accepted" + ); + } + let mut unknown = metadata.clone(); + unknown["custom"] = json!("value"); + ensure!( + validate_agent_metadata(&unknown, None).is_err(), + "unknown metadata key was accepted" + ); + metadata["arch"] = json!("wrong-platform"); + ensure!( + validate_agent_metadata(&metadata, None).is_err(), + "wrong architecture was accepted" + ); + metadata["arch"] = json!(std::env::consts::ARCH); + ensure!( + validate_agent_metadata(&metadata, Some("different-version")).is_err(), + "wrong agent version was accepted" + ); + Ok(()) + } + + /// An agent case on the file backend whose agent never starts. + async fn file_backend_case() -> anyhow::Result { + let work_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join("agent-identity-conformance-tests"); + std::fs::create_dir_all(&work_dir)?; + let target = Target::new( + "https://localhost/mock".to_owned(), + crate::dvls::AppCredential::new("test".to_owned(), "test".to_owned()), + None, + None, + )?; + let mut case = AgentCase::new( + Context { + test: "harness", + target, + second: None, + target_kind: crate::TargetKind::Mock, + disposable_dvls_target: false, + dvls_rotation_window_secs: 60, + leaf_lifetime_secs: 90 * 24 * 3600, + expect_channel: true, + channel_available: true, + key_name_prefix: format!("conformance-{}", uuid::Uuid::new_v4().simple()), + agent_version: None, + unprivileged: None, + agent_bin: PathBuf::new(), + key_backend: KeyBackend::File, + work_dir, + }, + None, + false, + ) + .await?; + case.keep = false; + Ok(case) + } + + /// Stores a new P-256 file key with an owner-only mode, under a new name with the case's run prefix. + fn new_file_key(case: &AgentCase) -> anyhow::Result<(String, rcgen::KeyPair)> { + use std::io::Write as _; + + let name = format!("{}_{}", case.ctx.key_name_prefix, uuid::Uuid::new_v4()); + let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let path = case.key_file_path(&name); + std::fs::create_dir_all(path.parent().context("key directory")?)?; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + options.open(path)?.write_all(&key.serialize_der())?; + Ok((name, key)) + } + + /// A stored key slot that names `name` and holds a self-signed leaf for `key`. + fn key_slot(name: &str, key: &rcgen::KeyPair) -> anyhow::Result { + let leaf = rcgen::CertificateParams::new(Vec::::new())?.self_signed(key)?; + Ok(json!({ + "key_name": name, + "certificate_chain": [base64::engine::general_purpose::STANDARD.encode(leaf.der())] + })) + } + + fn write_identity(case: &AgentCase, authority_id: &str, state: &Value) -> anyhow::Result<()> { + let path = case.identity_path(authority_id); + std::fs::create_dir_all(path.parent().context("authority directory")?)?; + std::fs::write(path, serde_json::to_vec(state)?)?; + Ok(()) + } +} diff --git a/crates/agent-identity-conformance/src/authority.rs b/crates/agent-identity-conformance/src/authority.rs new file mode 100644 index 000000000..592354782 --- /dev/null +++ b/crates/agent-identity-conformance/src/authority.rs @@ -0,0 +1,424 @@ +//! The authorities a run tests, what each offers, and which ones a test runs on. + +use std::path::PathBuf; +use std::sync::LazyLock; +use std::time::Duration; + +use anyhow::{Context as _, ensure}; + +use crate::client::Target; + +pub(crate) const DAY_SECS: u64 = 24 * 3600; + +/// The shortest leaf lifetime that outlasts a test: 3/2 of [`crate::TEST_TIMEOUT`]. +/// +/// Every wait of a test ends within its timeout, and the agent renews a certificate `2/3 × lifetime` after its anchor ([agent intent], "Schedules"). +/// A leaf issued during a test therefore neither comes due for renewal nor expires, which would close its stream ([contract], "Termination"), before the test ends. +/// This takes the anchor to be no earlier than the test's start: an authority that backdates `notBefore`, or whose clock runs behind the runner's, needs longer leaves. +/// +/// [agent intent]: ../../agent-identity/INTENT.md#schedules +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#channel-termination +pub(crate) const LONG_LEAF_SECS: u64 = crate::TEST_TIMEOUT.as_secs() * 3 / 2; + +static LONG_LEAVES_MISSING: LazyLock = + LazyLock::new(|| format!("requires leaves that outlast a test, of --leaf-lifetime-secs >= {LONG_LEAF_SECS}")); + +/// What an authority offers the tests, as the runner's flags declare it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Profile { + /// The lifetime of the leaves the authority issues, which must match its setting. + pub(crate) leaf_lifetime_secs: u64, + /// Whether the runner assumes that the authority offers the agent channel, rather than probing for it. + pub(crate) expect_channel: bool, + /// Whether rotation and settings changes are safe on the authority. + pub(crate) disposable: bool, + /// The rotation window, or `None` to derive it from the leaf lifetime. + pub(crate) rotation_window_secs: Option, +} + +impl Default for Profile { + fn default() -> Self { + Self { + leaf_lifetime_secs: 90 * DAY_SECS, + expect_channel: true, + disposable: false, + rotation_window_secs: None, + } + } +} + +impl Profile { + /// The rotation window: the explicit one, or else 60 seconds, capped at 75% of the leaf lifetime in whole seconds. + /// + /// DVLS refuses a rotation deadline after the rotation maximum, which is at most one leaf lifetime ahead ([DVLS intent], "Rotation"). + /// A leaf lifetime of one second leaves no whole second within that cap, so it requires an explicit window. + /// + /// [DVLS intent]: https://github.com/Devolutions/RDM/blob/0acd9d2474bf9fd4a030e146a28097bf4ddf391a/DVLS/Server/server/Common/AgentIdentity/INTENT.md + pub(crate) fn rotation_window_secs(&self) -> anyhow::Result { + if let Some(window) = self.rotation_window_secs { + return Ok(window); + } + let window = (self.leaf_lifetime_secs.saturating_mul(3) / 4).min(60); + ensure!( + window > 0, + "--leaf-lifetime-secs must be at least 2 for a default rotation window, or pass --dvls-rotation-window-secs" + ); + Ok(window) + } +} + +/// An authority under test. +#[derive(Clone)] +pub(crate) struct Authority { + /// The flag that declares the authority, such as `--base-url` or `--authority 2`. + pub(crate) name: String, + pub(crate) target: Target, + pub(crate) profile: Profile, + /// Whether the authority offers the agent channel: assumed when the profile expects it, and probed otherwise. + pub(crate) channel_available: bool, + /// Whether the profile's leaf lifetime is the authority's certificate lifetime setting, which the runner reads only on a disposable DVLS target. + pub(crate) lifetime_from_settings: bool, + pub(crate) rotation_window_secs: u64, +} + +impl Authority { + pub(crate) fn leaf_lifetime(&self) -> Duration { + Duration::from_secs(self.profile.leaf_lifetime_secs) + } +} + +/// An authority that `--authority` declares, as comma-separated `=` items. +pub(crate) struct AuthoritySpec { + pub(crate) base_url: String, + pub(crate) app_key: String, + pub(crate) app_secret: String, + pub(crate) authority_id: Option, + pub(crate) ca_path: Option, + pub(crate) profile: Profile, +} + +impl AuthoritySpec { + /// Parses `spec`, whose application secret is in the environment variable that its `app-secret-env` item names, as `variable` reads it. + /// + /// Errors name known keys and item positions, never values or unknown keys, since a misplaced value may be a secret. + pub(crate) fn parse(spec: &str, variable: &dyn Fn(&str) -> Option) -> anyhow::Result { + let mut base_url = None; + let mut app_key = None; + let mut secret_variable = None; + let mut authority_id = None; + let mut ca_path = None; + let mut profile = Profile::default(); + let mut seen = Vec::new(); + for (item, position) in spec.split(',').zip(1..) { + let (key, value) = item + .split_once('=') + .with_context(|| format!("--authority item {position} is not ="))?; + let positive = || -> anyhow::Result { + value + .parse() + .ok() + .filter(|number| 0 < *number) + .with_context(|| format!("--authority {key} must be a positive integer")) + }; + let switch = || { + value + .parse::() + .with_context(|| format!("--authority {key} must be true or false")) + }; + match key { + "base-url" => base_url = Some(value.to_owned()), + "app-key" => app_key = Some(value.to_owned()), + "app-secret-env" => secret_variable = Some(value), + "authority-id" => authority_id = Some(value.to_owned()), + "extra-trusted-root" => ca_path = Some(PathBuf::from(value)), + "leaf-lifetime-secs" => profile.leaf_lifetime_secs = positive()?, + "expect-channel" => profile.expect_channel = switch()?, + "disposable-dvls-target" => profile.disposable = switch()?, + "dvls-rotation-window-secs" => profile.rotation_window_secs = Some(positive()?), + _ => anyhow::bail!("--authority item {position} has an unknown key"), + } + ensure!(!seen.contains(&key), "--authority repeats {key}"); + seen.push(key); + } + let app_secret = variable(secret_variable.context("--authority requires app-secret-env")?) + .filter(|secret| !secret.is_empty()) + .context("--authority app-secret-env names an unset or empty environment variable")?; + Ok(Self { + base_url: base_url.context("--authority requires base-url")?, + app_key: app_key.context("--authority requires app-key")?, + app_secret, + authority_id, + ca_path, + profile, + }) + } +} + +/// A capability that a test needs from the authority it runs on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Need { + /// The authority offers the agent channel. + Channel, + /// The authority offers no agent channel; the mock withdraws it with a fault. + NoChannel, + /// Rotation and settings changes are safe. + Disposable, + /// The authority issues leaves of at most 30 seconds; the mock does with a fault. + ShortLeaves, + /// The authority issues leaves of its certificate lifetime setting, which the profile's leaf lifetime matches, rather than a test lifetime in seconds. + SettingsLifetime, + /// The management API can change a config, so that an identity's stored revision becomes stale; the mock changes one with a control. + ConfigChange, + /// The authority's leaves outlast a test, by lasting at least [`LONG_LEAF_SECS`]; the mock's last its default 90 days, which each test's reset restores. + LongLeaves, +} + +impl Need { + pub(crate) const ALL: [Self; 7] = [ + Self::Channel, + Self::NoChannel, + Self::Disposable, + Self::ShortLeaves, + Self::SettingsLifetime, + Self::ConfigChange, + Self::LongLeaves, + ]; + + pub(crate) const fn bit(self) -> u8 { + 1 << self as u8 + } + + fn met_by(self, authority: &Authority, mock: bool) -> bool { + let profile = &authority.profile; + match self { + Self::Channel => authority.channel_available, + Self::NoChannel => mock || !authority.channel_available, + Self::Disposable => mock || profile.disposable, + Self::ShortLeaves => mock || profile.leaf_lifetime_secs <= 30, + Self::SettingsLifetime => mock || authority.lifetime_from_settings, + // DVLS's management API changes a config only through the agent channel switch, which needs both. + Self::ConfigChange => mock || profile.disposable && authority.channel_available, + Self::LongLeaves => mock || LONG_LEAF_SECS <= profile.leaf_lifetime_secs, + } + } + + /// Why a test that has this need reports `SKIP` when no authority meets it. + fn missing(self) -> &'static str { + match self { + Self::Channel => "config.agent_channel_url absent and --expect-channel=false", + Self::NoChannel => "requires a naturally channelless DVLS target", + Self::Disposable => "rotation and settings changes require --disposable-dvls-target", + Self::ShortLeaves => "real-target stream expiry requires --leaf-lifetime-secs <= 30", + Self::SettingsLifetime => { + "requires a disposable DVLS target without a test leaf lifetime, whose --leaf-lifetime-secs is its certificate lifetime in days" + } + Self::ConfigChange => { + "a stale Hello requires a disposable DVLS target that offers the agent channel, whose switch changes the config" + } + Self::LongLeaves => LONG_LEAVES_MISSING.as_str(), + } + } +} + +const SECOND_MISSING: &str = + "requires a second authority that meets the same needs (--second-base-url and --second-app-key, or --authority)"; + +/// The authorities a test runs on, by index: the first that meets every need, and, when the test needs two, the next one that does. +/// +/// When too few do, the error is the test's `SKIP` reason: the first need that the first authority lacks, or else the missing second authority. +pub(crate) fn pick( + authorities: &[Authority], + needs: &[Need], + second: bool, + mock: bool, +) -> Result<(usize, Option), &'static str> { + let meets = |authority: &Authority| needs.iter().all(|need| need.met_by(authority, mock)); + let mut candidates = authorities + .iter() + .enumerate() + .filter(|(_, authority)| meets(authority)) + .map(|(index, _)| index); + let Some(first) = candidates.next() else { + let lacking = authorities + .first() + .and_then(|authority| needs.iter().find(|need| !need.met_by(authority, mock))); + return Err(lacking.map_or(SECOND_MISSING, |need| need.missing())); + }; + if !second { + return Ok((first, None)); + } + candidates.next().map(|next| (first, Some(next))).ok_or(SECOND_MISSING) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dvls::AppCredential; + + fn authority(profile: Profile, channel_available: bool, lifetime_from_settings: bool) -> anyhow::Result { + Ok(Authority { + name: "--authority".to_owned(), + target: Target::new( + "https://localhost/authority".to_owned(), + AppCredential::new("key".to_owned(), "secret".to_owned()), + None, + None, + )?, + profile, + channel_available, + lifetime_from_settings, + rotation_window_secs: profile.rotation_window_secs()?, + }) + } + + /// A DVLS run with two authorities issuing 20-second test leaves with the channel, a third issuing one-day test leaves, and a fourth with neither test leaves nor the channel. + fn dvls_run() -> anyhow::Result> { + let short = Profile { + leaf_lifetime_secs: 20, + disposable: true, + ..Profile::default() + }; + let day = Profile { + leaf_lifetime_secs: DAY_SECS, + ..short + }; + let plain = Profile { + expect_channel: false, + disposable: true, + ..Profile::default() + }; + Ok(vec![ + authority(short, true, false)?, + authority(short, true, false)?, + authority(day, true, false)?, + authority(plain, false, true)?, + ]) + } + + /// A check runs on the first authority that offers what it needs, so a profile-dependent check reaches an extra authority instead of skipping. + #[test] + fn a_check_runs_on_the_first_authority_that_meets_its_needs() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + let authorities = dvls_run()?; + assert_eq!(pick(&authorities, &[], false, false), Ok((0, None))); + assert_eq!( + pick(&authorities, &[Need::Disposable, Need::SettingsLifetime], false, false), + Ok((3, None)) + ); + assert_eq!( + pick( + &authorities[2..], + &[Need::Disposable, Need::SettingsLifetime], + false, + false + ), + Ok((1, None)) + ); + assert_eq!(pick(&authorities, &[Need::NoChannel], false, false), Ok((3, None))); + assert_eq!( + pick(&authorities, &[Need::Channel, Need::ConfigChange], false, false), + Ok((0, None)) + ); + assert_eq!(pick(&authorities, &[Need::Channel], true, false), Ok((0, Some(1)))); + // Twenty-second leaves expire within a test, while a day's outlast it. + assert_eq!( + pick(&authorities, &[Need::Channel, Need::LongLeaves], false, false), + Ok((2, None)) + ); + Ok(()) + } + + /// A run that lacks a fixture reports `SKIP` with the capability it lacks, rather than a generic reason. + #[test] + fn a_check_skips_with_the_missing_capability_only_when_no_authority_meets_it() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + let authorities = dvls_run()?; + assert_eq!( + pick(&authorities[..2], &[Need::NoChannel], false, false), + Err(Need::NoChannel.missing()) + ); + assert_eq!( + pick( + &authorities[..3], + &[Need::Disposable, Need::SettingsLifetime], + false, + false + ), + Err(Need::SettingsLifetime.missing()) + ); + assert_eq!( + pick(&authorities[3..], &[Need::Channel, Need::ConfigChange], false, false), + Err(Need::Channel.missing()) + ); + assert_eq!(pick(&authorities, &[Need::NoChannel], true, false), Err(SECOND_MISSING)); + assert_eq!( + pick(&authorities[..2], &[Need::Channel, Need::LongLeaves], false, false), + Err(Need::LongLeaves.missing()) + ); + Ok(()) + } + + /// The mock offers every fixture through its controls, so a mock run never skips for one, and runs on its first authority. + #[test] + fn the_mock_meets_every_need_but_an_absent_channel() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + let mock = [authority(Profile::default(), true, false)?]; + for need in Need::ALL { + assert_eq!(pick(&mock, &[need], false, true), Ok((0, None)), "{need:?}"); + } + let channelless = [authority(Profile::default(), false, false)?]; + assert_eq!( + pick(&channelless, &[Need::Channel], false, true), + Err(Need::Channel.missing()) + ); + Ok(()) + } + + /// An authority's secret comes from the environment, which keeps it off the command line. + #[test] + fn an_authority_spec_reads_its_secret_from_the_variable_it_names() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + let variable = |name: &str| (name == "C_SECRET").then(|| "synthetic-secret".to_owned()); + let spec = AuthoritySpec::parse( + "base-url=https://localhost:15512,app-key=c-key,app-secret-env=C_SECRET,leaf-lifetime-secs=7776000,expect-channel=false,disposable-dvls-target=true", + &variable, + )?; + assert_eq!( + (spec.base_url.as_str(), spec.app_key.as_str(), spec.app_secret.as_str()), + ("https://localhost:15512", "c-key", "synthetic-secret") + ); + assert_eq!( + spec.profile, + Profile { + leaf_lifetime_secs: 7_776_000, + expect_channel: false, + disposable: true, + rotation_window_secs: None, + } + ); + Ok(()) + } + + /// A usage error lands in CI logs, so an `--authority` item that holds a secret in a form the runner does not take must not appear in it. + #[test] + fn authority_spec_errors_never_echo_a_value() { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + let secret = "synthetic-credential"; + let variable = |_: &str| None; + for spec in [ + format!("base-url=https://localhost,app-key=key,app-secret={secret}"), + format!("base-url=https://localhost,app-key=key,{secret}"), + format!("base-url=https://localhost,app-key=key,app-secret-env={secret}"), + format!("base-url=https://localhost,app-key=key,app-secret-env=V,leaf-lifetime-secs={secret}"), + format!("base-url=https://localhost,app-key=key,app-secret-env=V,expect-channel={secret}"), + ] { + let error = AuthoritySpec::parse(&spec, &variable) + .err() + .map(|error| format!("{error:#}")); + assert!( + error.as_ref().is_some_and(|error| !error.contains(secret)), + "{spec} gave {error:?}" + ); + } + } +} diff --git a/crates/agent-identity-conformance/src/channel.rs b/crates/agent-identity-conformance/src/channel.rs new file mode 100644 index 000000000..5b1c7c971 --- /dev/null +++ b/crates/agent-identity-conformance/src/channel.rs @@ -0,0 +1,458 @@ +use std::time::Duration; + +use agent_channel_proto::{Ack, AgentMessage, Hello, ServerMessage, agent_message, server_message}; +use anyhow::{Context as _, ensure}; +use http::Request as HttpRequest; +use http::uri::PathAndQuery; +use prost::Message as _; +use prost::bytes::{Buf as _, BufMut as _}; +use time::format_description::well_known::Rfc3339; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::body::BoxBody; +use tonic::client::Grpc; +use tonic::codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder}; +use tonic::transport::{Certificate, ClientTlsConfig, Endpoint}; +use tonic::{Code, Request, Status, Streaming}; +use tower::{Service as _, ServiceExt as _, service_fn}; + +use crate::client::{Identity, Target}; +use crate::signer::SignedHeaders; + +/// The largest envelope either side of the channel accepts or sends, in bytes of serialized protobuf without gRPC's 5-byte message prefix. +/// +/// See [CONTRACT.agent-identity.md] §7.1 "Service". +/// +/// [CONTRACT.agent-identity.md]: ../../../docs/agent-identity/CONTRACT.agent-identity.md +pub(crate) const MAX_ENVELOPE_LEN: usize = 65_536; + +/// The `:path` of `AgentChannel.Connect` below the channel URL's path. +/// +/// See [CONTRACT.agent-identity.md] §7.2 "URL and path prefix". +/// +/// [CONTRACT.agent-identity.md]: ../../../docs/agent-identity/CONTRACT.agent-identity.md +const CONNECT_PATH: &str = "/devolutions.agent.channel.v1.AgentChannel/Connect"; + +/// The key of `AgentMessage.id`, field 1 with wire type 2 (`LEN`): `1 << 3 | 2`. +const ID_KEY: [u8; 1] = [0x0A]; + +/// The key of field 99 with wire type 2 (`LEN`), which `AgentMessage` does not define: `99 << 3 | 2` = 794, the varint `0x9A 0x06`. +const PADDING_KEY: [u8; 2] = [0x9A, 0x06]; + +pub(crate) struct ChannelStream { + sender: mpsc::Sender>, + pub(crate) stream: Streaming, + pub(crate) headers: SignedHeaders, +} + +impl ChannelStream { + pub(crate) async fn challenge(&mut self) -> anyhow::Result { + let message = self.next().await?; + ensure!( + matches!(message.payload.as_ref(), Some(server_message::Payload::Challenge(_))), + "first channel message is not a challenge" + ); + ensure!( + message.payload.as_ref().and_then(|p| match p { + server_message::Payload::Challenge(challenge) => Some(challenge.challenge.len()), + _ => None, + }) == Some(32), + "channel challenge is not 32 bytes" + ); + Ok(message) + } + + pub(crate) async fn send_hello( + &mut self, + identity: &Identity, + challenge: &ServerMessage, + metadata: &[(&str, &str)], + proof_override: Option>, + ) -> anyhow::Result { + let revision = config_revision(identity)?; + self.send_hello_with_revision(identity, challenge, metadata, proof_override, revision) + .await + } + + pub(crate) async fn send_hello_with_revision( + &mut self, + identity: &Identity, + challenge: &ServerMessage, + metadata: &[(&str, &str)], + proof_override: Option>, + revision: u64, + ) -> anyhow::Result { + let message = self.hello_message(identity, challenge, metadata, proof_override, revision)?; + self.send(&message).await.context("send channel Hello")?; + Ok(message.id) + } + + /// Serializes the `Hello` that [`Self::send_hello`] sends, as [`agent_message_frame`] does with `id` and `len`. + pub(crate) fn hello_frame( + &self, + identity: &Identity, + challenge: &ServerMessage, + metadata: &[(&str, &str)], + id: &[u8], + len: usize, + ) -> anyhow::Result> { + let message = self.hello_message(identity, challenge, metadata, None, config_revision(identity)?)?; + agent_message_frame(id, &message, len) + } + + fn hello_message( + &self, + identity: &Identity, + challenge: &ServerMessage, + metadata: &[(&str, &str)], + proof_override: Option>, + revision: u64, + ) -> anyhow::Result { + let Some(server_message::Payload::Challenge(payload)) = challenge.payload.as_ref() else { + anyhow::bail!("expected a channel challenge"); + }; + let proof = + proof_override.unwrap_or_else(|| identity.key.channel_proof(&payload.challenge, &self.headers.nonce)); + Ok(AgentMessage { + id: uuid::Uuid::new_v4().to_string(), + correlation_id: Some(challenge.id.clone()), + payload: Some(agent_message::Payload::Hello(Hello { + metadata: metadata + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + capabilities: Vec::new(), + applied_state_versions: [("config".to_owned(), revision)].into_iter().collect(), + proof, + })), + }) + } + + pub(crate) async fn send(&mut self, message: &AgentMessage) -> anyhow::Result<()> { + self.send_frame(message.encode_to_vec()).await + } + + /// Sends `frame` as one gRPC message, whether or not it decodes as an `AgentMessage`. + pub(crate) async fn send_frame(&mut self, frame: Vec) -> anyhow::Result<()> { + self.sender.send(frame).await.context("send channel frame") + } + + pub(crate) async fn hello(&mut self, identity: &Identity, metadata: &[(&str, &str)]) -> anyhow::Result<()> { + let challenge = self.challenge().await?; + let hello_id = self.send_hello(identity, &challenge, metadata, None).await?; + let welcome = self.next().await?; + ensure!( + matches!(welcome.payload, Some(server_message::Payload::Welcome(_))) + && welcome.correlation_id.as_deref() == Some(&hello_id), + "channel Welcome missing or incorrectly correlated" + ); + Ok(()) + } + + pub(crate) async fn ack(&mut self, message: &ServerMessage) -> anyhow::Result<()> { + self.send(&AgentMessage { + id: uuid::Uuid::new_v4().to_string(), + correlation_id: Some(message.id.clone()), + payload: Some(agent_message::Payload::Ack(Ack {})), + }) + .await + .context("acknowledge channel message") + } + + pub(crate) async fn next(&mut self) -> anyhow::Result { + self.next_with_timeout(Duration::from_secs(5)).await + } + + pub(crate) async fn next_with_timeout(&mut self, timeout: Duration) -> anyhow::Result { + tokio::time::timeout(timeout, self.stream.message()) + .await + .context("timed out waiting for channel message")?? + .context("channel closed unexpectedly") + } + + pub(crate) async fn closing_status(&mut self, timeout: Duration) -> anyhow::Result { + match tokio::time::timeout(timeout, self.stream.message()) + .await + .context("channel did not close")? + { + Err(status) => Ok(status), + Ok(Some(_)) => anyhow::bail!("channel sent a message instead of closing"), + Ok(None) => anyhow::bail!("channel closed without a gRPC error"), + } + } +} + +pub(crate) fn expect_status(status: &Status, grpc_code: Code, error_code: &str) -> anyhow::Result<()> { + ensure!( + status.code() == grpc_code, + "expected gRPC {grpc_code:?}, got {:?} ({:?})", + status.code(), + status.message() + ); + ensure!( + status + .metadata() + .get("error-code") + .and_then(|value| value.to_str().ok()) + == Some(error_code), + "expected gRPC error-code {error_code}, got {:?}", + status.metadata().get("error-code") + ); + Ok(()) +} + +/// Checks a channel opening refused before `Challenge`, whose trailers carry the §5.4 code and the authority's time. +/// +/// See [CONTRACT.agent-identity.md] §7.4 "Termination" and §1 "Protocol conventions". +/// +/// [CONTRACT.agent-identity.md]: ../../../docs/agent-identity/CONTRACT.agent-identity.md +pub(crate) fn expect_refused_opening(status: &Status, grpc_code: Code, error_code: &str) -> anyhow::Result<()> { + expect_status(status, grpc_code, error_code)?; + expect_server_time(status) +} + +/// Checks that an authentication failure, at opening or while the stream awaits `Hello`, carries the authority's time. +/// +/// See [CONTRACT.agent-identity.md] §7.4 "Termination" and §1 "Protocol conventions". +/// +/// [CONTRACT.agent-identity.md]: ../../../docs/agent-identity/CONTRACT.agent-identity.md +pub(crate) fn expect_server_time(status: &Status) -> anyhow::Result<()> { + let server_time = status + .metadata() + .get("server-time") + .context("authentication failure has no server-time trailer")? + .to_str() + .context("server-time trailer is not ASCII")?; + ensure!( + server_time.ends_with('Z'), + "server-time trailer {server_time:?} does not end in Z" + ); + time::OffsetDateTime::parse(server_time, &Rfc3339).context("server-time trailer is not RFC 3339")?; + Ok(()) +} + +pub(crate) async fn open(target: &Target, identity: &Identity) -> anyhow::Result { + let headers = identity.key.sign_now(&identity.thumbprint, "connect", None); + open_with_headers(target, identity, headers).await +} + +pub(crate) async fn open_with_headers( + target: &Target, + identity: &Identity, + headers: SignedHeaders, +) -> anyhow::Result { + let channel_url = identity + .agent_channel_url + .as_deref() + .context("enrollment omitted config.agent_channel_url")?; + open_at_url(target, channel_url, headers, None).await +} + +pub(crate) async fn open_with_duplicate_header( + target: &Target, + identity: &Identity, + name: &'static str, +) -> anyhow::Result { + let channel_url = identity + .agent_channel_url + .as_deref() + .context("enrollment omitted config.agent_channel_url")?; + let headers = identity.key.sign_now(&identity.thumbprint, "connect", None); + open_at_url(target, channel_url, headers, Some(name)).await +} + +pub(crate) async fn probe_unavailable(target: &Target, identity: &Identity) -> anyhow::Result { + let headers = identity.key.sign_now(&identity.thumbprint, "connect", None); + open_at_url(target, &target.base_url, headers, None).await +} + +async fn open_at_url( + target: &Target, + channel_url: &str, + headers: SignedHeaders, + duplicate: Option<&'static str>, +) -> anyhow::Result { + let url = reqwest::Url::parse(channel_url)?; + let origin = url.origin().ascii_serialization(); + let mut endpoint = Endpoint::from_shared(origin)?.connect_timeout(Duration::from_secs(5)); + let mut tls = ClientTlsConfig::new().with_webpki_roots(); + if let Some(ca_path) = &target.ca_path { + tls = tls.ca_certificate(Certificate::from_pem(std::fs::read(ca_path)?)); + } + endpoint = endpoint.tls_config(tls)?; + let transport = endpoint.connect().await.context("connect channel transport")?; + let prefix = url.path().trim_end_matches('/').to_owned(); + let service = service_fn(move |mut request: HttpRequest| { + let mut transport = transport.clone(); + let path = format!( + "{prefix}{}", + request.uri().path_and_query().map_or("/", |value| value.as_str()) + ); + async move { + *request.uri_mut() = path + .parse() + .map_err(|error: http::uri::InvalidUri| Status::internal(format!("invalid channel path: {error}")))?; + transport + .ready() + .await + .map_err(|error| Status::from_error(Box::new(error)))?; + transport + .call(request) + .await + .map_err(|error| Status::from_error(Box::new(error))) + } + }); + let mut grpc = Grpc::new(service); + let (sender, receiver) = mpsc::channel(8); + let mut request = Request::new(ReceiverStream::new(receiver)); + request.metadata_mut().insert( + "signature-input", + headers.input.parse().context("encode signature input")?, + ); + request + .metadata_mut() + .insert("signature", headers.signature.parse().context("encode signature")?); + if let Some(digest) = &headers.digest { + request + .metadata_mut() + .insert("content-digest", digest.parse().context("encode content digest")?); + } + if let Some(name) = duplicate { + let value = match name { + "signature-input" => &headers.input, + "signature" => &headers.signature, + _ => anyhow::bail!("unknown duplicate metadata {name}"), + }; + request + .metadata_mut() + .append(name, value.parse().context("duplicate signature metadata")?); + } + grpc.ready().await?; + let stream = grpc + .streaming(request, PathAndQuery::from_static(CONNECT_PATH), FrameCodec) + .await? + .into_inner(); + Ok(ChannelStream { + sender, + stream, + headers, + }) +} + +fn config_revision(identity: &Identity) -> anyhow::Result { + identity.config["revision"] + .as_u64() + .context("identity config has no revision") +} + +/// Serializes `message` with `id` in place of its own, as bytes that need not be UTF-8, and pads it with an unknown field to exactly `len` bytes. +/// +/// A protobuf decoder skips a field its schema does not define, so the padding changes no decoded value. +pub(crate) fn agent_message_frame(id: &[u8], message: &AgentMessage, len: usize) -> anyhow::Result> { + let mut frame = Vec::with_capacity(len); + frame.extend_from_slice(&ID_KEY); + prost::encode_length_delimiter(id.len(), &mut frame)?; + frame.extend_from_slice(id); + AgentMessage { + id: String::new(), + ..message.clone() + } + .encode(&mut frame)?; + let room = len + .checked_sub(frame.len() + PADDING_KEY.len()) + .context("the message leaves no room for padding")?; + // The padding's length prefix takes part of the room, and a varint takes at most 10 bytes. + let padding = (1..=10) + .filter_map(|prefix_len| room.checked_sub(prefix_len)) + .find(|&padding| padding + prost::length_delimiter_len(padding) == room) + .with_context(|| format!("no padding makes the frame {len} bytes"))?; + frame.extend_from_slice(&PADDING_KEY); + prost::encode_length_delimiter(padding, &mut frame)?; + frame.extend(std::iter::repeat_n(0, padding)); + ensure!(frame.len() == len, "padded frame is {} bytes, not {len}", frame.len()); + Ok(frame) +} + +/// Sends each frame's bytes as they are, so that a test can send a message that does not decode, and decodes server messages within the envelope limit. +struct FrameCodec; + +impl Codec for FrameCodec { + type Encode = Vec; + type Decode = ServerMessage; + type Encoder = FrameCodec; + type Decoder = FrameCodec; + + fn encoder(&mut self) -> Self::Encoder { + FrameCodec + } + + fn decoder(&mut self) -> Self::Decoder { + FrameCodec + } +} + +impl Encoder for FrameCodec { + type Item = Vec; + type Error = Status; + + fn encode(&mut self, frame: Vec, dst: &mut EncodeBuf<'_>) -> Result<(), Status> { + dst.put_slice(&frame); + Ok(()) + } +} + +impl Decoder for FrameCodec { + type Item = ServerMessage; + type Error = Status; + + fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result, Status> { + // The buffer holds exactly one message, without its gRPC prefix. + let len = src.remaining(); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service + // UNTESTED: a server message over the envelope limit fails the read that receives it. + // No authority under test sends one, so a broken check still passes every run. + // When changing this decoder, lower the limit below a `Challenge` and see every channel test fail. + if MAX_ENVELOPE_LEN < len { + return Err(Status::internal(format!( + "server message of {len} bytes exceeds the {MAX_ENVELOPE_LEN}-byte envelope limit" + ))); + } + ServerMessage::decode(src) + .map(Some) + .map_err(|error| Status::internal(format!("undecodable server message: {error}"))) + } +} + +#[cfg(test)] +mod tests { + use tonic::metadata::MetadataMap; + + use super::*; + + fn refusal(server_time: Option<&str>) -> anyhow::Result { + let mut metadata = MetadataMap::new(); + metadata.insert("error-code", "clock_skew".parse()?); + if let Some(server_time) = server_time { + metadata.insert("server-time", server_time.parse()?); + } + Ok(Status::with_metadata(Code::Unauthenticated, "skewed", metadata)) + } + + #[test] + fn refused_opening_requires_a_z_server_time_trailer() -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + expect_refused_opening( + &refusal(Some("2026-09-24T12:00:00Z"))?, + Code::Unauthenticated, + "clock_skew", + )?; + for server_time in [None, Some("2026-09-24T12:00:00+00:00"), Some("not a time")] { + ensure!( + expect_refused_opening(&refusal(server_time)?, Code::Unauthenticated, "clock_skew").is_err(), + "refused opening with server-time {server_time:?} was accepted" + ); + } + Ok(()) + } +} diff --git a/crates/agent-identity-conformance/src/client.rs b/crates/agent-identity-conformance/src/client.rs new file mode 100644 index 000000000..976fd0c6c --- /dev/null +++ b/crates/agent-identity-conformance/src/client.rs @@ -0,0 +1,485 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context as _, ensure}; +use base64::Engine as _; +use http::Method; +use serde_json::{Value, json}; +use time::format_description::well_known::Rfc3339; + +use crate::dvls::{AppCredential, Credential, Management, Session}; +use crate::signer::{KeyPair, SignedHeaders}; + +#[derive(Clone)] +pub(crate) struct Target { + pub(crate) base_url: String, + /// The privileged session of the management API. + pub(crate) session: Arc, + pub(crate) ca_path: Option, + pub(crate) authority_id: Option, + pub(crate) http: reqwest::Client, +} + +#[derive(Clone)] +pub(crate) struct Reply { + pub(crate) status: u16, + pub(crate) body: Value, +} + +impl Target { + pub(crate) fn new( + base_url: String, + credential: AppCredential, + ca_path: Option<&Path>, + authority_id: Option<&str>, + ) -> anyhow::Result { + let url = reqwest::Url::parse(&base_url).context("parse base URL")?; + ensure!(url.scheme() == "https", "base URL must use https"); + ensure!( + url.query().is_none() && url.fragment().is_none(), + "base URL cannot have a query or fragment" + ); + let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(8)); + if let Some(path) = ca_path { + let pem = std::fs::read(path).context("read trusted root PEM")?; + builder = builder.add_root_certificate(reqwest::Certificate::from_pem(&pem)?); + } + let authority_id = authority_id + .map(|id| { + let parsed = uuid::Uuid::parse_str(id).context("invalid known authority ID")?; + ensure!( + parsed.to_string() == id, + "authority ID must be a lowercase hyphenated UUID" + ); + Ok(parsed) + }) + .transpose()?; + Ok(Self { + base_url: base_url.trim_end_matches('/').to_owned(), + session: Arc::new(Session::new(credential)), + ca_path: ca_path.map(Path::to_path_buf), + authority_id, + http: builder.build()?, + }) + } + + pub(crate) fn management(&self) -> Management<'_> { + Management::new(self, Credential::Session(&self.session)) + } + + /// The management API called with `credential`. + pub(crate) fn management_as<'a>(&'a self, credential: Credential<'a>) -> Management<'a> { + Management::new(self, credential) + } + + /// Sends a request whose response must have a JSON body unless it is empty. + pub(crate) async fn send( + &self, + method: Method, + path: &str, + body: Option<&[u8]>, + bearer: Option<&str>, + signed: Option<&SignedHeaders>, + ) -> anyhow::Result { + let (status, bytes) = self.exchange(method, path, body, bearer, signed).await?; + let body = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).context("decode JSON response")? + }; + Ok(Reply { status, body }) + } + + /// Sends a request and returns the response status and body bytes. + pub(crate) async fn exchange( + &self, + method: Method, + path: &str, + body: Option<&[u8]>, + bearer: Option<&str>, + signed: Option<&SignedHeaders>, + ) -> anyhow::Result<(u16, Vec)> { + let mut request = self.http.request(method, format!("{}{path}", self.base_url)); + if let Some(bearer) = bearer { + request = request.bearer_auth(bearer); + } + if let Some(body) = body { + request = request.header("content-type", "application/json").body(body.to_vec()); + } + if let Some(signed) = signed { + request = request + .header("signature-input", &signed.input) + .header("signature", &signed.signature); + if let Some(digest) = &signed.digest { + request = request.header("content-digest", digest); + } + } + let response = request.send().await.context("send HTTP request")?; + let status = response.status().as_u16(); + let bytes = response.bytes().await.context("read HTTP response")?; + Ok((status, bytes.to_vec())) + } + + pub(crate) async fn agent( + &self, + method: Method, + path: &str, + body: Option<&Value>, + token: Option<&str>, + ) -> anyhow::Result { + let bytes = body.map(serde_json::to_vec).transpose()?; + self.send( + method, + &format!("/api/agent-identity/v1{path}"), + bytes.as_deref(), + token, + None, + ) + .await + } + + pub(crate) async fn control(&self, path: &str, body: &Value) -> anyhow::Result { + let bytes = serde_json::to_vec(body)?; + self.send(Method::POST, &format!("/__mock__/{path}"), Some(&bytes), None, None) + .await + } + + pub(crate) async fn reset(&self) -> anyhow::Result<()> { + let reply = self.control("reset", &json!({})).await?; + expect_status(&reply, 200)?; + if let Some(known) = self.authority_id { + ensure!( + reply.body["authority_id"] == known.to_string(), + "mock authority ID changed" + ); + } + Ok(()) + } + + pub(crate) async fn channel_available(&self) -> anyhow::Result { + let management = self.management(); + let token = management.create_token(1, Duration::from_secs(3600), None).await?; + let key = KeyPair::generate()?; + let reply = self.enroll(&token.text, &key, &json!({})).await?; + let identity = Identity::from_enrollment(key, &reply)?; + management.revoke(&identity.identity_id).await?; + management.delete_identity(&identity.identity_id).await?; + management.delete_token(&token.id).await?; + Ok(identity.agent_channel_url.is_some()) + } + + pub(crate) async fn requests(&self, token: Option<&Token>) -> anyhow::Result { + let path = token.map_or_else( + || "/__mock__/requests".to_owned(), + |token| format!("/__mock__/requests?token_id={}", token.id), + ); + let reply = self.send(Method::GET, &path, None, None, None).await?; + expect_status(&reply, 200)?; + Ok(reply.body) + } + + pub(crate) async fn events(&self, identity_id: &str) -> anyhow::Result> { + let reply = self + .send( + Method::GET, + &format!("/__mock__/events?identity_id={identity_id}"), + None, + None, + None, + ) + .await?; + expect_status(&reply, 200)?; + let events = reply.body["events"].as_array().context("mock events are missing")?; + let mut previous = 0; + for event in events { + ensure!( + event["identity_id"] == identity_id, + "mock event belongs to the wrong identity" + ); + let seq = count(event, "seq")?; + ensure!(seq > previous, "mock event sequences are not strictly increasing"); + previous = seq; + } + Ok(events.clone()) + } + + pub(crate) async fn paused_stream_ids(&self) -> anyhow::Result> { + let reply = self.send(Method::GET, "/__mock__/handshake", None, None, None).await?; + expect_status(&reply, 200)?; + reply.body["paused_stream_ids"] + .as_array() + .context("mock paused-stream ids are missing")? + .iter() + .map(|id| id.as_str().context("invalid paused-stream id").map(str::to_owned)) + .collect() + } + + pub(crate) async fn faults(&self, faults: &Value) -> anyhow::Result<()> { + expect_status(&self.control("faults", faults).await?, 200) + } + + pub(crate) async fn advance(&self, secs: i64) -> anyhow::Result<()> { + expect_status(&self.control("time/advance", &json!({ "secs": secs })).await?, 200) + } + + pub(crate) async fn enroll(&self, token: &str, key: &KeyPair, metadata: &Value) -> anyhow::Result { + self.agent( + Method::POST, + "/enroll", + Some(&json!({ "csr": key.csr, "metadata": metadata })), + Some(token), + ) + .await + } + + pub(crate) async fn renew(&self, current: &Identity, new_key: &KeyPair, metadata: &Value) -> anyhow::Result { + let body = serde_json::to_vec(&json!({ "csr": new_key.csr, "metadata": metadata }))?; + let headers = current.key.sign_now(¤t.thumbprint, "renew", Some(&body)); + self.signed_renew(&body, &headers, None).await + } + + pub(crate) async fn signed_renew( + &self, + body: &[u8], + headers: &SignedHeaders, + token: Option<&str>, + ) -> anyhow::Result { + self.send( + Method::POST, + "/api/agent-identity/v1/renew", + Some(body), + token, + Some(headers), + ) + .await + } + + pub(crate) async fn confirm(&self, identity: &Identity) -> anyhow::Result { + let headers = identity.key.sign_now(&identity.thumbprint, "confirm", None); + self.signed_confirm(&headers, None).await + } + + pub(crate) async fn signed_confirm(&self, headers: &SignedHeaders, body: Option<&[u8]>) -> anyhow::Result { + self.send( + Method::POST, + "/api/agent-identity/v1/confirm", + body, + None, + Some(headers), + ) + .await + } + + pub(crate) async fn check_in(&self, identity: &Identity, metadata: &Value) -> anyhow::Result { + let body = serde_json::to_vec(&json!({ "metadata": metadata }))?; + let headers = identity.key.sign_now(&identity.thumbprint, "check-in", Some(&body)); + self.signed_check_in(&body, &headers).await + } + + pub(crate) async fn signed_check_in(&self, body: &[u8], headers: &SignedHeaders) -> anyhow::Result { + self.send( + Method::POST, + "/api/agent-identity/v1/check-in", + Some(body), + None, + Some(headers), + ) + .await + } + + pub(crate) async fn trust_anchor(&self) -> anyhow::Result { + let reply = self.agent(Method::GET, "/trust-anchor", None, None).await?; + expect_status(&reply, 200)?; + Ok(reply.body) + } +} + +pub(crate) struct Token { + pub(crate) text: String, + pub(crate) id: String, +} + +pub(crate) struct Identity { + pub(crate) identity_id: String, + pub(crate) authority_id: String, + pub(crate) thumbprint: String, + pub(crate) certificate_chain: Vec, + pub(crate) agent_channel_url: Option, + pub(crate) config: Value, + pub(crate) key: KeyPair, +} + +impl Identity { + pub(crate) fn from_enrollment(key: KeyPair, reply: &Reply) -> anyhow::Result { + expect_status(reply, 200)?; + let chain = reply.body["certificate_chain"] + .as_array() + .context("missing certificate chain")? + .iter() + .map(|entry| entry.as_str().context("invalid chain entry").map(str::to_owned)) + .collect::>>()?; + ensure!(!chain.is_empty(), "empty certificate chain"); + let config = reply.body["config"].as_object().context("missing enrollment config")?; + ensure!( + config.get("version") == Some(&json!(1)), + "enrollment config.version is not 1" + ); + ensure!( + config.get("revision").and_then(Value::as_u64).is_some(), + "enrollment config.revision is not an unsigned integer" + ); + let agent_channel_url = match config.get("agent_channel_url") { + None => None, + Some(Value::String(url)) => Some(url.clone()), + Some(_) => anyhow::bail!("enrollment config.agent_channel_url must be a string when present"), + }; + Ok(Self { + identity_id: field(&reply.body, "identity_id")?.to_owned(), + authority_id: field(&reply.body, "authority_id")?.to_owned(), + thumbprint: crate::signer::thumbprint(&chain[0])?, + certificate_chain: chain, + agent_channel_url, + config: reply.body["config"].clone(), + key, + }) + } + + pub(crate) fn adopt_certificate(&mut self, chain: &Value, key: KeyPair) -> anyhow::Result<()> { + let chain = chain + .as_array() + .context("missing renewal certificate chain")? + .iter() + .map(|entry| entry.as_str().context("invalid chain entry").map(str::to_owned)) + .collect::>>()?; + ensure!(!chain.is_empty(), "empty renewal certificate chain"); + self.thumbprint = crate::signer::thumbprint(&chain[0])?; + self.certificate_chain = chain; + self.key = key; + Ok(()) + } +} + +pub(crate) fn expect_status(reply: &Reply, status: u16) -> anyhow::Result<()> { + ensure!( + reply.status == status, + "expected HTTP {status}, got HTTP {} ({})", + reply.status, + reply.body["error"].as_str().unwrap_or("no error code") + ); + Ok(()) +} + +pub(crate) fn expect_error(reply: &Reply, status: u16, code: &str) -> anyhow::Result<()> { + expect_status(reply, status)?; + ensure!( + reply.body["error"] == code, + "expected {code}, got {}", + reply.body["error"] + ); + ensure!( + reply.body["message"].as_str().is_some_and(|value| !value.is_empty()), + "missing error message" + ); + let server_time = field(&reply.body, "server_time")?; + ensure!( + server_time.ends_with('Z'), + "error body server_time {server_time:?} does not end in Z" + ); + time::OffsetDateTime::parse(server_time, &Rfc3339).context("invalid server_time")?; + Ok(()) +} + +pub(crate) fn field<'a>(value: &'a Value, key: &str) -> anyhow::Result<&'a str> { + value[key] + .as_str() + .with_context(|| format!("missing string field {key}")) +} + +pub(crate) fn count(value: &Value, key: &str) -> anyhow::Result { + value[key] + .as_u64() + .with_context(|| format!("missing number field {key}")) +} + +pub(crate) fn decoded_certificate(certificate: &str) -> anyhow::Result> { + base64::engine::general_purpose::STANDARD + .decode(certificate) + .context("decode certificate") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_body_server_time_must_end_in_z() -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + let reply = |server_time: &str| Reply { + status: 401, + body: json!({ "error": "clock_skew", "message": "skewed", "server_time": server_time }), + }; + expect_error(&reply("2026-09-24T12:00:00Z"), 401, "clock_skew")?; + ensure!( + expect_error(&reply("2026-09-24T12:00:00+00:00"), 401, "clock_skew").is_err(), + "server_time with a numeric offset was accepted" + ); + Ok(()) + } + + #[test] + fn enrollment_accepts_unknown_fields_but_not_null_known_fields() -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + let vectors: Value = serde_json::from_str(include_str!("../../../docs/agent-identity/test-vectors.json"))?; + let certificate = field(&vectors["keys"][0], "certificate")?; + let mut reply = Reply { + status: 200, + body: json!({ + "authority_id": uuid::Uuid::new_v4(), + "identity_id": uuid::Uuid::new_v4(), + "certificate_chain": [certificate], + "config": { "version": 1, "revision": 1 }, + }), + }; + reply.body["channel_url"] = Value::Null; + reply.body["extra_response_field"] = json!({ "nested": true }); + reply.body["config"]["product_field"] = json!("product-specific"); + let identity = Identity::from_enrollment(KeyPair::generate()?, &reply)?; + ensure!( + identity.agent_channel_url.is_none() && identity.config["product_field"] == "product-specific", + "unknown response fields or omitted config.agent_channel_url were not accepted" + ); + reply.body["config"]["agent_channel_url"] = Value::Null; + ensure!( + Identity::from_enrollment(KeyPair::generate()?, &reply).is_err(), + "explicit null config.agent_channel_url was accepted" + ); + reply.body["config"]["agent_channel_url"] = json!("https://host/mock"); + ensure!( + Identity::from_enrollment(KeyPair::generate()?, &reply)? + .agent_channel_url + .as_deref() + == Some("https://host/mock"), + "unknown top-level channel_url overrode config.agent_channel_url" + ); + for name in ["authority_id", "identity_id", "certificate_chain", "config"] { + let mut invalid = reply.clone(); + invalid.body[name] = Value::Null; + ensure!( + Identity::from_enrollment(KeyPair::generate()?, &invalid).is_err(), + "explicit null {name} was accepted" + ); + } + for name in ["version", "revision", "agent_channel_url"] { + let mut invalid = reply.clone(); + invalid.body["config"][name] = Value::Null; + ensure!( + Identity::from_enrollment(KeyPair::generate()?, &invalid).is_err(), + "explicit null config.{name} was accepted" + ); + } + Ok(()) + } +} diff --git a/crates/agent-identity-conformance/src/dvls.rs b/crates/agent-identity-conformance/src/dvls.rs new file mode 100644 index 000000000..15902ba0a --- /dev/null +++ b/crates/agent-identity-conformance/src/dvls.rs @@ -0,0 +1,1644 @@ +//! DVLS's management API, which the mock replicates. +//! +//! Management APIs are product-specific, so tests reach an authority's only through the module for that authority. +//! Every route, header, field name, paging rule, enum spelling, status code and error body of DVLS's management API lives here, so tests depend only on the operations and records below. +//! DVLS's OpenAPI document for Agent Identity is the reference for all of them; DVLS's Agent Identity intent (`DVLS/Server/server/Common/AgentIdentity/INTENT.md` in the RDM repository), "Management API", holds the decisions behind them. +//! The sessions that authenticate it come from the login and logout of DVLS's public OpenAPI document. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, ensure}; +use http::Method; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer}; +use serde_json::{Value, json}; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; + +use crate::client::{Target, Token}; +use crate::{Context, TargetKind}; + +const BASE_PATH: &str = "/api/v1/agent-identity"; + +const LOGIN_PATH: &str = "/api/v1/login"; + +const LOGOUT_PATH: &str = "/api/v1/logout"; + +/// The request header that carries the management API credential, a session's token. +const CREDENTIAL_HEADER: &str = "tokenId"; + +/// How long DVLS keeps a session after its login, however often it is used. +const SESSION_LIFETIME: Duration = Duration::from_secs(7 * 60); + +/// The largest page of identities a listing returns. +pub(crate) const MAX_PAGE_SIZE: u64 = 100; + +/// The largest page of enrollment tokens a listing returns. +pub(crate) const MAX_TOKEN_PAGE_SIZE: u64 = 500; + +/// Why a test cannot apply to a target at all, as opposed to a fixture the run lacks. +/// +/// DVLS has no mock controls, so a test that needs them never applies to it. +pub(crate) fn unsupported(kind: TargetKind, needs_mock_controls: bool) -> Option<&'static str> { + (kind == TargetKind::Dvls && needs_mock_controls).then_some("DVLS has no mock controls") +} + +/// The permissions that guard the management API's route groups. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Permission { + View, + Manage, + Revoke, + RotateRoot, +} + +impl Permission { + pub(crate) const ALL: [Self; 4] = [Self::View, Self::Manage, Self::Revoke, Self::RotateRoot]; + + /// The name of this permission in a mock key's scope (`.`). + const fn mock_scope(self) -> &'static str { + match self { + Self::View => "view", + Self::Manage => "manage", + Self::Revoke => "revoke", + Self::RotateRoot => "rotate-root", + } + } +} + +/// A session of an application identity without `missing`, or `None` when the run lacks that fixture. +/// +/// The mock derives the key of one holding every other permission from the privileged key, with the same secret. +/// On DVLS, the unprivileged application identity lacks every permission, so it lacks `missing` too. +pub(crate) fn session_lacking(ctx: &Context, missing: Permission) -> Option> { + if !ctx.mock() { + return ctx.unprivileged.clone(); + } + let scope = Permission::ALL + .into_iter() + .filter(|permission| *permission != missing) + .map(Permission::mock_scope) + .collect::>() + .join("+"); + let privileged = &ctx.target.session.credential; + Some(Arc::new(Session::new(AppCredential::new( + format!("{}.{scope}", privileged.key), + privileged.secret.clone(), + )))) +} + +/// An application identity's key and secret, which open management API sessions. +#[derive(Clone)] +pub(crate) struct AppCredential { + key: String, + secret: String, +} + +impl AppCredential { + pub(crate) fn new(key: String, secret: String) -> Self { + Self { key, secret } + } +} + +/// An application identity's management API session on one target. +/// +/// A session's token expires a fixed time after its login, however often it is used, and the login reports no expiry. +/// The session logs in on first use and logs in again before the lifetime it assumes elapses. +/// It assumes DVLS's lifetime until the target refuses a younger token, whose age then bounds the lifetime. +pub(crate) struct Session { + credential: AppCredential, + /// Where token ages are read from. + clock: Clock, + state: tokio::sync::Mutex, +} + +type Clock = Box Instant + Send + Sync>; + +struct SessionState { + login: Option, + lifetime: Duration, +} + +struct Login { + token_id: String, + /// Taken before the login request is sent, so the token is never older than its age from here. + at: Instant, +} + +// UNTESTED: no log line carries a token, key or secret, and no error does beyond the login response a test checks. +// The run's output ends up in CI logs and reports, where a token would open the management API until it expires. +// When changing a message here, report only the target's URL, ages, lifetimes and HTTP statuses. +impl Session { + pub(crate) fn new(credential: AppCredential) -> Self { + Self::with_clock(credential, Box::new(Instant::now)) + } + + fn with_clock(credential: AppCredential, clock: Clock) -> Self { + Self { + credential, + clock, + state: tokio::sync::Mutex::new(SessionState { + login: None, + lifetime: SESSION_LIFETIME, + }), + } + } + + /// The token to send to `target`, after logging in when the session has none, when its token is about to expire, or when `refused` is the token the target just refused. + async fn token_id(&self, target: &Target, refused: Option<&str>) -> anyhow::Result { + let mut state = self.state.lock().await; + if let Some(login) = &state.login { + let age = (self.clock)().saturating_duration_since(login.at); + // A tenth of the lifetime leaves a request time to arrive before its token expires: 42 s of DVLS's 7 minutes, beyond the HTTP client's 8 s timeout. + let fresh = age < state.lifetime - state.lifetime / 10; + if refused == Some(login.token_id.as_str()) { + // The refused token lived no longer than its age. + state.lifetime = state.lifetime.min(age); + eprintln!( + "INFO Logging in to the management API at {} again: it refused a token {} s old", + target.base_url, + age.as_secs() + ); + } else if fresh { + return Ok(login.token_id.clone()); + } else { + eprintln!( + "INFO Logging in to the management API at {} again: its token is {} s old, and a token lasts {} s", + target.base_url, + age.as_secs(), + state.lifetime.as_secs() + ); + } + } + let login = self.log_in(target).await?; + let token_id = login.token_id.clone(); + state.login = Some(login); + Ok(token_id) + } + + async fn log_in(&self, target: &Target) -> anyhow::Result { + #[derive(Deserialize)] + struct LoginResult { + #[serde(rename = "tokenId")] + token_id: Option, + } + let at = (self.clock)(); + let response = target + .http + .post(format!("{}{LOGIN_PATH}", target.base_url)) + .json(&json!({ "appKey": self.credential.key, "appSecret": self.credential.secret })) + .send() + .await + .context("send the management API login")?; + let status = response.status().as_u16(); + ensure!(status == 200, "the management API refused the login with HTTP {status}"); + let bytes = response + .bytes() + .await + .context("read the management API login response")?; + // The decoding error would quote the response, which may hold a token, so it is dropped. + let result = serde_json::from_slice::(&bytes) + .ok() + .context("the management API login response is not a LoginResult")?; + let token_id = result + .token_id + .filter(|token_id| !token_id.is_empty()) + .context("the management API login returned no token")?; + Ok(Login { token_id, at }) + } + + /// Closes the session's token, if it has one; the next request logs in again. + async fn log_out(&self, target: &Target) -> anyhow::Result<()> { + let Some(login) = self.state.lock().await.login.take() else { + return Ok(()); + }; + let response = target + .http + .post(format!("{}{LOGOUT_PATH}", target.base_url)) + .header(CREDENTIAL_HEADER, login.token_id) + .send() + .await + .context("send the management API logout")?; + let status = response.status().as_u16(); + ensure!( + status == 200, + "the management API refused the logout with HTTP {status}" + ); + Ok(()) + } +} + +/// What a management API request presents as its credential. +#[derive(Clone, Copy)] +pub(crate) enum Credential<'a> { + /// No credential. + Absent, + /// This token, as is, whatever the target answers. + TokenId(&'a str), + /// The session's token, logging in again when the session requires. + Session(&'a Session), +} + +/// How the management API refused a request. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Refusal { + /// The request is invalid. + Invalid, + /// The request carries no valid credential. + Unauthenticated, + /// The credential lacks the permission the request needs. + Forbidden, + /// The resource does not exist. + NotFound, + /// The request conflicts with the resource's state. + Conflict, + /// The request is valid but exceeds what the authority can carry out, such as a rotation deadline after the rotation maximum. + Unprocessable, + /// Any other unsuccessful HTTP status. + Other(u16), +} + +impl Refusal { + const fn from_status(status: u16) -> Self { + match status { + 400 => Self::Invalid, + 401 => Self::Unauthenticated, + 403 => Self::Forbidden, + 404 => Self::NotFound, + 409 => Self::Conflict, + 422 => Self::Unprocessable, + _ => Self::Other(status), + } + } +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "the management API refused the request ({self:?})") + } +} + +impl std::error::Error for Refusal {} + +/// The refusal that ended `result`; an error when the request succeeded or failed for another reason. +pub(crate) fn refusal(result: anyhow::Result) -> anyhow::Result { + match result { + Ok(_) => anyhow::bail!("the management API accepted a request it must refuse"), + Err(error) => error.downcast::(), + } +} + +/// The refusal an unsuccessful `status` stands for, once its body is the error body the status documents. +/// +/// A 400 carries a `PublicApiValidationError`, and every other documented refusal a `PublicApiError`. +fn refusal_from(status: u16, body: &[u8]) -> anyhow::Result { + // The document's error bodies are objects whose members are all optional and limited to the schema's own: + // `errors` is an array of field errors, and every other member is a string or null. + fn conforms(value: &Value, members: &[&str]) -> bool { + value.as_object().is_some_and(|object| { + object.iter().all(|(name, member)| { + members.contains(&name.as_str()) + && match name.as_str() { + "errors" => member + .as_array() + .is_some_and(|errors| errors.iter().all(|error| conforms(error, &["field", "message"]))), + _ => member.is_null() || member.is_string(), + } + }) + }) + } + let refusal = Refusal::from_status(status); + let body = || serde_json::from_slice::(body).ok(); + let (documented, schema) = match refusal { + Refusal::Other(_) => return Ok(refusal), + Refusal::Invalid => ( + body().is_some_and(|body| conforms(&body, &["errors"])), + "PublicApiValidationError", + ), + _ => ( + body().is_some_and(|body| conforms(&body, &["message"])), + "PublicApiError", + ), + }; + ensure!(documented, "HTTP {status} body is not a {schema}"); + Ok(refusal) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize)] +pub(crate) enum TokenState { + Active, + Exhausted, + Expired, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TokenRecord { + pub(crate) id: String, + pub(crate) name: String, + #[serde(rename = "useCount")] + pub(crate) used_count: u64, + pub(crate) state: TokenState, + #[serde(deserialize_with = "timestamp")] + pub(crate) created_on: OffsetDateTime, + #[serde(deserialize_with = "timestamp")] + pub(crate) expires_on: OffsetDateTime, + // The members below are only compared, so a record may omit them. + #[serde(default)] + max_uses: Option, + #[serde(default)] + created_by: Option, + #[serde(default)] + friendly_name_format: Option, + #[serde(default)] + config: Option>, +} + +/// What a token creation request asks for. +pub(crate) struct TokenSpec { + pub(crate) name: String, + pub(crate) max_uses: u64, + /// When the token stops enrolling devices; `None` leaves it to the authority's default. + pub(crate) expires_on: Option, + pub(crate) friendly_name_format: Option, + /// Product-specific config entries for the identities the token enrolls. + pub(crate) config: Option>, +} + +impl TokenSpec { + /// A uniquely named token that expires `lifetime` from now. + pub(crate) fn new(max_uses: u64, lifetime: Duration) -> anyhow::Result { + Ok(Self { + name: format!("conformance-{}", uuid::Uuid::new_v4()), + max_uses, + expires_on: Some(OffsetDateTime::now_utc() + time::Duration::try_from(lifetime)?), + friendly_name_format: None, + config: None, + }) + } + + fn body(&self) -> anyhow::Result> { + let mut body = json!({ + "name": self.name, + "maxUses": self.max_uses, + }); + if let Some(instant) = self.expires_on { + body["expiresOn"] = json!(instant.format(&Rfc3339)?); + } + if let Some(format) = &self.friendly_name_format { + body["friendlyNameFormat"] = json!(format); + } + if let Some(config) = &self.config { + body["config"] = json!(config); + } + Ok(serde_json::to_vec(&body)?) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize)] +pub(crate) enum IdentityStatus { + Active, + Grace, + Expired, + Revoked, +} + +impl IdentityStatus { + const fn query_value(self) -> &'static str { + match self { + Self::Active => "Active", + Self::Grace => "Grace", + Self::Expired => "Expired", + Self::Revoked => "Revoked", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize)] +pub(crate) enum CertificateStatus { + Pending, + Current, + Retired, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CertificateRecord { + pub(crate) thumbprint: String, + /// Only compared, so a record may omit it. + #[serde(default)] + serial_number: Option, + #[serde(rename = "validFrom", deserialize_with = "timestamp")] + pub(crate) not_before: OffsetDateTime, + #[serde(rename = "validUntil", deserialize_with = "timestamp")] + pub(crate) not_after: OffsetDateTime, + /// The issuing root's thumbprint. + #[serde(rename = "issuerThumbprint")] + pub(crate) issuer: String, + pub(crate) status: CertificateStatus, +} + +/// The current certificate, as an identity's summary shows it. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub(crate) struct CurrentCertificate { + #[serde(rename = "validUntil", deserialize_with = "timestamp")] + pub(crate) not_after: OffsetDateTime, + /// The issuing root's thumbprint. + #[serde(rename = "issuerThumbprint")] + pub(crate) issuer: String, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IdentityRecord { + pub(crate) id: String, + pub(crate) friendly_name: String, + pub(crate) status: IdentityStatus, + #[serde(rename = "isConnected")] + pub(crate) connected: bool, + /// A summary omits the keys it was not asked for, and the member when none remain. + #[serde(default, deserialize_with = "null_as_default")] + pub(crate) metadata: BTreeMap, + /// Every certificate issued to the identity; only the full view lists them. + #[serde(default)] + pub(crate) certificates: Option>, + #[serde(default)] + pub(crate) certificate: Option, + // The enrollment token members are only compared, so a record may omit them. + #[serde(default)] + enrollment_token_id: Option, + #[serde(default)] + enrollment_token_name: Option, + /// When the identity was created; only the full view shows it. + #[serde(rename = "createdOn", default, deserialize_with = "optional_timestamp")] + pub(crate) created_at: Option, + #[serde(rename = "lastSeenOn", default, deserialize_with = "optional_timestamp")] + pub(crate) last_seen_at: Option, + /// The request-renewal flag; a summary omits it, which reads as `false`. + #[serde(rename = "isRenewalRequested", default, deserialize_with = "null_as_default")] + pub(crate) renewal_requested: bool, + #[serde(rename = "revokedOn", default, deserialize_with = "optional_timestamp")] + pub(crate) revoked_at: Option, +} + +impl IdentityRecord { + /// The identity's certificates; empty in a summary view. + pub(crate) fn certificates(&self) -> &[CertificateRecord] { + self.certificates.as_deref().unwrap_or_default() + } + + pub(crate) fn certificate_with(&self, status: CertificateStatus) -> Option<&CertificateRecord> { + self.certificates() + .iter() + .find(|certificate| certificate.status == status) + } + + pub(crate) fn has_certificate(&self, thumbprint: &str, status: CertificateStatus) -> bool { + self.certificates() + .iter() + .any(|certificate| certificate.thumbprint == thumbprint && certificate.status == status) + } + + /// The thumbprint of the root that issued the current certificate. + pub(crate) fn issuer(&self) -> Option<&str> { + self.certificate.as_ref().map(|certificate| certificate.issuer.as_str()) + } + + /// The end of the current certificate's validity. + pub(crate) fn not_after(&self) -> Option { + self.certificate.as_ref().map(|certificate| certificate.not_after) + } + + pub(crate) fn metadata_value(&self, key: &str) -> Option<&str> { + self.metadata.get(key).map(String::as_str) + } +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub(crate) struct Page { + #[serde(rename = "data")] + pub(crate) items: Vec, + #[serde(rename = "currentPage")] + pub(crate) number: u64, + #[serde(rename = "pageSize")] + pub(crate) size: u64, + #[serde(rename = "totalCount")] + pub(crate) total_count: u64, + #[serde(rename = "totalPage")] + pub(crate) total_pages: u64, +} + +/// Which identities a listing returns, and how. +#[derive(Clone, Default)] +pub(crate) struct IdentityQuery { + full: Option, + metadata: Vec, + status: Option, + token_id: Option, + issuer: Option, + last_seen_to: Option, + last_seen_from: Option, + name_contains: Option, + page: Option<(u64, u64)>, +} + +impl IdentityQuery { + pub(crate) fn enrolled_with(token: &Token) -> Self { + Self { + token_id: Some(token.id.clone()), + ..Self::default() + } + } + + /// Every field of each identity, including all its metadata and certificates. + pub(crate) fn full(mut self) -> Self { + self.full = Some(true); + self + } + + pub(crate) fn summary(mut self) -> Self { + self.full = Some(false); + self + } + + /// Only these metadata keys in a summary. + pub(crate) fn metadata(mut self, keys: &[&str]) -> Self { + self.metadata = keys.iter().map(|key| (*key).to_owned()).collect(); + self + } + + pub(crate) fn status(mut self, status: IdentityStatus) -> Self { + self.status = Some(status); + self + } + + /// Identities whose current certificate the root with this thumbprint issued. + pub(crate) fn issuer(mut self, thumbprint: &str) -> Self { + self.issuer = Some(thumbprint.to_owned()); + self + } + + /// Identities last seen at or before `instant`. + pub(crate) fn last_seen_to(mut self, instant: OffsetDateTime) -> Self { + self.last_seen_to = Some(instant); + self + } + + /// Identities last seen at or after `instant`. + pub(crate) fn last_seen_from(mut self, instant: OffsetDateTime) -> Self { + self.last_seen_from = Some(instant); + self + } + + /// Identities whose friendly name contains `text`, ignoring case. + pub(crate) fn name_contains(mut self, text: &str) -> Self { + self.name_contains = Some(text.to_owned()); + self + } + + /// One page, counted from 1, of at most `size` identities. + pub(crate) fn page(mut self, number: u64, size: u64) -> Self { + self.page = Some((number, size)); + self + } + + fn query_string(&self) -> anyhow::Result { + let (number, size) = self.page.unwrap_or((1, MAX_PAGE_SIZE)); + let mut parameters = vec![("pageNumber", number.to_string()), ("pageSize", size.to_string())]; + if let Some(full) = self.full { + parameters.push(("view", if full { "Full" } else { "Summary" }.to_owned())); + } + for key in &self.metadata { + parameters.push(("metadataKey", key.clone())); + } + if let Some(status) = self.status { + parameters.push(("status", status.query_value().to_owned())); + } + if let Some(id) = &self.token_id { + parameters.push(("enrollmentTokenId", id.clone())); + } + if let Some(issuer) = &self.issuer { + parameters.push(("issuerThumbprint", issuer.clone())); + } + if let Some(instant) = self.last_seen_from { + parameters.push(("lastSeenFrom", instant.format(&Rfc3339)?)); + } + if let Some(instant) = self.last_seen_to { + parameters.push(("lastSeenTo", instant.format(&Rfc3339)?)); + } + if let Some(text) = &self.name_contains { + parameters.push(("search", text.clone())); + } + Ok(parameters + .iter() + .map(|(name, value)| format!("{name}={}", percent_encode(value))) + .collect::>() + .join("&")) + } +} + +fn percent_encode(value: &str) -> String { + value + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() || b"-_.~:,".contains(&byte) { + char::from(byte).to_string() + } else { + format!("%{byte:02X}") + } + }) + .collect() +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize)] +pub(crate) enum RotationPhase { + Idle, + Rotating, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RootSummary { + pub(crate) thumbprint: String, + // The validity members are only compared, so a summary may omit them. + #[serde(default, deserialize_with = "optional_timestamp")] + valid_from: Option, + #[serde(default, deserialize_with = "optional_timestamp")] + valid_until: Option, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RotationStatus { + pub(crate) phase: RotationPhase, + /// Only compared, so a status may omit it. + #[serde(default, deserialize_with = "optional_timestamp")] + started_on: Option, + #[serde(default)] + pub(crate) old_root: Option, + #[serde(default)] + pub(crate) new_root: Option, + #[serde(rename = "deadlineOn", default, deserialize_with = "optional_timestamp")] + pub(crate) deadline: Option, + /// Non-revoked identities that still hold an unexpired current or pending old-root certificate. + #[serde(rename = "oldRootActiveIdentityCount")] + pub(crate) active_identities_on_old_root: u64, +} + +impl RotationStatus { + pub(crate) fn rotating(&self) -> bool { + self.phase == RotationPhase::Rotating + } + + pub(crate) fn new_root(&self) -> anyhow::Result<&str> { + Ok(&self.new_root.as_ref().context("rotation has no new root")?.thumbprint) + } + + pub(crate) fn old_root(&self) -> anyhow::Result<&str> { + Ok(&self.old_root.as_ref().context("rotation has no old root")?.thumbprint) + } + + pub(crate) fn deadline(&self) -> anyhow::Result { + self.deadline.context("rotation has no deadline") + } +} + +/// When a requested rotation must complete. +#[derive(Clone, Copy)] +pub(crate) enum RotationDeadline { + /// The rotation maximum. + Default, + /// An emergency rotation. + Now, + At(OffsetDateTime), +} + +impl RotationDeadline { + fn body(self) -> anyhow::Result> { + let body = match self { + Self::Default => json!({}), + // Any deadline at or before the authority's current time requests an emergency rotation. + // An hour in the past stays at or before it despite clock skew or an advanced mock clock. + Self::Now => json!({ "deadlineOn": (OffsetDateTime::now_utc() - time::Duration::HOUR).format(&Rfc3339)? }), + Self::At(instant) => json!({ "deadlineOn": instant.format(&Rfc3339)? }), + }; + Ok(serde_json::to_vec(&body)?) + } +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Settings { + /// The `authority_id` agents receive at enrollment. + pub(crate) authority_id: String, + /// The administrator's switch for the agent channel. + #[serde(rename = "isAgentChannelEnabled")] + pub(crate) channel_enabled: bool, + /// Whether the authority offers the agent channel: it is enabled and the hosting supports it. + #[serde(rename = "isAgentChannelAvailable")] + pub(crate) channel_available: bool, + /// The lifetime of the certificates issued from now on. + pub(crate) certificate_lifetime_days: u64, + pub(crate) rotation_notifications_per_second: u64, +} + +/// The settings a change sets; the others keep their value. +#[derive(Clone, Default)] +pub(crate) struct SettingsChange { + pub(crate) channel_enabled: Option, + pub(crate) certificate_lifetime_days: Option, + pub(crate) rotation_notifications_per_second: Option, +} + +impl SettingsChange { + fn body(&self) -> anyhow::Result> { + let mut body = json!({}); + if let Some(enabled) = self.channel_enabled { + body["isAgentChannelEnabled"] = json!(enabled); + } + if let Some(days) = self.certificate_lifetime_days { + body["certificateLifetimeDays"] = json!(days); + } + if let Some(rate) = self.rotation_notifications_per_second { + body["rotationNotificationsPerSecond"] = json!(rate); + } + Ok(serde_json::to_vec(&body)?) + } +} + +/// One management API request, with the permission it requires. +pub(crate) struct Route { + method: Method, + path: String, + body: Option>, + success: u16, + pub(crate) permission: Permission, +} + +impl std::fmt::Display for Route { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {BASE_PATH}{}", self.method, self.path) + } +} + +/// Every management API route, acting on one token and one identity. +/// +/// The token creation route asks for `create`, the rename route for `rename`, the rotation route for an emergency rotation, and the settings route turns the agent channel off. +/// When `inert` is set, the rotation request is malformed and the settings change is empty, so that neither can change the target even if authorization is broken. +pub(crate) fn routes( + token_id: &str, + identity_id: &str, + create: &TokenSpec, + rename: &str, + inert: bool, +) -> anyhow::Result> { + let token = format!("/enrollment-token/{token_id}"); + let identity = format!("/{identity_id}"); + let (rotation, settings) = if inert { + (b"{".to_vec(), SettingsChange::default().body()?) + } else { + let settings = SettingsChange { + channel_enabled: Some(false), + ..SettingsChange::default() + }; + (RotationDeadline::Now.body()?, settings.body()?) + }; + let route = |method: Method, path: String, body: Option>, success: u16, permission: Permission| Route { + method, + path, + body, + success, + permission, + }; + Ok(vec![ + route( + Method::POST, + "/enrollment-token".to_owned(), + Some(create.body()?), + 201, + Permission::Manage, + ), + route(Method::GET, "/enrollment-token".to_owned(), None, 200, Permission::View), + route(Method::GET, token.clone(), None, 200, Permission::View), + route(Method::DELETE, token, None, 204, Permission::Manage), + route( + Method::GET, + format!("?enrollmentTokenId={token_id}"), + None, + 200, + Permission::View, + ), + route(Method::GET, identity.clone(), None, 200, Permission::View), + route( + Method::PATCH, + identity.clone(), + Some(serde_json::to_vec(&json!({ "friendlyName": rename }))?), + 200, + Permission::Manage, + ), + route(Method::DELETE, identity.clone(), None, 204, Permission::Manage), + route( + Method::POST, + format!("{identity}/revoke"), + None, + 204, + Permission::Revoke, + ), + route( + Method::POST, + format!("{identity}/request-renewal"), + None, + 202, + Permission::Manage, + ), + route(Method::GET, "/rotation".to_owned(), None, 200, Permission::View), + route( + Method::POST, + "/rotation".to_owned(), + Some(rotation), + 202, + Permission::RotateRoot, + ), + route(Method::GET, "/settings".to_owned(), None, 200, Permission::View), + route( + Method::PATCH, + "/settings".to_owned(), + Some(settings), + 200, + Permission::Manage, + ), + ]) +} + +/// The management API of one target, called with one credential. +pub(crate) struct Management<'a> { + target: &'a Target, + credential: Credential<'a>, +} + +impl<'a> Management<'a> { + pub(crate) fn new(target: &'a Target, credential: Credential<'a>) -> Self { + Self { target, credential } + } + + /// Sends a request and returns its JSON body, or the refusal when the status is not `success`. + /// + /// When the target refuses a session's token, the session logs in again and the request is sent once more. + /// That first refusal must still have the error body every refusal of a token has. + async fn request(&self, method: Method, path: &str, body: Option<&[u8]>, success: u16) -> anyhow::Result { + let (status, bytes) = match self.credential { + Credential::Absent => { + let (status, bytes) = self.send(&method, path, body, None).await?; + // DVLS answers a request without a token with a 401 and no body, unlike the `PublicApiError` its document gives every 401. + if status == 401 && bytes.is_empty() { + return Err(Refusal::Unauthenticated.into()); + } + (status, bytes) + } + Credential::TokenId(token_id) => self.send(&method, path, body, Some(token_id)).await?, + Credential::Session(session) => { + let token_id = session.token_id(self.target, None).await?; + let (status, bytes) = self.send(&method, path, body, Some(&token_id)).await?; + if status == 401 { + let token_id = session.token_id(self.target, Some(&token_id)).await?; + let retried = self.send(&method, path, body, Some(&token_id)).await?; + refusal_from(status, &bytes) + .with_context(|| format!("{method} {path} refusal before logging in again"))?; + retried + } else { + (status, bytes) + } + } + }; + if status != success { + ensure!( + !(200..300).contains(&status), + "{method} {path} succeeded with HTTP {status} instead of {success}" + ); + let refusal = refusal_from(status, &bytes).with_context(|| format!("{method} {path} refusal"))?; + return Err(refusal.into()); + } + if bytes.is_empty() { + return Ok(Value::Null); + } + serde_json::from_slice(&bytes).with_context(|| format!("decode the {method} {path} response")) + } + + /// Sends a request and returns its status and body. + async fn send( + &self, + method: &Method, + path: &str, + body: Option<&[u8]>, + token_id: Option<&str>, + ) -> anyhow::Result<(u16, Vec)> { + let mut request = self + .target + .http + .request(method.clone(), format!("{}{BASE_PATH}{path}", self.target.base_url)); + if let Some(token_id) = token_id { + request = request.header(CREDENTIAL_HEADER, token_id); + } + if let Some(body) = body { + request = request + .header(http::header::CONTENT_TYPE, "application/json") + .body(body.to_vec()); + } + let response = request.send().await.with_context(|| format!("send {method} {path}"))?; + let status = response.status().as_u16(); + let bytes = response + .bytes() + .await + .with_context(|| format!("read the {method} {path} response"))?; + Ok((status, bytes.to_vec())) + } + + /// Closes the session this management API is called with, if any. + pub(crate) async fn log_out(&self) -> anyhow::Result<()> { + match self.credential { + Credential::Session(session) => session.log_out(self.target).await, + Credential::Absent | Credential::TokenId(_) => Ok(()), + } + } + + async fn fetch( + &self, + method: Method, + path: &str, + body: Option<&[u8]>, + success: u16, + ) -> anyhow::Result { + let value = self.request(method.clone(), path, body, success).await?; + serde_json::from_value(value).with_context(|| format!("unexpected {method} {path} response")) + } + + /// Sends `route` and succeeds only when the management API performs it. + pub(crate) async fn call(&self, route: &Route) -> anyhow::Result<()> { + self.request(route.method.clone(), &route.path, route.body.as_deref(), route.success) + .await + .map(drop) + } + + pub(crate) async fn create_token( + &self, + max_uses: u64, + lifetime: Duration, + friendly_name_format: Option<&str>, + ) -> anyhow::Result { + let mut spec = TokenSpec::new(max_uses, lifetime)?; + spec.friendly_name_format = friendly_name_format.map(str::to_owned); + self.create_token_from(&spec).await + } + + pub(crate) async fn create_token_from(&self, spec: &TokenSpec) -> anyhow::Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Created { + token: String, + enrollment_token: CreatedRecord, + } + #[derive(Deserialize)] + struct CreatedRecord { + id: String, + } + let created: Created = self + .fetch(Method::POST, "/enrollment-token", Some(&spec.body()?), 201) + .await?; + Ok(Token { + text: created.token, + id: created.enrollment_token.id, + }) + } + + pub(crate) async fn token(&self, id: &str) -> anyhow::Result { + self.fetch(Method::GET, &format!("/enrollment-token/{id}"), None, 200) + .await + } + + /// Whether the token's record holds its secret text anywhere, which only token creation may return. + pub(crate) async fn token_discloses_secret(&self, token: &Token) -> anyhow::Result { + let (_, secret) = token.text.rsplit_once('.').context("token has no secret")?; + let record = self + .request(Method::GET, &format!("/enrollment-token/{}", token.id), None, 200) + .await?; + Ok(contains_text(&record, secret)) + } + + /// One page, counted from 1, of at most `size` tokens. + pub(crate) async fn tokens(&self, number: u64, size: u64) -> anyhow::Result> { + self.fetch( + Method::GET, + &format!("/enrollment-token?pageNumber={number}&pageSize={size}"), + None, + 200, + ) + .await + } + + /// Every token, walking all pages. + pub(crate) async fn all_tokens(&self) -> anyhow::Result> { + let mut tokens = Vec::new(); + for number in 1.. { + let page = self.tokens(number, MAX_TOKEN_PAGE_SIZE).await?; + tokens.extend(page.items); + if number >= page.total_pages { + break; + } + } + Ok(tokens) + } + + pub(crate) async fn delete_token(&self, id: &str) -> anyhow::Result<()> { + self.request(Method::DELETE, &format!("/enrollment-token/{id}"), None, 204) + .await + .map(drop) + } + + /// The full view of one identity. + pub(crate) async fn identity(&self, id: &str) -> anyhow::Result { + let path = format!("/{id}"); + let value = self.request(Method::GET, &path, None, 200).await?; + for name in [ + "certificates", + "enrollmentTokenId", + "enrollmentTokenName", + "createdOn", + "isRenewalRequested", + ] { + ensure!( + value.get(name).is_some_and(|field| !field.is_null()), + "full identity record omits {name}" + ); + } + serde_json::from_value(value).with_context(|| format!("unexpected GET {path} response")) + } + + /// The full view of one identity, or `None` when it does not exist. + pub(crate) async fn find_identity(&self, id: &str) -> anyhow::Result> { + match self.identity(id).await { + Ok(record) => Ok(Some(record)), + Err(error) => match error.downcast::()? { + Refusal::NotFound => Ok(None), + other => Err(other.into()), + }, + } + } + + pub(crate) async fn identities(&self, query: &IdentityQuery) -> anyhow::Result> { + self.fetch(Method::GET, &format!("?{}", query.query_string()?), None, 200) + .await + } + + /// Every identity `query` selects, walking all pages and checking that they agree with the reported total. + pub(crate) async fn all_identities(&self, query: &IdentityQuery) -> anyhow::Result> { + let mut identities: Vec = Vec::new(); + for number in 1.. { + let page = self.identities(&query.clone().page(number, MAX_PAGE_SIZE)).await?; + for identity in page.items { + ensure!( + identities.iter().all(|seen| seen.id != identity.id), + "identity listing repeated an identity ID" + ); + identities.push(identity); + } + if number >= page.total_pages { + ensure!( + identities.len() as u64 == page.total_count, + "identity listing count disagrees with all pages" + ); + break; + } + } + Ok(identities) + } + + /// How many identities `token` enrolled that still exist. + pub(crate) async fn identity_count(&self, token: &Token) -> anyhow::Result { + Ok(self.identities(&IdentityQuery::enrolled_with(token)).await?.total_count) + } + + pub(crate) async fn rename(&self, id: &str, friendly_name: &str) -> anyhow::Result { + let body = serde_json::to_vec(&json!({ "friendlyName": friendly_name }))?; + self.fetch(Method::PATCH, &format!("/{id}"), Some(&body), 200).await + } + + pub(crate) async fn revoke(&self, id: &str) -> anyhow::Result<()> { + self.request(Method::POST, &format!("/{id}/revoke"), None, 204) + .await + .map(drop) + } + + pub(crate) async fn delete_identity(&self, id: &str) -> anyhow::Result<()> { + self.request(Method::DELETE, &format!("/{id}"), None, 204) + .await + .map(drop) + } + + pub(crate) async fn request_renewal(&self, id: &str) -> anyhow::Result<()> { + self.request(Method::POST, &format!("/{id}/request-renewal"), None, 202) + .await + .map(drop) + } + + pub(crate) async fn rotation(&self) -> anyhow::Result { + self.fetch(Method::GET, "/rotation", None, 200).await + } + + pub(crate) async fn rotate(&self, deadline: RotationDeadline) -> anyhow::Result { + self.fetch(Method::POST, "/rotation", Some(&deadline.body()?), 202) + .await + } + + pub(crate) async fn settings(&self) -> anyhow::Result { + self.fetch(Method::GET, "/settings", None, 200).await + } + + /// Applies `change` and returns the resulting settings. + pub(crate) async fn change_settings(&self, change: &SettingsChange) -> anyhow::Result { + self.fetch(Method::PATCH, "/settings", Some(&change.body()?), 200).await + } +} + +fn timestamp<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + OffsetDateTime::parse(&text, &Rfc3339).map_err(serde::de::Error::custom) +} + +fn optional_timestamp<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + Option::::deserialize(deserializer)? + .map(|text| OffsetDateTime::parse(&text, &Rfc3339).map_err(serde::de::Error::custom)) + .transpose() +} + +/// Reads a nullable member, so that `null` means the same as an absent member. +fn null_as_default<'de, D: Deserializer<'de>, T: Deserialize<'de> + Default>(deserializer: D) -> Result { + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +fn contains_text(value: &Value, text: &str) -> bool { + match value { + Value::String(value) => value.contains(text), + Value::Array(items) => items.iter().any(|item| contains_text(item, text)), + Value::Object(members) => members + .iter() + .any(|(name, member)| name.contains(text) || contains_text(member, text)), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity() -> Value { + json!({ + "id": "identity-id", + "friendlyName": "test", + "status": "Active", + "isConnected": false, + "metadata": { "hostname": "test" }, + "certificates": [], + "enrollmentTokenId": "token-id", + "enrollmentTokenName": "test", + "createdOn": "2026-09-25T12:00:00Z", + "isRenewalRequested": false, + "certificate": { "issuerThumbprint": "root-id", "validUntil": "2026-12-24T12:00:00Z" } + }) + } + + #[test] + fn records_ignore_unknown_fields_and_treat_null_optional_fields_as_absent() -> anyhow::Result<()> { + // oracle: mutant an identity record whose `metadata` member is null fails to parse + let record: IdentityRecord = serde_json::from_value(identity())?; + let mut noisy = identity(); + noisy["request_id"] = json!("different"); + noisy["certificate"]["request_id"] = json!("different"); + noisy["lastSeenOn"] = Value::Null; + noisy["revokedOn"] = Value::Null; + ensure!( + serde_json::from_value::(noisy)? == record, + "unknown or null optional identity fields changed the record" + ); + let mut no_certificate = identity(); + no_certificate + .as_object_mut() + .context("identity fixture must be an object")? + .remove("certificate"); + let mut null_certificate = no_certificate.clone(); + null_certificate["certificate"] = Value::Null; + ensure!( + serde_json::from_value::(no_certificate)? + == serde_json::from_value::(null_certificate)?, + "null optional identity certificate was not treated as absent" + ); + // A summary omits the metadata and the renewal flag; the document declares both nullable. + let mut summary = identity(); + let members = summary.as_object_mut().context("identity fixture must be an object")?; + members.remove("metadata"); + members.remove("isRenewalRequested"); + let mut null_summary = identity(); + null_summary["metadata"] = Value::Null; + null_summary["isRenewalRequested"] = Value::Null; + let summary: IdentityRecord = serde_json::from_value(summary)?; + ensure!( + summary.metadata.is_empty() + && !summary.renewal_requested + && serde_json::from_value::(null_summary)? == summary, + "null metadata or renewal flag was not treated as absent" + ); + let rotation: RotationStatus = serde_json::from_value(json!({ + "phase": "Idle", + "oldRootActiveIdentityCount": 0, + "request_id": "different", + "startedOn": null, + "deadlineOn": null, + "oldRoot": null, + "newRoot": null + }))?; + ensure!( + !rotation.rotating() && rotation.deadline.is_none() && rotation.old_root.is_none(), + "null optional rotation fields were not treated as absent" + ); + Ok(()) + } + + #[test] + fn secret_disclosure_is_found_in_member_names_and_values_but_not_in_redactions() { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens + let secret = "s3cr3t-text"; + let record = |extra: Value| { + let mut record = json!({ "id": "token-id", "name": "test", "hint": "s3c…" }); + record["nested"] = extra; + record + }; + assert!(contains_text( + &record(json!([{ "value": "dvaet1.bag.s3cr3t-text" }])), + secret + )); + assert!(contains_text( + &record(json!({ "dvaet1.bag.s3cr3t-text": null })), + secret + )); + assert!(!contains_text(&record(json!({ "token": "dvaet1.bag.****" })), secret)); + } + + #[test] + fn records_reject_null_or_malformed_required_fields() { + // oracle: mutant an identity record whose `isConnected` member is null parses as not connected + for (name, value) in [ + ("status", Value::Null), + ("status", json!("active")), + ("isConnected", Value::Null), + ("createdOn", json!("not a timestamp")), + ("lastSeenOn", json!(123)), + ] { + let mut record = identity(); + record[name] = value.clone(); + assert!( + serde_json::from_value::(record).is_err(), + "{name} = {value} was accepted" + ); + } + assert!( + serde_json::from_value::(json!({ "phase": null, "oldRootActiveIdentityCount": 0 })) + .is_err(), + "explicit null rotation phase was accepted" + ); + } + + #[test] + fn refusals_require_the_documented_error_body() { + // oracle: mutant an error body member other than `errors` may hold any JSON type + // The document: 400 carries a PublicApiValidationError, and 401, 403, 404, 409 and 422 a PublicApiError. + // Every member of each schema is optional, its messages and fields are nullable, and it admits no other member. + for (status, body, expected) in [ + ( + 400, + r#"{"errors":[{"field":"maxUses","message":"too small"}]}"#, + Some(Refusal::Invalid), + ), + ( + 400, + r#"{"errors":[{"field":null,"message":"malformed"}]}"#, + Some(Refusal::Invalid), + ), + ( + 400, + r#"{"errors":[{"field":"expiresOn","message":null}]}"#, + Some(Refusal::Invalid), + ), + (400, r#"{"errors":[{}]}"#, Some(Refusal::Invalid)), + (400, "{}", Some(Refusal::Invalid)), + (400, r#"{"message":"invalid"}"#, None), + (400, r#"{"errors":null}"#, None), + (400, r#"{"errors":[{"field":1,"message":"wrong type"}]}"#, None), + (400, r#"{"errors":["not an object"]}"#, None), + (409, r#"{"message":"rotation in progress"}"#, Some(Refusal::Conflict)), + (409, r#"{"message":null}"#, Some(Refusal::Conflict)), + (404, "{}", Some(Refusal::NotFound)), + (422, r#"{"message":"deadline too late"}"#, Some(Refusal::Unprocessable)), + (409, r#"{"message":42}"#, None), + (409, r#"["not an object"]"#, None), + (403, r#"{"errors":[{"field":null,"message":"forbidden"}]}"#, None), + (401, "", None), + ] { + assert_eq!( + refusal_from(status, body.as_bytes()).ok(), + expected, + "HTTP {status} with {body:?}" + ); + } + } + + #[test] + fn identity_query_repeats_metadata_keys() -> anyhow::Result<()> { + // oracle: mutant the metadata keys are sent as one comma-separated `metadataKey` parameter + // The document's example for `metadataKey`: "?metadataKey=hostname&metadataKey=os_name". + let query = IdentityQuery::default() + .metadata(&["hostname", "os_name"]) + .query_string()?; + assert!(query.contains("metadataKey=hostname&metadataKey=os_name"), "{query}"); + Ok(()) + } + + /// A token from a fresh login that is refused too will not be accepted by waiting, so the request is not sent a third time. + #[tokio::test] + async fn session_refused_after_logging_in_again_is_unauthenticated() -> anyhow::Result<()> { + // oracle: mutant a request refused again after logging in again is retried once more + let stub = Stub::default(); + stub.refuse_every_token(); + let target = stub.start().await?; + assert_eq!(refusal(target.management().settings().await)?, Refusal::Unauthenticated); + assert_eq!( + stub.counts(), + Counts { + logins: 2, + reads: 2, + refused: 2 + } + ); + Ok(()) + } + + /// DVLS's login reports no expiry, so a refused token's age is the lifetime a session observes, and it logs in again before a token reaches that age. + #[tokio::test] + async fn session_logs_in_again_before_reaching_the_age_of_a_refused_token() -> anyhow::Result<()> { + // oracle: mutant a refused token's age never shortens the session lifetime + let stub = Stub::default(); + let target = stub.start().await?; + let management = target.management(); + management.settings().await?; + stub.advance(Duration::from_secs(60)); + stub.expire_tokens(); + management.settings().await?; + management.settings().await?; + assert_eq!( + stub.counts(), + Counts { + logins: 2, + reads: 4, + refused: 1 + }, + "a fresh token was not reused" + ); + // The stub never expires this token, so only the session's own lifetime makes it log in again. + stub.advance(Duration::from_secs(60) - Duration::from_millis(1)); + management.settings().await?; + assert_eq!( + stub.counts(), + Counts { + logins: 3, + reads: 5, + refused: 1 + }, + "a token about to reach the age of the refused one was not replaced" + ); + Ok(()) + } + + /// DVLS answers a request without a token with a 401 and no body. + #[tokio::test] + async fn request_without_a_token_is_unauthenticated_by_a_bodyless_401() -> anyhow::Result<()> { + // oracle: mutant a body-less 401 to a request without a token is rejected as malformed, not read as unauthenticated + let stub = Stub::default(); + let target = stub.start().await?; + assert_eq!( + refusal(target.management_as(Credential::Absent).settings().await)?, + Refusal::Unauthenticated + ); + Ok(()) + } + + /// DVLS answers a request with a token it refuses with a `PublicApiError`, so a body-less 401 to one is malformed even when the session's retry then succeeds. + #[tokio::test] + async fn bodyless_401_to_a_session_fails_even_when_the_retry_succeeds() -> anyhow::Result<()> { + // oracle: mutant the refusal before logging in again is not checked once the retry succeeds + let stub = Stub::default(); + stub.refuse_without_body(); + let target = stub.start().await?; + let management = target.management(); + management.settings().await?; + stub.expire_tokens(); + assert!( + management.settings().await.is_err(), + "a body-less 401 to a request with a token was accepted" + ); + assert_eq!( + stub.counts(), + Counts { + logins: 2, + reads: 3, + refused: 1 + }, + "the session did not log in again and retry once" + ); + Ok(()) + } + + /// A login response is a credential, so an error about a malformed one must not repeat it. + #[tokio::test] + async fn malformed_login_response_error_omits_the_token() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + let stub = Stub::default(); + stub.answer_logins_with_a_bare_token(); + let target = stub.start().await?; + let error = target + .management() + .settings() + .await + .err() + .map(|error| format!("{error:#}")); + assert!( + error.as_ref().is_some_and(|error| !error.contains("stub-token-1")), + "{error:?}" + ); + Ok(()) + } + + /// A loopback stand-in for DVLS's login and settings read, which opens a token per login and accepts only open tokens. + #[derive(Clone, Default)] + struct Stub(Arc>); + + #[derive(Default)] + struct StubState { + counts: Counts, + open: Vec, + refuse_every_token: bool, + refuse_without_body: bool, + /// Whether a login returns its token as a bare JSON string instead of a `LoginResult`. + bare_login_token: bool, + /// How far the test moved the clock of the session under test. + elapsed: Duration, + } + + #[derive(Clone, Copy, Default, PartialEq, Eq, Debug)] + struct Counts { + logins: u32, + /// Settings reads with a token, accepted or refused. + reads: u32, + refused: u32, + } + + impl Stub { + const KEY: &str = "stub-key"; + const SECRET: &str = "stub-secret"; + + /// Serves on a loopback port, and returns a target whose session logs in with the stub's key and secret, on a clock that moves only with `advance`. + async fn start(&self) -> anyhow::Result { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let base_url = format!("http://{}", listener.local_addr()?); + let stub = self.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + tokio::spawn(stub.clone().serve(stream)); + } + }); + let stub = self.clone(); + let start = Instant::now(); + let clock = Box::new(move || start + stub.state().elapsed); + Ok(Target { + base_url, + session: Arc::new(Session::with_clock( + AppCredential::new(Self::KEY.to_owned(), Self::SECRET.to_owned()), + clock, + )), + ca_path: None, + authority_id: None, + http: reqwest::Client::builder().no_proxy().build()?, + }) + } + + /// Moves the session's clock forward. + fn advance(&self, by: Duration) { + self.state().elapsed += by; + } + + /// Closes every open token, as their expiry would. + fn expire_tokens(&self) { + self.state().open.clear(); + } + + fn refuse_every_token(&self) { + self.state().refuse_every_token = true; + } + + fn refuse_without_body(&self) { + self.state().refuse_without_body = true; + } + + fn answer_logins_with_a_bare_token(&self) { + self.state().bare_login_token = true; + } + + fn counts(&self) -> Counts { + self.state().counts + } + + fn state(&self) -> std::sync::MutexGuard<'_, StubState> { + self.0.lock().expect("stub state lock poisoned") + } + + /// Answers one HTTP/1.1 request, then closes the connection. + async fn serve(self, mut stream: tokio::net::TcpStream) -> anyhow::Result<()> { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let mut request = Vec::new(); + let mut chunk = [0; 4096]; + let head_length = loop { + if let Some(end) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break end + 4; + } + let read = stream.read(&mut chunk).await?; + ensure!(read != 0, "the connection closed before the request head"); + request.extend_from_slice(&chunk[..read]); + }; + let head = String::from_utf8(request[..head_length].to_vec())?; + let header = |name: &str| { + head.lines().find_map(|line| { + let (header, value) = line.split_once(':')?; + header.eq_ignore_ascii_case(name).then(|| value.trim().to_owned()) + }) + }; + let body_length: usize = header("content-length").map_or(Ok(0), |length| length.parse())?; + while request.len() < head_length + body_length { + let read = stream.read(&mut chunk).await?; + ensure!(read != 0, "the connection closed before the request body"); + request.extend_from_slice(&chunk[..read]); + } + let request_line = head.lines().next().unwrap_or_default(); + let (status, body) = self.respond( + request_line, + header(CREDENTIAL_HEADER), + &request[head_length..head_length + body_length], + ); + let response = format!( + "HTTP/1.1 {status} Stub\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await?; + stream.shutdown().await?; + Ok(()) + } + + fn respond(&self, request_line: &str, token_id: Option, body: &[u8]) -> (u16, String) { + let mut state = self.state(); + if request_line.starts_with(&format!("POST {LOGIN_PATH} ")) { + let parameters = serde_json::from_slice::(body).unwrap_or_default(); + if parameters != json!({ "appKey": Self::KEY, "appSecret": Self::SECRET }) { + return (401, json!({ "message": "wrong key or secret" }).to_string()); + } + state.counts.logins += 1; + let token_id = format!("stub-token-{}", state.counts.logins); + state.open.push(token_id.clone()); + if state.bare_login_token { + return (200, json!(token_id).to_string()); + } + return (200, json!({ "tokenId": token_id }).to_string()); + } + if request_line.starts_with(&format!("GET {BASE_PATH}/settings ")) { + let Some(token_id) = token_id else { + return (401, String::new()); + }; + state.counts.reads += 1; + if !state.open.contains(&token_id) || state.refuse_every_token { + state.counts.refused += 1; + if state.refuse_without_body { + return (401, String::new()); + } + return (401, json!({ "message": "Missing or invalid token" }).to_string()); + } + let settings = json!({ + "authorityId": "stub-authority", + "isAgentChannelEnabled": true, + "isAgentChannelAvailable": true, + "certificateLifetimeDays": 90, + "rotationNotificationsPerSecond": 10 + }); + return (200, settings.to_string()); + } + (404, json!({ "message": "unknown route" }).to_string()) + } + } +} diff --git a/crates/agent-identity-conformance/src/main.rs b/crates/agent-identity-conformance/src/main.rs new file mode 100644 index 000000000..d500859a4 --- /dev/null +++ b/crates/agent-identity-conformance/src/main.rs @@ -0,0 +1,1070 @@ +#![expect( + clippy::print_stdout, + clippy::print_stderr, + reason = "the conformance runner reports every test and its summary to the CLI" +)] + +mod agent; +mod authority; +mod channel; +mod client; +mod dvls; +mod protocol; +mod signer; +#[cfg(windows)] +mod system_probe; +#[cfg(windows)] +mod windows; + +use std::env; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, ensure}; + +use crate::authority::{Authority, AuthoritySpec, Need, Profile}; +use crate::client::Target; +use crate::dvls::AppCredential; + +type TestFuture = Pin> + Send>>; +type TestFn = fn(Context) -> TestFuture; +const RUN_DEADLINE: Duration = Duration::from_secs(14 * 60 + 30); +/// How long a test may run unless [`run_test`] gives it another timeout. +const TEST_TIMEOUT: Duration = Duration::from_secs(90); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Protocol, + Agent, +} + +struct Test { + name: &'static str, + kind: Kind, + mock_only: bool, + needs_second: bool, + /// The [`Need::bit`] of each need, beyond rotation's, that the test has of its authority. + needs: u8, + /// Whether the test starts the agent, which then creates device keys. + starts_agent: bool, + run: TestFn, +} + +impl Test { + const fn protocol(name: &'static str, mock_only: bool, run: TestFn) -> Self { + Self { + name, + kind: Kind::Protocol, + mock_only, + needs_second: false, + needs: 0, + starts_agent: false, + run, + } + } + + const fn agent(name: &'static str, mock_only: bool, run: TestFn) -> Self { + Self { + name, + kind: Kind::Agent, + mock_only, + needs_second: false, + needs: 0, + starts_agent: true, + run, + } + } + + const fn second(mut self) -> Self { + self.needs_second = true; + self + } + + const fn need(mut self, need: Need) -> Self { + self.needs |= need.bit(); + self + } + + const fn channel(self) -> Self { + self.need(Need::Channel) + } + + const fn without_channel(self) -> Self { + self.need(Need::NoChannel) + } + + /// Marks an agent test that only runs the agent's CLI. + const fn cli_only(mut self) -> Self { + self.starts_agent = false; + self + } + + /// What the test needs of its authority; every rotation test needs a disposable one. + fn needs(&self) -> Vec { + let rotation = self.name.starts_with("p_rotation_") || self.name.starts_with("a_rotation_"); + Need::ALL + .into_iter() + .filter(|need| self.needs & need.bit() != 0 || rotation && *need == Need::Disposable) + .collect() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TargetKind { + Mock, + Dvls, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum KeyBackend { + WindowsSoftwareProvider, + File, +} + +impl KeyBackend { + const fn name(self) -> &'static str { + match self { + Self::WindowsSoftwareProvider => "WindowsSoftwareProvider", + Self::File => "File", + } + } +} + +/// What one test runs with: the authority it runs on, the second one when it needs two, and the run's settings. +#[derive(Clone)] +struct Context { + /// The name of the test. + test: &'static str, + target: Target, + second: Option, + target_kind: TargetKind, + disposable_dvls_target: bool, + dvls_rotation_window_secs: u64, + leaf_lifetime_secs: u64, + expect_channel: bool, + channel_available: bool, + key_name_prefix: String, + agent_version: Option, + /// On DVLS, a session of an application identity without any Agent Identity permission. + unprivileged: Option>, + agent_bin: PathBuf, + key_backend: KeyBackend, + work_dir: PathBuf, +} + +impl Context { + fn mock(&self) -> bool { + self.target_kind == TargetKind::Mock + } + + fn leaf_lifetime(&self) -> Duration { + Duration::from_secs(self.leaf_lifetime_secs) + } +} + +/// The authorities and settings of a run. +struct Suite { + /// The authority that `--base-url` names, then the one `--second-base-url` names, then each `--authority` in order. + authorities: Vec, + target_kind: TargetKind, + key_name_prefix: String, + agent_version: Option, + /// On DVLS, a session of an application identity without any Agent Identity permission on the first authority. + unprivileged: Option>, + agent_bin: PathBuf, + key_backend: KeyBackend, + work_dir: PathBuf, +} + +impl Suite { + fn mock(&self) -> bool { + self.target_kind == TargetKind::Mock + } + + /// The context of `test` on the authorities at `primary` and `second`. + fn context(&self, test: &'static str, primary: usize, second: Option) -> Context { + let authority = &self.authorities[primary]; + Context { + test, + target: authority.target.clone(), + second: second.map(|index| self.authorities[index].clone()), + target_kind: self.target_kind, + disposable_dvls_target: authority.profile.disposable, + dvls_rotation_window_secs: authority.rotation_window_secs, + leaf_lifetime_secs: authority.profile.leaf_lifetime_secs, + expect_channel: authority.profile.expect_channel, + channel_available: authority.channel_available, + key_name_prefix: self.key_name_prefix.clone(), + agent_version: self.agent_version.clone(), + // UNTESTED: a test that runs on another authority than the first gets no unprivileged session. + // `--unprivileged-app-key` names an application identity of the first authority, which another authority refuses at login, so its permission tests would fail instead of skipping. + // When changing which authority a test runs on, keep this session on the first one. + unprivileged: if primary == 0 { self.unprivileged.clone() } else { None }, + agent_bin: self.agent_bin.clone(), + key_backend: self.key_backend, + work_dir: self.work_dir.clone(), + } + } +} + +struct Options { + target: Option, + base_url: Option, + app_key: Option, + app_secret: Option, + authority_id: Option, + agent_bin: Option, + ca_path: Option, + second_base_url: Option, + second_app_key: Option, + second_app_secret: Option, + second_authority_id: Option, + second_ca_path: Option, + /// Each `--authority` value, as given. + authorities: Vec, + unprivileged_app_key: Option, + unprivileged_app_secret: Option, + key_backend: KeyBackend, + work_dir: PathBuf, + filter: String, + list: bool, + disposable_dvls_target: bool, + /// `None` derives the window from `leaf_lifetime_secs`. + dvls_rotation_window_secs: Option, + leaf_lifetime_secs: u64, + expect_channel: bool, + allow_incomplete: bool, + agent_version: Option, +} + +impl Default for Options { + fn default() -> Self { + let profile = Profile::default(); + Self { + target: None, + base_url: None, + app_key: None, + app_secret: None, + authority_id: None, + agent_bin: None, + ca_path: None, + second_base_url: None, + second_app_key: None, + second_app_secret: None, + second_authority_id: None, + second_ca_path: None, + authorities: Vec::new(), + unprivileged_app_key: None, + unprivileged_app_secret: None, + key_backend: if cfg!(windows) { + KeyBackend::WindowsSoftwareProvider + } else { + KeyBackend::File + }, + work_dir: PathBuf::from("target").join("agent-identity-conformance"), + filter: String::new(), + list: false, + disposable_dvls_target: profile.disposable, + dvls_rotation_window_secs: profile.rotation_window_secs, + leaf_lifetime_secs: profile.leaf_lifetime_secs, + expect_channel: profile.expect_channel, + allow_incomplete: false, + agent_version: None, + } + } +} + +impl Options { + /// Reads the runner's arguments, without the program name. + fn parse(args: impl IntoIterator) -> anyhow::Result { + let mut options = Self::default(); + let mut args = args.into_iter().zip(1..); + while let Some((flag, position)) = args.next() { + if flag == "--list" { + options.list = true; + continue; + } + if flag == "--disposable-dvls-target" { + options.disposable_dvls_target = true; + continue; + } + if flag == "--allow-incomplete" { + options.allow_incomplete = true; + continue; + } + if flag == "--no-expect-channel" { + options.expect_channel = false; + continue; + } + // An argument that is not a known flag may be a misplaced secret, so errors name only known flags. + let mut value = || { + args.next() + .map(|(value, _)| value) + .with_context(|| format!("missing value for {flag}")) + }; + match flag.as_str() { + "--target" => { + options.target = Some(match value()?.as_str() { + "mock" => TargetKind::Mock, + "dvls" => TargetKind::Dvls, + _ => anyhow::bail!("unknown target"), + }); + } + "--base-url" => options.base_url = Some(value()?), + "--app-key" => options.app_key = Some(value()?), + "--app-secret" => options.app_secret = Some(value()?), + "--authority-id" => options.authority_id = Some(value()?), + "--agent-bin" => options.agent_bin = Some(value()?.into()), + "--agent-version" => { + let version = value()?; + ensure!(!version.is_empty(), "--agent-version must not be empty"); + options.agent_version = Some(version); + } + "--extra-trusted-root" => options.ca_path = Some(value()?.into()), + "--second-base-url" => options.second_base_url = Some(value()?), + "--second-app-key" => options.second_app_key = Some(value()?), + "--second-app-secret" => options.second_app_secret = Some(value()?), + "--second-authority-id" => options.second_authority_id = Some(value()?), + "--second-extra-trusted-root" => options.second_ca_path = Some(value()?.into()), + "--unprivileged-app-key" => options.unprivileged_app_key = Some(value()?), + "--unprivileged-app-secret" => options.unprivileged_app_secret = Some(value()?), + "--expect-channel" => { + options.expect_channel = value()?.parse().context("--expect-channel must be true or false")? + } + "--leaf-lifetime-secs" => { + options.leaf_lifetime_secs = value()? + .parse() + .context("--leaf-lifetime-secs must be a positive integer")?; + ensure!(options.leaf_lifetime_secs > 0, "--leaf-lifetime-secs must be positive"); + } + "--dvls-rotation-window-secs" => { + let window: u64 = value()? + .parse() + .context("--dvls-rotation-window-secs must be a positive integer")?; + ensure!(window > 0, "--dvls-rotation-window-secs must be positive"); + options.dvls_rotation_window_secs = Some(window); + } + "--key-backend" => { + options.key_backend = match value()?.as_str() { + "windows-software-provider" => KeyBackend::WindowsSoftwareProvider, + "file" => KeyBackend::File, + _ => anyhow::bail!("unknown key backend"), + }; + } + "--work-dir" => options.work_dir = value()?.into(), + "--filter" => options.filter = value()?, + "--authority" => options.authorities.push(value()?), + _ => anyhow::bail!("argument {position} is not a known flag"), + } + } + Ok(options) + } + + /// The profile that the run's flags declare for the authorities `--base-url` and `--second-base-url` name. + fn profile(&self) -> Profile { + Profile { + leaf_lifetime_secs: self.leaf_lifetime_secs, + expect_channel: self.expect_channel, + disposable: self.disposable_dvls_target, + rotation_window_secs: self.dvls_rotation_window_secs, + } + } + + async fn suite(self) -> anyhow::Result { + let kind = self.target.context("--target is required")?; + let profile = self.profile(); + let key_name_prefix = format!("conformance-{}", uuid::Uuid::new_v4().simple()); + #[cfg(windows)] + if self.key_backend == KeyBackend::File { + use std::os::windows::ffi::OsStrExt as _; + + let work_dir = std::path::absolute(&self.work_dir).context("resolve --work-dir")?; + let length = work_dir.as_os_str().encode_wide().count(); + let longest = longest_file_backend_work_dir(&key_name_prefix); + ensure!( + length <= longest, + "--work-dir must be at most {longest} characters long with the file backend, so that its key paths fit the Windows path limit, but {} is {length}", + work_dir.display() + ); + } + let credential = app_credential( + self.app_key, + self.app_secret, + ["--app-key", "--app-secret", "AGENT_IDENTITY_CONFORMANCE_APP_SECRET"], + )? + .context("--app-key is required")?; + let target = Target::new( + self.base_url.context("--base-url is required")?, + credential, + self.ca_path.as_deref(), + self.authority_id.as_deref(), + )?; + let mut targets = vec![("--base-url".to_owned(), target, profile)]; + let second_credential = app_credential( + self.second_app_key, + self.second_app_secret, + [ + "--second-app-key", + "--second-app-secret", + "AGENT_IDENTITY_CONFORMANCE_SECOND_APP_SECRET", + ], + )?; + match (self.second_base_url, second_credential) { + (Some(url), Some(credential)) => targets.push(( + "--second-base-url".to_owned(), + Target::new( + url, + credential, + self.second_ca_path.as_deref(), + self.second_authority_id.as_deref(), + )?, + profile, + )), + (None, None) => ensure!( + self.second_authority_id.is_none(), + "--second-authority-id requires a second target" + ), + _ => anyhow::bail!("--second-base-url and --second-app-key must be supplied together"), + } + for (spec, position) in self.authorities.iter().zip(1..) { + let spec = AuthoritySpec::parse(spec, &|name| env::var(name).ok())?; + let target = Target::new( + spec.base_url, + AppCredential::new(spec.app_key, spec.app_secret), + spec.ca_path.as_deref(), + spec.authority_id.as_deref(), + )?; + targets.push((format!("--authority {position}"), target, spec.profile)); + } + let unprivileged = app_credential( + self.unprivileged_app_key, + self.unprivileged_app_secret, + [ + "--unprivileged-app-key", + "--unprivileged-app-secret", + "AGENT_IDENTITY_CONFORMANCE_UNPRIVILEGED_APP_SECRET", + ], + )?; + let rotation_windows = targets + .iter() + .map(|(_, _, profile)| profile.rotation_window_secs()) + .collect::>>()?; + let mut authorities = Vec::with_capacity(targets.len()); + for ((name, target, profile), rotation_window_secs) in targets.into_iter().zip(rotation_windows) { + if kind == TargetKind::Mock { + target.reset().await?; + } + let channel_available = profile.expect_channel || target.channel_available().await?; + // A test leaf lifetime can be a whole number of days too, so only the settings show whether the leaves follow them. + let lifetime_from_settings = kind == TargetKind::Dvls && profile.disposable && { + let days = target.management().settings().await?.certificate_lifetime_days; + days.checked_mul(authority::DAY_SECS) == Some(profile.leaf_lifetime_secs) + }; + authorities.push(Authority { + name, + target, + profile, + channel_available, + lifetime_from_settings, + rotation_window_secs, + }); + } + tokio::fs::create_dir_all(&self.work_dir).await?; + Ok(Suite { + authorities, + target_kind: kind, + key_name_prefix, + agent_version: self.agent_version, + unprivileged: unprivileged.map(|credential| Arc::new(dvls::Session::new(credential))), + agent_bin: self.agent_bin.context("--agent-bin is required")?, + key_backend: self.key_backend, + work_dir: self.work_dir, + }) + } +} + +/// The application identity a key flag names, with its secret from the secret flag or else from the environment variable. +/// +/// A key without a secret, or a secret flag without a key, is a usage error. +fn app_credential( + key: Option, + secret: Option, + [key_flag, secret_flag, secret_variable]: [&str; 3], +) -> anyhow::Result> { + let Some(key) = key else { + ensure!(secret.is_none(), "{secret_flag} requires {key_flag}"); + return Ok(None); + }; + let secret = secret + .or_else(|| env::var(secret_variable).ok().filter(|secret| !secret.is_empty())) + .with_context(|| format!("{key_flag} requires {secret_flag} or {secret_variable}"))?; + Ok(Some(AppCredential::new(key, secret))) +} + +/// The longest path that Windows file APIs accept without long-path support, in UTF-16 code units: `MAX_PATH` without its terminating NUL. +#[cfg(any(windows, test))] +const WINDOWS_MAX_PATH_LEN: usize = 259; + +/// The longest work directory, in UTF-16 code units, under which the file backend's key files stay within [`WINDOWS_MAX_PATH_LEN`]. +/// +/// A key file is `\\identity\keys\`, for a key name `_`. +/// The device-key intent ("Key name") keeps every file name the file backend derives from a key name within 255 characters for the longest key name, of 193 characters, so a derived file name is at most 62 characters longer than its key name. +#[cfg(any(windows, test))] +fn longest_file_backend_work_dir(key_name_prefix: &str) -> usize { + let file_name = key_name_prefix.len() + 1 /* _ */ + uuid::fmt::Hyphenated::LENGTH + 62 /* derived suffix */; + let below_work_dir = 1 /* \ */ + + agent::DATA_DIR_PREFIX.len() + + agent::DATA_DIR_RANDOM_LEN + + 1 /* \ */ + + 8 /* identity */ + + 1 /* \ */ + + 4 /* keys */ + + 1 /* \ */ + + file_name; + WINDOWS_MAX_PATH_LEN.saturating_sub(below_work_dir) +} + +#[tokio::main] +async fn main() { + rustls::crypto::ring::default_provider() + .install_default() + .expect("install rustls ring crypto provider"); + if let Err(error) = run().await { + eprintln!("conformance runner error: {error:#}"); + std::process::exit(1); + } +} + +async fn run() -> anyhow::Result<()> { + #[cfg(windows)] + if env::args_os() + .nth(1) + .is_some_and(|flag| flag == std::ffi::OsStr::new("--system-probe")) + { + let mut args = env::args_os().skip(2); + let request = PathBuf::from(args.next().context("--system-probe requires a request path")?); + let result = PathBuf::from(args.next().context("--system-probe requires a result path")?); + ensure!(args.next().is_none(), "--system-probe accepts exactly two paths"); + return system_probe::run(&request, &result); + } + let options = Options::parse(env::args().skip(1))?; + let selected: Vec<&Test> = TESTS + .iter() + .filter(|test| test.name.contains(&options.filter)) + .collect(); + if options.list { + for test in selected { + println!("{}", test.name); + } + return Ok(()); + } + ensure!(!selected.is_empty(), "no tests match the filter"); + let allow_incomplete = options.allow_incomplete; + let start = Instant::now(); + let deadline = start + RUN_DEADLINE; + let suite = tokio::time::timeout(RUN_DEADLINE, options.suite()) + .await + .context("conformance setup exceeded the 14-minute-30-second deadline")??; + println!("KEY NAME PREFIX {}", suite.key_name_prefix); + // The Windows software provider keeps the agent's keys as machine keys, which only an elevated administrator can create. + #[cfg(windows)] + let machine_keys_denied = + suite.key_backend == KeyBackend::WindowsSoftwareProvider && !windows::running_as_elevated_administrator()?; + #[cfg(not(windows))] + let machine_keys_denied = false; + let mut totals = [0u32; 4]; + let mut protocol_elapsed = Duration::ZERO; + let mut agent_elapsed = Duration::ZERO; + let mut non_pass = Vec::new(); + let mut deadline_exceeded = false; + for test in selected { + let test_start = Instant::now(); + let outcome = if test.name == "a_unavailable_key_backend_stops_identity" && cfg!(windows) { + Outcome::NotApplicable("Windows software provider is available on Windows") + } else if test.name == "a_key_non_exportable" && !cfg!(windows) { + Outcome::NotApplicable("Windows software provider test") + } else if test.name == "a_file_backend_permissions" && !cfg!(unix) { + Outcome::NotApplicable("Unix file-backend test") + } else if let Some(reason) = dvls::unsupported(suite.target_kind, test.mock_only) { + Outcome::NotApplicable(reason) + } else { + match authority::pick(&suite.authorities, &test.needs(), test.needs_second, suite.mock()) { + Err(reason) => Outcome::Skip(reason), + Ok(_) + if test.name == "a_key_non_exportable" + && suite.key_backend != KeyBackend::WindowsSoftwareProvider => + { + Outcome::Skip("requires --key-backend windows-software-provider") + } + Ok(_) if test.name == "a_file_backend_permissions" && suite.key_backend != KeyBackend::File => { + Outcome::Skip("requires --key-backend file") + } + // UNTESTED: an unelevated run with the Windows software provider reports each test that starts the agent as SKIP. + // The agent fails to create its machine keys there, so a FAIL would blame the agent for the runner's missing privilege. + // CI runs elevated, so check this by hand from an unelevated shell when changing which tests start the agent. + Ok(_) if test.starts_agent && machine_keys_denied => { + Outcome::Skip("the Windows software provider's machine keys require an elevated administrator") + } + Ok((primary, second)) => { + if 0 < primary || second.is_some_and(|second| second != 1) { + let names = [Some(primary), second] + .into_iter() + .flatten() + .map(|index| suite.authorities[index].name.as_str()) + .collect::>(); + println!("AUTHORITY {} {}", test.name, names.join(" ")); + } + let (outcome, exceeded) = run_test(test, suite.context(test.name, primary, second), deadline).await; + deadline_exceeded = exceeded; + outcome + } + } + }; + let elapsed = test_start.elapsed(); + match test.kind { + Kind::Protocol => protocol_elapsed += elapsed, + Kind::Agent => agent_elapsed += elapsed, + } + let (index, label, reason) = match outcome { + Outcome::Pass => (0, "PASS", None), + Outcome::Fail(reason) => (1, "FAIL", Some(reason)), + Outcome::Skip(reason) => (2, "SKIP", Some(reason.to_owned())), + Outcome::NotApplicable(reason) => (3, "N/A", Some(reason.to_owned())), + }; + totals[index] += 1; + if let Some(reason) = reason { + println!("{label} {} ({} ms): {reason}", test.name, elapsed.as_millis()); + non_pass.push((label, test.name, reason)); + } else { + println!("{label} {} ({} ms)", test.name, elapsed.as_millis()); + } + if deadline_exceeded { + break; + } + } + println!( + "SUMMARY PASS {} FAIL {} SKIP {} N/A {} ({} ms)", + totals[0], + totals[1], + totals[2], + totals[3], + start.elapsed().as_millis() + ); + println!( + "SUMMARY DURATION protocol={} ms agent={} ms", + protocol_elapsed.as_millis(), + agent_elapsed.as_millis() + ); + for (label, name, reason) in non_pass { + println!("NON-PASS {label} {name}: {reason}"); + } + // Logging out only closes sessions that would expire anyway, so its failure never fails the run. + if tokio::time::timeout(Duration::from_secs(5), log_out(&suite)) + .await + .is_err() + { + eprintln!("WARN Logging out of the management API timed out"); + } + if totals[1] != 0 || (totals[2] != 0 && !allow_incomplete) { + anyhow::bail!( + "conformance failed: {} failures, {} incomplete skips{}", + totals[1], + totals[2], + if allow_incomplete { " (allowed)" } else { "" } + ); + } + Ok(()) +} + +/// Runs `test` with `context` within its timeout, which the global `deadline` shortens; the flag reports whether the deadline ended it. +async fn run_test(test: &Test, context: Context, deadline: Instant) -> (Outcome, bool) { + let result = async { + if context.mock() { + context.target.reset().await?; + if let Some(second) = &context.second { + second.target.reset().await?; + } + } + (test.run)(context.clone()).await + }; + let timeout = if test.name == "p_channel_no_hello_timeout" { + Duration::from_secs(20) + } else if test.name == "a_key_non_exportable" && cfg!(windows) { + Duration::from_secs(240) + } else if test.name == "a_rotation_migrates_on_schedule" { + Duration::from_secs(120) + } else if !context.mock() && test.name.starts_with("a_rotation_") { + Duration::from_secs(context.dvls_rotation_window_secs.saturating_add(60)) + } else if !context.mock() && test.name.starts_with("p_rotation_") { + Duration::from_secs(context.dvls_rotation_window_secs.saturating_add(30)) + } else { + TEST_TIMEOUT + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + let allowed = timeout.min(remaining); + match tokio::time::timeout(allowed, result).await { + Ok(Ok(())) => (Outcome::Pass, false), + Ok(Err(error)) => { + let outcome = if let Some(inapplicable) = error.downcast_ref::() { + Outcome::NotApplicable(inapplicable.0) + } else if let Some(incomplete) = error.downcast_ref::() { + Outcome::Skip(incomplete.0) + } else { + Outcome::Fail(format!("{error:#}")) + }; + (outcome, false) + } + Err(_) if allowed == remaining => ( + Outcome::Fail("global 14-minute-30-second deadline exceeded".to_owned()), + true, + ), + Err(_) => (Outcome::Fail("timed out".to_owned()), false), + } +} + +/// Closes the run's management API sessions on every authority. +async fn log_out(suite: &Suite) { + let unprivileged = suite.unprivileged.as_deref().and_then(|session| { + suite + .authorities + .first() + .map(|first| first.target.management_as(dvls::Credential::Session(session))) + }); + let managements = suite + .authorities + .iter() + .map(|authority| authority.target.management()) + .chain(unprivileged); + for management in managements { + if let Err(error) = management.log_out().await { + eprintln!("WARN Failed to log out of the management API: {error:#}"); + } + } +} + +enum Outcome { + Pass, + Fail(String), + Skip(&'static str), + NotApplicable(&'static str), +} + +#[derive(Debug)] +pub(crate) struct NotApplicable(pub(crate) &'static str); + +impl std::fmt::Display for NotApplicable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} + +impl std::error::Error for NotApplicable {} + +#[derive(Debug)] +pub(crate) struct Incomplete(pub(crate) &'static str); + +impl std::fmt::Display for Incomplete { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } +} + +impl std::error::Error for Incomplete {} + +macro_rules! p { + ($name:ident) => { + Test::protocol(stringify!($name), false, |context| Box::pin(protocol::$name(context))) + }; + ($name:ident, mock_only) => { + Test::protocol(stringify!($name), true, |context| Box::pin(protocol::$name(context))) + }; +} + +macro_rules! a { + ($name:ident) => { + Test::agent(stringify!($name), false, |context| Box::pin(agent::$name(context))) + }; + ($name:ident, mock_only) => { + Test::agent(stringify!($name), true, |context| Box::pin(agent::$name(context))) + }; +} + +const TESTS: &[Test] = &[ + p!(p_trust_anchor_lists_roots), + p!(p_channel_url_expectation), + p!(p_reset_rebases_root_validity, mock_only), + p!(p_token_n_uses_consumed_then_exhausted), + p!(p_token_concurrent_enrollment_respects_max_uses), + p!(p_concurrent_same_key_enroll_replays_winner), + p!(p_concurrent_same_key_renew_replays_winner), + p!(p_failed_enrollment_consumes_nothing), + p!(p_token_errors_distinct), + p!(p_enroll_malformed_authorization_rejected), + p!(p_token_expired, mock_only), + p!(p_enroll_idempotent_same_key), + p!(p_token_config_contributes_to_effective_config), + p!(p_deleted_token_replays_own_key_only), + p!(p_enroll_revoked_same_key_rejected), + p!(p_enroll_certificate_key_reuse_rules), + p!(p_renew_certificate_key_reuse_rules), + p!(p_metadata_limits), + p!(p_friendly_name_format), + p!(p_friendly_name_white_space_and_truncation), + p!(p_duplicate_json_members_rejected), + p!(p_json_body_nesting_limit), + p!(p_invalid_unicode_json_strings_rejected), + p!(p_noncanonical_base64_csr_rejected), + p!(p_token_secret_must_match_exactly), + p!(p_compressed_point_csr_rejected), + p!(p_identity_cannot_impersonate_another), + p!(p_channel_hello_cannot_impersonate_another).channel(), + p!(p_renew_digest_integrity), + p!(p_signature_parameter_order_accepted_renew), + p!(p_signature_parameter_order_accepted_connect).channel(), + p!(p_renew_rejects_sha384_csr), + p!(p_same_tag_covered_components_rejected), + p!(p_same_tag_connect_components_rejected).channel(), + p!(p_extra_signature_parameters_rejected_renew), + p!(p_extra_signature_parameters_rejected_connect).channel(), + p!(p_signature_parser_wire_negatives), + p!(p_signature_noncanonical_base64_rejected), + p!(p_signature_repeated_raw_items_rejected), + p!(p_connect_signature_noncanonical_or_repeated_rejected).channel(), + p!(p_replay_nonce_rejected), + p!(p_replay_window_rejected), + p!(p_connect_signature_replayed_to_renew), + p!(p_renew_signature_rejected_on_connect).channel(), + p!(p_channel_duplicate_signature_metadata_rejected).channel(), + p!(p_confirm_cross_tag_signatures_rejected), + p!(p_confirm_signature_rejected_on_connect).channel(), + p!(p_check_in_current_and_pending), + p!(p_check_in_cross_tag_signatures_rejected), + p!(p_check_in_signature_rejected_on_connect).channel(), + p!(p_renew_happy_path), + p!(p_confirm_promotes_and_is_idempotent), + p!(p_confirm_retired_certificate_rejected), + p!(p_confirm_expired_pending_certificate_rejected, mock_only), + p!(p_confirm_nonempty_body_rejected), + p!(p_confirm_faults_do_not_duplicate_promotion, mock_only), + p!(p_renew_idempotent_lost_response, mock_only), + p!(p_mock_enroll_retry_barrier, mock_only), + p!(p_renew_second_pending_retires_first), + p!(p_retired_pending_certificate_cannot_connect).channel(), + p!(p_renew_within_grace, mock_only), + p!(p_renew_beyond_grace, mock_only), + p!(p_revocation_blocks_renew_and_connect), + p!(p_revocation_blocks_connect).channel(), + p!(p_delete_only_when_revoked_then_unknown), + p!(p_deleted_certificate_cannot_connect).channel(), + p!(p_channel_hello_updates_metadata_and_connected).channel(), + p!(p_channel_agent_message_refreshes_last_seen).channel(), + p!(p_handshake_barrier_holds_authentication, mock_only).channel(), + p!(p_channel_proof_replay_fails).channel(), + p!(p_channel_revoked_before_hello).channel(), + p!(p_channel_deleted_before_hello).channel(), + p!(p_channel_no_hello_timeout).channel(), + p!(p_channel_hello_invalid_metadata_rejected).channel(), + p!(p_channel_undecodable_first_message_rejected).channel(), + p!(p_channel_undecodable_message_after_welcome_rejected).channel(), + p!(p_channel_clock_skew_refusal_carries_server_time).channel(), + p!(p_channel_server_time_corrects_skew, mock_only).channel(), + p!(p_channel_unavailable_no_channel_url, mock_only), + p!(p_mock_malformed_https_channel_urls, mock_only), + p!(p_config_revision_monotonic_changes, mock_only).channel(), + p!(p_config_hello_reconciles_stale_revision) + .channel() + .need(Need::ConfigChange), + p!(p_config_update_pushes_higher_revision, mock_only).channel(), + p!(p_request_renewal_connected_and_on_connect).channel(), + p!(p_request_renewal_flag_cleared_only_on_confirm), + p!(p_request_renewal_flag_cleared_after_new_cert).channel(), + p!(p_reconnect_push_and_handoff, mock_only).channel(), + p!(p_revocation_closes_stream).channel(), + p!(p_stream_closes_at_not_after).channel().need(Need::ShortLeaves), + p!(p_expired_certificate_refused_at_stream_open, mock_only).channel(), + p!(p_challenged_stream_expires_on_advance, mock_only).channel(), + p!(p_pending_cert_auth_does_not_promote).channel(), + p!(p_confirm_reconnects_and_closes_retired_stream_at_grace, mock_only).channel(), + p!(p_channel_retired_before_hello_completes_then_reconnects, mock_only).channel(), + p!(p_rotation_publishes_both_roots_and_issues_from_new), + p!(p_rotation_status_counts), + p!(p_rotation_conflict_409), + p!(p_rotation_completes_early_when_last_identity_migrates, mock_only), + p!(p_rotation_completes_immediately_without_old_root_identities, mock_only), + p!(p_rotation_deadline_bound, mock_only), + p!(p_rotation_disposable_deadline_above_own_max_rejected), + p!(p_rotation_disposable_completes_before_future_deadline), + p!(p_rotation_grace_renewal_after_early_completion, mock_only), + p!(p_rotation_completes_early_at_certificate_expiry_via_freeze, mock_only), + p!(p_rotation_push_rate_survives_deadline, mock_only).channel(), + p!(p_rotation_deadline_removes_old_root), + p!(p_rotation_old_root_cert_renewable_after_deadline, mock_only), + p!(p_rotation_emergency_deadline), + p!(p_rotation_request_renewal_pushed).channel(), + p!(p_rotation_pending_only_old_root_receives_push, mock_only).channel(), + p!(p_listing_pagination_stable_during_concurrent_enrollment), + p!(p_listing_views_filters_and_bounds), + p!(p_identity_statuses, mock_only), + p!(p_management_tokens_crud_and_states), + p!(p_management_requires_auth), + p!(p_management_permission_denied), + p!(p_rotation_start_requires_auth), + p!(p_token_expiry_default_and_maximum), + p!(p_rotation_without_root_conflicts, mock_only), + p!(p_root_key_unavailable_conflicts, mock_only), + p!(p_settings_read), + p!(p_settings_change_bounds).need(Need::Disposable), + p!(p_settings_certificate_lifetime_applies_to_new_certificates) + .need(Need::Disposable) + .need(Need::SettingsLifetime), + p!(p_settings_channel_switch_withdraws_and_restores_channel_url) + .channel() + .need(Need::Disposable), + p!(p_error_body_shape), + p!(p_authentication_error_precedes_invalid_body), + p!(p_oversized_body_precedes_authentication), + p!(p_mock_fault_response_not_processed, mock_only), + a!(a_pending_file_enroll_success), + a!(a_bad_pending_files_do_not_reject_valid_tokens), + a!(a_undecryptable_pending_file_does_not_reject_token, mock_only), + a!(a_untrusted_tls_server_keeps_pending_token, mock_only).second(), + a!(a_enroll_lost_response_retried_across_restart, mock_only), + a!(a_two_pending_tokens_same_authority_last_wins, mock_only), + a!( + a_two_pending_tokens_different_authorities_progress_independently, + mock_only + ) + .second(), + a!(a_enroll_revoked_before_response_is_permanent, mock_only), + a!(a_pending_file_deleted_on_permanent_error), + a!(a_pending_file_kept_on_transient_error), + a!(a_unavailable_key_backend_stops_identity), + a!(a_renew_transient_failures_retry_without_state_change, mock_only).channel(), + a!(a_check_in_transient_failures_retry_without_state_change, mock_only), + a!(a_token_never_logged), + a!(a_same_token_no_enrollment), + a!(a_same_token_after_token_exhausted_no_enrollment), + a!(a_same_token_after_token_invalid_no_enrollment), + a!(a_same_token_after_token_malformed_no_enrollment), + a!(a_same_token_after_identity_revoked_no_enrollment, mock_only), + a!(a_same_token_rejected_identity_no_enrollment).channel(), + a!(a_different_token_replaces_identity), + a!(a_rejected_identity_replaced).channel(), + a!(a_cli_identity_enroll_writes_pending_file).cli_only(), + a!(a_cli_identity_enroll_stdin).cli_only(), + a!(a_renewal_happy_path).channel().need(Need::LongLeaves), + a!(a_renewal_lost_response_retried, mock_only).channel(), + a!(a_renewal_lost_confirm_response_retried, mock_only).channel(), + a!(a_confirm_barrier_blocks_channelless_traffic, mock_only), + a!(a_confirm_expired_pending_renews_with_fresh_key, mock_only), + a!(a_confirm_expired_pending_beyond_grace_rejects, mock_only), + a!(a_expired_current_renews_in_grace_or_rejects_beyond, mock_only).channel(), + a!(a_channel_connected_and_metadata).channel(), + a!(a_make_before_break_on_renewal).channel(), + a!(a_old_key_deleted_after_confirm_without_channel, mock_only).channel(), + a!(a_request_renewal_connected).channel().need(Need::LongLeaves), + a!(a_request_renewal_while_offline).channel().need(Need::LongLeaves), + a!(a_request_renewal_without_channel).without_channel(), + a!(a_reconnect_make_before_break, mock_only).channel(), + a!(a_revocation_stops_agent).channel(), + a!(a_identity_unknown_recorded, mock_only).channel(), + a!(a_signed_rejection_without_live_stream, mock_only), + a!(a_no_channel_when_absent, mock_only), + a!(a_no_channel_url_with_query, mock_only), + a!(a_no_channel_url_with_fragment, mock_only), + a!(a_config_update_disables_channel, mock_only).channel(), + a!(a_check_in_enables_channel, mock_only), + a!(a_check_in_recovers_broken_channel, mock_only), + a!(a_config_unknown_fields_and_hello_revision, mock_only).channel(), + a!(a_config_unsupported_version_keeps_stored_config, mock_only), + a!(a_config_unsupported_version_on_enroll_keeps_pending, mock_only), + a!(a_multi_authority).second().channel().need(Need::LongLeaves), + a!(a_key_non_exportable), + a!(a_file_backend_permissions), + a!(a_rotation_migrates_connected_agent).channel().need(Need::LongLeaves), + a!(a_rotation_migrates_through_check_in, mock_only), + a!(a_rotation_migrates_on_schedule, mock_only), +]; + +#[cfg(test)] +mod tests { + use super::*; + + /// A usage error lands in CI logs, so a credential given in a form the runner does not take, such as `--app-secret=` or a stray argument, must not appear in it. + #[test] + fn usage_errors_never_echo_a_credential() { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + let secret = "synthetic-credential"; + let mut cases: Vec> = [ + "--app-key", + "--app-secret", + "--second-app-key", + "--second-app-secret", + "--unprivileged-app-key", + "--unprivileged-app-secret", + "--authority", + ] + .into_iter() + .flat_map(|flag| { + let joined = format!("{flag}={secret}"); + [vec![joined.clone()], vec![joined, "--list".to_owned()]] + }) + .collect(); + cases.push(vec!["--app-key".to_owned(), "key".to_owned(), secret.to_owned()]); + cases.push(vec![ + "--app-key".to_owned(), + "key".to_owned(), + secret.to_owned(), + "--list".to_owned(), + ]); + for args in cases { + let error = Options::parse(args.clone()).err().map(|error| format!("{error:#}")); + assert!( + error.as_ref().is_some_and(|error| !error.contains(secret)), + "{args:?} gave {error:?}" + ); + } + } + + /// The rotation window that `args` select. + fn rotation_window(args: &[&str]) -> anyhow::Result { + Options::parse(args.iter().map(|arg| (*arg).to_owned()))? + .profile() + .rotation_window_secs() + } + + /// A DVLS under test issues leaves of a few seconds, so the default window must stay below a short leaf lifetime, while an explicit window wins in either flag order. + #[test] + fn rotation_window_follows_a_short_leaf_lifetime_unless_explicit() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + assert_eq!(rotation_window(&[])?, 60); + assert_eq!(rotation_window(&["--leaf-lifetime-secs", "20"])?, 15); + assert_eq!(rotation_window(&["--leaf-lifetime-secs", "2"])?, 1); + assert_eq!( + rotation_window(&["--dvls-rotation-window-secs", "18", "--leaf-lifetime-secs", "20"])?, + 18 + ); + Ok(()) + } + + /// 75% of a one-second leaf lifetime holds no whole second, and rounding the window up to one second would ask DVLS for a deadline it refuses. + #[test] + fn one_second_leaf_lifetime_requires_an_explicit_rotation_window() { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + assert!(rotation_window(&["--leaf-lifetime-secs", "1"]).is_err()); + } + + /// A key file path is `\identity-agent-XXXXXX\identity\keys\`: 22, 15 and at most 143 characters below the work directory for a run's 81-character key names, which leaves 79 of the 259. + #[test] + fn file_backend_work_dir_leaves_room_for_the_longest_key_file() { + // oracle: intent crates/device-key/INTENT.md §Key name + // oracle: spec Microsoft "Naming Files, Paths, and Namespaces" §Maximum Path Length Limitation + assert_eq!( + longest_file_backend_work_dir(&format!("conformance-{}", "0".repeat(32))), + 79 + ); + } +} diff --git a/crates/agent-identity-conformance/src/protocol.rs b/crates/agent-identity-conformance/src/protocol.rs new file mode 100644 index 000000000..f9da71f9e --- /dev/null +++ b/crates/agent-identity-conformance/src/protocol.rs @@ -0,0 +1,7588 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, ensure}; +use base64::Engine as _; +use der::asn1::ObjectIdentifier; +use der::oid::AssociatedOid as _; +use der::{Decode as _, Encode as _}; +use http::Method; +use p256::ecdsa::signature::{Signer as _, Verifier as _}; +use p256::ecdsa::{Signature, VerifyingKey}; +use p256::pkcs8::{DecodePublicKey as _, EncodePublicKey as _}; +use p384::ecdsa::{Signature as Signature384, VerifyingKey as VerifyingKey384}; +use rand::RngExt as _; +use serde_json::{Value, json}; +use sha2::Digest as _; +use time::format_description::well_known::Rfc3339; +use tokio::sync::Barrier; +use tonic::Code; +use x509_cert::Certificate; +use x509_cert::ext::pkix::name::GeneralName; +use x509_cert::ext::pkix::{ + AuthorityKeyIdentifier, BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAltName, SubjectKeyIdentifier, +}; + +use crate::client::{Identity, Reply, Target, Token, count, decoded_certificate, expect_error, expect_status, field}; +use crate::dvls::{ + self, CertificateStatus, Credential, IdentityQuery, IdentityRecord, IdentityStatus, Permission, Refusal, + RotationDeadline, RotationStatus, Settings, SettingsChange, TokenSpec, TokenState, refusal, +}; +use crate::signer::{KeyPair, SignatureParameter, SignedHeaders, thumbprint}; +use crate::{Context, channel}; + +const TOKEN_LIFETIME: Duration = Duration::from_secs(3600); + +async fn token(ctx: &Context, max_uses: u64) -> anyhow::Result { + ctx.target + .management() + .create_token(max_uses, TOKEN_LIFETIME, None) + .await +} + +async fn identity(target: &Target, token: &Token, hostname: &str) -> anyhow::Result { + let key = KeyPair::generate()?; + let reply = target + .enroll(&token.text, &key, &json!({ "hostname": hostname })) + .await?; + let identity = Identity::from_enrollment(key, &reply)?; + ensure!( + reply.body["config"]["version"] == 1, + "enrollment config.version is not 1" + ); + if let Some(known) = target.authority_id { + ensure!( + identity.authority_id == known.to_string(), + "enrollment returned an unexpected authority ID" + ); + } + Ok(identity) +} + +async fn issued(ctx: &Context, hostname: &str) -> anyhow::Result<(Token, Identity)> { + let token = token(ctx, 5).await?; + let identity = identity(&ctx.target, &token, hostname).await?; + Ok((token, identity)) +} + +fn fresh_nonce() -> String { + let mut bytes = [0u8; 16]; + rand::rng().fill(&mut bytes[..]); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn attach_content_digest(headers: &mut SignedHeaders, body: &[u8]) { + headers.digest = Some(format!( + "sha-256=:{}:", + base64::engine::general_purpose::STANDARD.encode(sha2::Sha256::digest(body)) + )); +} + +pub(crate) async fn wait_rotation_idle(target: &Target, window_secs: u64) -> anyhow::Result<()> { + let started = Instant::now(); + loop { + if !target.management().rotation().await?.rotating() { + return Ok(()); + } + ensure!( + started.elapsed() < Duration::from_secs(window_secs.saturating_add(5)), + "rotation is already in progress; use a disposable DVLS target" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +async fn begin_rotation(ctx: &Context) -> anyhow::Result { + if ctx.mock() { + return ctx.target.management().rotate(RotationDeadline::Default).await; + } + ensure!( + ctx.disposable_dvls_target, + "DVLS rotation requires --disposable-dvls-target" + ); + wait_rotation_idle(&ctx.target, ctx.dvls_rotation_window_secs).await?; + let deadline = + time::OffsetDateTime::now_utc() + time::Duration::seconds(i64::try_from(ctx.dvls_rotation_window_secs)?); + ctx.target.management().rotate(RotationDeadline::At(deadline)).await +} + +async fn finish_rotation(ctx: &Context) -> anyhow::Result<()> { + if !ctx.mock() { + wait_rotation_idle(&ctx.target, ctx.dvls_rotation_window_secs).await?; + } + Ok(()) +} + +async fn next_config_update( + stream: &mut channel::ChannelStream, +) -> anyhow::Result<(agent_channel_proto::ServerMessage, Value)> { + let message = stream.next().await?; + let Some(agent_channel_proto::server_message::Payload::ConfigUpdate(update)) = message.payload.as_ref() else { + anyhow::bail!("expected ConfigUpdate after config revision changed"); + }; + let config: Value = serde_json::from_str(&update.config_json).context("decode ConfigUpdate config_json")?; + ensure!(config.is_object(), "ConfigUpdate config_json is not an object"); + Ok((message, config)) +} + +async fn check_in_config(target: &Target, identity: &Identity) -> anyhow::Result { + let reply = target.check_in(identity, &json!({})).await?; + expect_status(&reply, 200)?; + let config = reply.body["config"].clone(); + ensure!(config.is_object(), "check-in response omitted its config object"); + Ok(config) +} + +async fn identity_record(target: &Target, id: &str) -> anyhow::Result { + target.management().identity(id).await +} + +async fn assert_identity_metadata_and_certificates_unchanged( + target: &Target, + id: &str, + before: &IdentityRecord, +) -> anyhow::Result<()> { + let after = identity_record(target, id).await?; + ensure!( + after.metadata == before.metadata && after.certificates == before.certificates, + "rejected key reuse changed identity metadata or certificate identities/statuses" + ); + Ok(()) +} + +/// The non-revoked identities that hold an unexpired current or pending certificate from `issuer`. +async fn active_old_root_ids(ctx: &Context, issuer: &str) -> anyhow::Result> { + let now = chain_evaluation_time(ctx).await?; + let identities = ctx + .target + .management() + .all_identities(&IdentityQuery::default().full()) + .await?; + Ok(identities + .into_iter() + .filter(|identity| { + identity.status != IdentityStatus::Revoked + && identity.certificates().iter().any(|cert| { + matches!(cert.status, CertificateStatus::Current | CertificateStatus::Pending) + && cert.issuer == issuer + && cert.not_after.unix_timestamp() >= now + }) + }) + .map(|identity| identity.id) + .collect()) +} + +fn require_isolated_old_root_fixture(active: &HashSet, expected: &[&str], mock: bool) -> anyhow::Result<()> { + ensure!( + expected.iter().all(|id| active.contains(*id)), + "newly enrolled identity is missing from the active old-root listing" + ); + if active.len() > expected.len() { + if !mock { + return Err(crate::Incomplete("disposable DVLS target has other active old-root identities").into()); + } + anyhow::bail!("mock reset left unrelated active old-root identities"); + } + Ok(()) +} + +async fn isolated_old_root_fixture(ctx: &Context, issuer: &str, expected: &[&str]) -> anyhow::Result<()> { + let active = active_old_root_ids(ctx, issuer).await?; + require_isolated_old_root_fixture(&active, expected, ctx.mock()) +} + +async fn signed_renew(target: &Target, current: &Identity, key: &KeyPair, metadata: &Value) -> anyhow::Result { + target.renew(current, key, metadata).await +} + +fn assert_root(root: &Value) -> anyhow::Result<()> { + let certificate = field(root, "certificate")?; + ensure!( + thumbprint(certificate)? == field(root, "thumbprint")?, + "trust-anchor thumbprint mismatch" + ); + let der = decoded_certificate(certificate)?; + let cert = Certificate::from_der(&der)?; + ensure!( + cert.tbs_certificate().issuer() == cert.tbs_certificate().subject(), + "root is not self-issued" + ); + let before = field(root, "not_before")?; + let after = field(root, "not_after")?; + ensure!( + before.ends_with('Z') && after.ends_with('Z'), + "trust-anchor dates are not UTC Z strings" + ); + let before = time::OffsetDateTime::parse(before, &Rfc3339)?.unix_timestamp(); + let after = time::OffsetDateTime::parse(after, &Rfc3339)?.unix_timestamp(); + let cert_before = i64::try_from( + cert.tbs_certificate() + .validity() + .not_before + .to_unix_duration() + .as_secs(), + )?; + let cert_after = i64::try_from(cert.tbs_certificate().validity().not_after.to_unix_duration().as_secs())?; + ensure!( + before == cert_before && after == cert_after, + "trust-anchor dates disagree with DER validity" + ); + Ok(()) +} + +fn assert_leaf_profile( + leaf: &str, + root: &str, + identity_id: &str, + key: &KeyPair, + lifetime_secs: u64, +) -> anyhow::Result<()> { + let cert = Certificate::from_der(&decoded_certificate(leaf)?)?; + let root = Certificate::from_der(&decoded_certificate(root)?)?; + let expected = format!("CN={identity_id}").parse::()?; + ensure!( + cert.tbs_certificate().subject() == &expected, + "leaf subject does not match identity ID" + ); + let expected_spki = key.key.verifying_key().to_public_key_der()?; + ensure!( + cert.tbs_certificate().subject_public_key_info().to_der()? == expected_spki.as_bytes(), + "issued certificate does not contain the CSR public key" + ); + let (_, san) = cert + .tbs_certificate() + .get_extension::()? + .context("leaf lacks subject alternative name")?; + let uri = format!("urn:uuid:{identity_id}"); + ensure!( + san.0 + .iter() + .any(|name| matches!(name, GeneralName::UniformResourceIdentifier(value) if value.as_str() == uri)), + "leaf SAN lacks the identity URI" + ); + let (_, eku) = cert + .tbs_certificate() + .get_extension::()? + .context("leaf lacks extended key usage")?; + ensure!( + eku.0.contains(&"1.3.6.1.5.5.7.3.2".parse()?), + "leaf lacks clientAuth EKU" + ); + let (_, key_usage) = cert + .tbs_certificate() + .get_extension::()? + .context("leaf lacks key usage")?; + ensure!(key_usage.digital_signature(), "leaf lacks digitalSignature key usage"); + assert_leaf_extensions(&cert)?; + let validity = cert.tbs_certificate().validity(); + ensure!( + validity.not_before.to_unix_duration() < validity.not_after.to_unix_duration(), + "leaf validity has empty lifetime" + ); + ensure!( + validity.not_after.to_unix_duration() <= root.tbs_certificate().validity().not_after.to_unix_duration(), + "leaf outlives issuing root" + ); + let expected_end = (validity.not_before.to_unix_duration() + Duration::from_secs(lifetime_secs)) + .min(root.tbs_certificate().validity().not_after.to_unix_duration()); + ensure!( + validity.not_after.to_unix_duration() == expected_end, + "leaf lifetime is not {lifetime_secs} seconds capped at the issuing root" + ); + Ok(()) +} + +/// CONTRACT.agent-identity.md §4: "The leaf carries no other extension except an authority key identifier and a subject key identifier; in particular it carries no basicConstraints". +/// +/// The failure names each other extension's OID. +fn assert_leaf_extensions(leaf: &Certificate) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + let allowed = [ + SubjectAltName::OID, + ExtendedKeyUsage::OID, + KeyUsage::OID, + AuthorityKeyIdentifier::OID, + SubjectKeyIdentifier::OID, + ]; + let others = leaf + .tbs_certificate() + .extensions() + .into_iter() + .flatten() + .filter(|extension| !allowed.contains(&extension.extn_id)) + .map(|extension| extension.extn_id.to_string()) + .collect::>(); + ensure!( + others.is_empty(), + "leaf carries extensions other than SAN, EKU, KeyUsage, AKI and SKI: {}", + others.join(", ") + ); + Ok(()) +} + +async fn chain_evaluation_time(ctx: &Context) -> anyhow::Result { + if ctx.mock() { + let reply = ctx.target.control("time/advance", &json!({ "secs": 0 })).await?; + expect_status(&reply, 200)?; + return reply.body["now"].as_i64().context("mock omitted evaluation time"); + } + Ok(time::OffsetDateTime::now_utc().unix_timestamp()) +} + +/// Validates an issued chain against the published roots. +/// +/// The root profile and the number of CA tiers are each authority's choice ([CONTRACT.agent-identity.md] §4 "Certificates"), so only RFC 5280 path rules apply: intermediates must be CAs allowed to sign certificates, while the trust anchor needs no extensions. +/// An issuer signature algorithm this tester cannot verify makes the test incomplete rather than failed. +/// +/// [CONTRACT.agent-identity.md]: ../../../docs/agent-identity/CONTRACT.agent-identity.md +fn verify_chain(chain: &[String], roots: &Value, now: i64) -> anyhow::Result<()> { + ensure!(chain.len() >= 2, "certificate chain has no issuer"); + let certs = chain + .iter() + .map(|encoded| Certificate::from_der(&decoded_certificate(encoded)?).map_err(anyhow::Error::from)) + .collect::>>()?; + for intermediate in &certs[1..certs.len() - 1] { + let basic = intermediate.tbs_certificate().get_extension::()?; + ensure!( + basic.is_some_and(|(_, basic)| basic.ca), + "chain intermediate is not a CA" + ); + if let Some((_, usage)) = intermediate.tbs_certificate().get_extension::()? { + ensure!(usage.key_cert_sign(), "chain intermediate lacks keyCertSign"); + } + } + let anchors = roots["roots"].as_array().context("missing trust anchors")?; + let root_der = decoded_certificate(chain.last().context("missing chain root")?)?; + let published = anchors + .iter() + .map(|root| decoded_certificate(field(root, "certificate")?)) + .collect::>>()?; + ensure!( + published.iter().any(|root| root == &root_der), + "chain does not end at a published root" + ); + for cert in &certs { + let validity = cert.tbs_certificate().validity(); + let before = i64::try_from(validity.not_before.to_unix_duration().as_secs())?; + let after = i64::try_from(validity.not_after.to_unix_duration().as_secs())?; + ensure!( + before <= now && now <= after, + "certificate chain element is not valid at evaluation time" + ); + } + for pair in certs.windows(2) { + ensure!( + pair[0].tbs_certificate().issuer() == pair[1].tbs_certificate().subject(), + "certificate issuer mismatch" + ); + let issuer_key = pair[1].tbs_certificate().subject_public_key_info(); + let curve = (issuer_key.algorithm.oid == ObjectIdentifier::new_unwrap("1.2.840.10045.2.1")) + .then_some(issuer_key.algorithm.parameters.as_ref()) + .flatten() + .and_then(|parameters| parameters.decode_as::().ok()); + let algorithm = pair[0].signature_algorithm().oid; + let signed_bytes = pair[0].tbs_certificate().to_der()?; + let signature = pair[0].signature().as_bytes().context("certificate signature")?; + if curve == Some(ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7")) + && algorithm == ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2") + { + let key = VerifyingKey::from_public_key_der(&issuer_key.to_der()?)?; + key.verify(&signed_bytes, &Signature::from_der(signature)?)?; + } else if curve == Some(ObjectIdentifier::new_unwrap("1.3.132.0.34")) + && algorithm == ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.3") + { + let key = VerifyingKey384::from_public_key_der(&issuer_key.to_der()?)?; + key.verify(&signed_bytes, &Signature384::from_der(signature)?)?; + } else { + return Err(crate::Incomplete( + "the tester cannot verify the issuer's certificate signature algorithm; only P-256 with SHA-256 and P-384 with SHA-384 are supported", + ) + .into()); + } + } + Ok(()) +} + +pub(crate) async fn p_trust_anchor_lists_roots(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + let roots = ctx.target.trust_anchor().await?; + let entries = roots["roots"].as_array().context("missing roots")?; + ensure!( + (1..=2).contains(&entries.len()), + "trust-anchor must publish exactly one root, or two during rotation" + ); + for root in entries { + assert_root(root)?; + } + let (_, identity) = issued(&ctx, "anchor").await?; + verify_chain(&identity.certificate_chain, &roots, chain_evaluation_time(&ctx).await?)?; + ensure!( + uuid::Uuid::parse_str(&identity.authority_id).is_ok(), + "invalid authority ID" + ); + ensure!( + uuid::Uuid::parse_str(&identity.identity_id).is_ok(), + "invalid identity ID" + ); + assert_leaf_profile( + &identity.certificate_chain[0], + identity.certificate_chain.last().context("missing issuer")?, + &identity.identity_id, + &identity.key, + ctx.leaf_lifetime_secs, + )?; + Ok(()) +} + +pub(crate) async fn p_channel_url_expectation(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + let (_, identity) = issued(&ctx, "channel-availability").await?; + if ctx.expect_channel { + ensure!( + identity.agent_channel_url.is_some(), + "enrollment omitted config.agent_channel_url while --expect-channel=true" + ); + } else { + ensure!( + identity.agent_channel_url.is_some() == ctx.channel_available, + "enrollment config.agent_channel_url availability changed during this run" + ); + } + if let Some(channel_url) = &identity.agent_channel_url { + let parsed = reqwest::Url::parse(channel_url).context("parse config.agent_channel_url")?; + ensure!( + parsed.scheme() == "https" && parsed.query().is_none() && parsed.fragment().is_none(), + "config.agent_channel_url must be an HTTPS URL without a query or fragment" + ); + if ctx.mock() { + ensure!( + channel_url == &ctx.target.base_url, + "mock config.agent_channel_url does not include its path prefix" + ); + } + } + Ok(()) +} + +pub(crate) async fn p_reset_rebases_root_validity(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control + let old = ctx.target.trust_anchor().await?; + ctx.target.advance(90 * 24 * 3600).await?; + ctx.target.reset().await?; + let roots = ctx.target.trust_anchor().await?; + ensure!( + roots["roots"][0]["thumbprint"] != old["roots"][0]["thumbprint"], + "mock reset retained the old root" + ); + let (_, identity) = issued(&ctx, "after-reset").await?; + verify_chain(&identity.certificate_chain, &roots, chain_evaluation_time(&ctx).await?)?; + Ok(()) +} + +pub(crate) async fn p_token_n_uses_consumed_then_exhausted(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + let token = token(&ctx, 3).await?; + let mut authority_id = None; + for n in 1..=3 { + let enrolled = identity(&ctx.target, &token, &format!("host-{n}")).await?; + if let Some(id) = &authority_id { + ensure!(&enrolled.authority_id == id, "authority ID changed across enrollments"); + } + authority_id = Some(enrolled.authority_id); + let record = ctx.target.management().token(&token.id).await?; + ensure!(record.used_count == n, "enrollment did not consume exactly one use"); + } + let reply = ctx + .target + .enroll(&token.text, &KeyPair::generate()?, &json!({})) + .await?; + expect_error(&reply, 403, "token_exhausted")?; + Ok(()) +} + +pub(crate) async fn p_token_concurrent_enrollment_respects_max_uses(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + let token = token(&ctx, 5).await?; + let mut tasks = tokio::task::JoinSet::new(); + let barrier = Arc::new(Barrier::new(21)); + for index in 0..20 { + let target = ctx.target.clone(); + let secret = token.text.clone(); + let key = KeyPair::generate()?; + let barrier = Arc::clone(&barrier); + tasks.spawn(async move { + barrier.wait().await; + target + .enroll(&secret, &key, &json!({ "hostname": format!("c-{index}") })) + .await + }); + } + barrier.wait().await; + let mut success = 0; + let mut exhausted = 0; + let mut ids = HashSet::new(); + while let Some(outcome) = tasks.join_next().await { + let reply = outcome??; + match reply.status { + 200 => { + let id = field(&reply.body, "identity_id")?; + ensure!( + ids.insert(id.to_owned()), + "concurrent enrollments returned duplicate identity IDs" + ); + success += 1; + } + 403 => { + expect_error(&reply, 403, "token_exhausted")?; + exhausted += 1; + } + _ => anyhow::bail!("unexpected concurrent enrollment status {}", reply.status), + } + } + ensure!( + success == 5 && exhausted == 15, + "concurrent uses were not atomic: {success} successes" + ); + let record = ctx.target.management().token(&token.id).await?; + ensure!(record.used_count == 5, "concurrent use count is incorrect"); + ensure!( + ctx.target.management().identity_count(&token).await? == 5, + "concurrent enrollment created unexpected identities" + ); + Ok(()) +} + +pub(crate) async fn p_concurrent_same_key_enroll_replays_winner(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + let token = token(&ctx, 1).await?; + let csr = KeyPair::generate()?.csr; + let body = json!({ "csr": csr, "metadata": { "hostname": "shared-key" } }); + let barrier = Arc::new(Barrier::new(3)); + let mut tasks = tokio::task::JoinSet::new(); + for _ in 0..2 { + let target = ctx.target.clone(); + let secret = token.text.clone(); + let body = body.clone(); + let barrier = Arc::clone(&barrier); + tasks.spawn(async move { + barrier.wait().await; + target.agent(Method::POST, "/enroll", Some(&body), Some(&secret)).await + }); + } + barrier.wait().await; + let mut replies = Vec::new(); + while let Some(outcome) = tasks.join_next().await { + let reply = outcome??; + expect_status(&reply, 200)?; + replies.push(reply); + } + ensure!( + replies.len() == 2 + && replies[0].body["identity_id"] == replies[1].body["identity_id"] + && replies[0].body["certificate_chain"] == replies[1].body["certificate_chain"] + && ctx.target.management().token(&token.id).await?.used_count == 1 + && ctx.target.management().identity_count(&token).await? == 1, + "concurrent same-key enrolls did not replay one winning identity and consume one use" + ); + Ok(()) +} + +pub(crate) async fn p_concurrent_same_key_renew_replays_winner(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + let (token, identity) = issued(&ctx, "shared-renew").await?; + let key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": key.csr, "metadata": { "hostname": "shared-renewal" } }))?; + let barrier = Arc::new(Barrier::new(3)); + let mut tasks = tokio::task::JoinSet::new(); + for _ in 0..2 { + let target = ctx.target.clone(); + let body = body.clone(); + let headers = identity.key.sign_now(&identity.thumbprint, "renew", Some(&body)); + let barrier = Arc::clone(&barrier); + tasks.spawn(async move { + barrier.wait().await; + target.signed_renew(&body, &headers, None).await + }); + } + barrier.wait().await; + let mut replies = Vec::new(); + while let Some(outcome) = tasks.join_next().await { + let reply = outcome??; + expect_status(&reply, 200)?; + replies.push(reply); + } + let record = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + replies.len() == 2 + && replies[0].body["certificate_chain"] == replies[1].body["certificate_chain"] + && record.certificates.as_ref().is_some_and(|certs| certs + .iter() + .filter(|cert| cert.status == CertificateStatus::Pending) + .count() + == 1) + && ctx.target.management().token(&token.id).await?.used_count == 1, + "concurrent same-CSR renews issued more than one pending certificate" + ); + Ok(()) +} + +pub(crate) async fn p_failed_enrollment_consumes_nothing(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + let token = token(&ctx, 2).await?; + let (prefix, _) = token.text.rsplit_once('.').context("token structure")?; + let invalid_secret = format!( + "{prefix}.{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x61; 32]) + ); + expect_error( + &ctx.target + .send( + Method::POST, + "/api/agent-identity/v1/enroll", + Some(b"{"), + Some(&invalid_secret), + None, + ) + .await?, + 401, + "token_invalid", + )?; + let vectors: Value = serde_json::from_str(include_str!("../../../docs/agent-identity/test-vectors.json"))?; + let bad_csr = field(&vectors["csr"]["bad_self_signature"], "csr")?; + let wrong_algorithm = field(&vectors["csr"]["ecdsa_with_sha384"], "csr")?; + for csr in [bad_csr, wrong_algorithm] { + let bad = ctx + .target + .agent( + Method::POST, + "/enroll", + Some(&json!({ "csr": csr, "metadata": {} })), + Some(&token.text), + ) + .await?; + expect_error(&bad, 400, "invalid_request")?; + } + let invalid = ctx + .target + .enroll(&token.text, &KeyPair::generate()?, &json!({ "hostname": 3 })) + .await?; + expect_error(&invalid, 400, "invalid_request")?; + let record = ctx.target.management().token(&token.id).await?; + ensure!(record.used_count == 0, "invalid enrollment consumed a use"); + Ok(()) +} + +pub(crate) async fn p_token_errors_distinct(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + let first_token = token(&ctx, 1).await?; + let (prefix, _) = first_token.text.rsplit_once('.').context("invalid issued token")?; + let wrong_secret = format!( + "{prefix}.{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x57; 32]) + ); + let invalid = ctx + .target + .enroll(&wrong_secret, &KeyPair::generate()?, &json!({})) + .await?; + expect_error(&invalid, 401, "token_invalid")?; + let _ = identity(&ctx.target, &first_token, "exhaust").await?; + let exhausted = ctx + .target + .enroll(&first_token.text, &KeyPair::generate()?, &json!({})) + .await?; + expect_error(&exhausted, 403, "token_exhausted")?; + let other = token(&ctx, 1).await?; + ctx.target.management().delete_token(&other.id).await?; + let deleted = ctx + .target + .enroll(&other.text, &KeyPair::generate()?, &json!({})) + .await?; + expect_error(&deleted, 401, "token_invalid")?; + Ok(()) +} + +pub(crate) async fn p_token_expired(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + let token = ctx + .target + .management() + .create_token(1, Duration::from_secs(3), None) + .await?; + ctx.target.advance(5).await?; + let expired = ctx + .target + .enroll(&token.text, &KeyPair::generate()?, &json!({})) + .await?; + expect_error(&expired, 401, "token_expired")?; + let record = ctx.target.management().token(&token.id).await?; + ensure!(record.state == TokenState::Expired, "token not marked expired"); + ensure!(record.used_count == 0, "expired token consumed a use"); + Ok(()) +} + +pub(crate) async fn p_enroll_idempotent_same_key(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata + let token = token(&ctx, 1).await?; + let key = KeyPair::generate()?; + let first = ctx + .target + .enroll(&token.text, &key, &json!({ "hostname": "original" })) + .await?; + expect_status(&first, 200)?; + let before = identity_record(&ctx.target, field(&first.body, "identity_id")?).await?; + if ctx.mock() { + ctx.target.advance(2).await?; + } else { + tokio::time::sleep(Duration::from_secs(1)).await; + } + let replay = ctx + .target + .enroll(&token.text, &key, &json!({ "hostname": "changed" })) + .await?; + expect_status(&replay, 200)?; + ensure!( + first.body["identity_id"] == replay.body["identity_id"] + && first.body["certificate_chain"] == replay.body["certificate_chain"], + "same-key replay was not idempotent" + ); + let record = ctx.target.management().token(&token.id).await?; + ensure!(record.used_count == 1, "idempotent replay consumed a use"); + if ctx.mock() { + ensure!( + count(&ctx.target.requests(Some(&token)).await?, "enroll")? == 2, + "mock did not observe both enrollment requests" + ); + } + let replayed_record = identity_record(&ctx.target, field(&first.body, "identity_id")?).await?; + ensure!( + replayed_record.metadata == before.metadata && replayed_record.last_seen_at == before.last_seen_at, + "idempotent enrollment changed metadata or last_seen_at" + ); + if ctx.mock() { + ctx.target.advance(3601).await?; + let expired_replay = ctx + .target + .enroll(&token.text, &key, &json!({ "hostname": "after-expiry" })) + .await?; + expect_status(&expired_replay, 200)?; + ensure!( + expired_replay.body["identity_id"] == first.body["identity_id"] + && expired_replay.body["certificate_chain"] == first.body["certificate_chain"] + && ctx.target.management().token(&token.id).await?.used_count == 1 + && identity_record(&ctx.target, field(&first.body, "identity_id")?) + .await? + .metadata + == before.metadata, + "expired-token current-key replay created, consumed or changed an identity" + ); + } + Ok(()) +} + +pub(crate) async fn p_token_config_contributes_to_effective_config(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens + let mut spec = TokenSpec::new(1, TOKEN_LIFETIME)?; + spec.config = Some([("psu_channel_url".to_owned(), "https://psu.example".to_owned())].into()); + let token = ctx.target.management().create_token_from(&spec).await?; + let identity = identity(&ctx.target, &token, "token-config").await?; + ensure!( + identity.config["psu_channel_url"] == "https://psu.example", + "enrollment omitted the token's product-specific config" + ); + ensure!( + check_in_config(&ctx.target, &identity).await?["psu_channel_url"] == "https://psu.example", + "check-in omitted the token's product-specific config" + ); + Ok(()) +} + +pub(crate) async fn p_deleted_token_replays_own_key_only(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + let foreign_token = token(&ctx, 1).await?; + let token = token(&ctx, 2).await?; + let key = KeyPair::generate()?; + let first = ctx + .target + .enroll(&token.text, &key, &json!({ "hostname": "original" })) + .await?; + expect_status(&first, 200)?; + let identity_id = field(&first.body, "identity_id")?; + let before = identity_record(&ctx.target, identity_id).await?; + let foreign = identity(&ctx.target, &foreign_token, "foreign").await?; + ctx.target.management().delete_token(&token.id).await?; + ensure!( + refusal(ctx.target.management().token(&token.id).await)? == Refusal::NotFound, + "deleted token is still listed" + ); + let replay = ctx + .target + .enroll(&token.text, &key, &json!({ "hostname": "must-not-replace" })) + .await?; + expect_status(&replay, 200)?; + ensure!( + replay.body["identity_id"] == first.body["identity_id"] + && replay.body["certificate_chain"] == first.body["certificate_chain"], + "deleted token could not recover its own committed enrollment" + ); + let after = identity_record(&ctx.target, identity_id).await?; + ensure!( + after.metadata == before.metadata && after.last_seen_at == before.last_seen_at, + "deleted-token replay changed identity state" + ); + expect_error( + &ctx.target + .enroll(&token.text, &KeyPair::generate()?, &json!({})) + .await?, + 401, + "token_invalid", + )?; + expect_error( + &ctx.target.enroll(&token.text, &foreign.key, &json!({})).await?, + 401, + "token_invalid", + )?; + ctx.target.management().revoke(&foreign.identity_id).await?; + expect_error( + &ctx.target.enroll(&token.text, &foreign.key, &json!({})).await?, + 401, + "token_invalid", + )?; + ctx.target.management().delete_identity(&foreign.identity_id).await?; + expect_error( + &ctx.target.enroll(&token.text, &foreign.key, &json!({})).await?, + 401, + "token_invalid", + )?; + expect_status(&ctx.target.enroll(&token.text, &key, &json!({})).await?, 200)?; + ensure!( + ctx.target.management().identity_count(&token).await? == 1 + && identity_record(&ctx.target, identity_id).await? == before, + "deleted token issued another identity or changed its own replayed identity" + ); + Ok(()) +} + +pub(crate) async fn p_enroll_revoked_same_key_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + let token = token(&ctx, 2).await?; + let key = KeyPair::generate()?; + let enrolled = ctx.target.enroll(&token.text, &key, &json!({})).await?; + let identity = Identity::from_enrollment(key, &enrolled)?; + ctx.target.management().revoke(&identity.identity_id).await?; + let refused = ctx.target.enroll(&token.text, &identity.key, &json!({})).await?; + expect_error(&refused, 403, "identity_revoked")?; + let record = ctx.target.management().token(&token.id).await?; + ensure!(record.used_count == 1, "revoked replay consumed a use"); + Ok(()) +} + +pub(crate) async fn p_enroll_certificate_key_reuse_rules(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + let original_token = token(&ctx, 1).await?; + let original_key = KeyPair::generate()?; + let first = ctx + .target + .enroll(&original_token.text, &original_key, &json!({ "hostname": "original" })) + .await?; + let identity = Identity::from_enrollment(original_key, &first)?; + let original_record = identity_record(&ctx.target, &identity.identity_id).await?; + let original_name = original_record.friendly_name.as_str().to_owned(); + let other_token = token(&ctx, 1).await?; + expect_error( + &ctx.target + .enroll(&other_token.text, &identity.key, &json!({ "hostname": "foreign" })) + .await?, + 400, + "invalid_request", + )?; + let replay = ctx + .target + .enroll(&original_token.text, &identity.key, &json!({ "hostname": "refreshed" })) + .await?; + expect_status(&replay, 200)?; + ensure!( + replay.body["identity_id"] == identity.identity_id + && replay.body["certificate_chain"] == first.body["certificate_chain"], + "current-key enrollment did not replay the existing identity and current chain" + ); + let replayed_record = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + replayed_record.metadata == original_record.metadata + && replayed_record.last_seen_at == original_record.last_seen_at + && replayed_record.friendly_name == original_name, + "current-key replay changed identity metadata, last_seen_at or friendly name" + ); + let first_pending = KeyPair::generate()?; + expect_status(&ctx.target.renew(&identity, &first_pending, &json!({})).await?, 200)?; + let pending_before = identity_record(&ctx.target, &identity.identity_id).await?; + for bearer in [&other_token.text, &original_token.text] { + expect_error( + &ctx.target.enroll(bearer, &first_pending, &json!({})).await?, + 400, + "invalid_request", + )?; + } + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &pending_before).await?; + let second_pending = KeyPair::generate()?; + expect_status(&ctx.target.renew(&identity, &second_pending, &json!({})).await?, 200)?; + let retired_before = identity_record(&ctx.target, &identity.identity_id).await?; + for bearer in [&other_token.text, &original_token.text] { + for key in [&first_pending, &second_pending] { + expect_error( + &ctx.target.enroll(bearer, key, &json!({})).await?, + 400, + "invalid_request", + )?; + } + } + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &retired_before).await?; + ctx.target.management().revoke(&identity.identity_id).await?; + for key in [&identity.key, &first_pending, &second_pending] { + expect_error( + &ctx.target.enroll(&other_token.text, key, &json!({})).await?, + 400, + "invalid_request", + )?; + } + ctx.target.management().delete_identity(&identity.identity_id).await?; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + // Only a non-deleted identity's key is matched against its token, so a deleted identity's key gets `identity_revoked` whichever token presents it. + for key in [&identity.key, &first_pending, &second_pending] { + for bearer in [&other_token.text, &original_token.text] { + expect_error( + &ctx.target.enroll(bearer, key, &json!({})).await?, + 403, + "identity_revoked", + )?; + } + } + ensure!( + ctx.target.management().token(&other_token.id).await?.used_count == 0 + && ctx.target.management().token(&original_token.id).await?.used_count == 1 + && ctx.target.management().identity_count(&original_token).await? == 0, + "key-reuse attempts consumed a token use or recreated a deleted identity" + ); + Ok(()) +} + +pub(crate) async fn p_renew_certificate_key_reuse_rules(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + let (_, identity) = issued(&ctx, "renew-a").await?; + let (_, other) = issued(&ctx, "renew-b").await?; + let initial = identity_record(&ctx.target, &identity.identity_id).await?; + for key in [&identity.key, &other.key] { + expect_error( + &ctx.target.renew(&identity, key, &json!({})).await?, + 400, + "invalid_request", + )?; + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &initial).await?; + } + let pending = KeyPair::generate()?; + let first = ctx.target.renew(&identity, &pending, &json!({})).await?; + expect_status(&first, 200)?; + let replay = ctx.target.renew(&identity, &pending, &json!({})).await?; + expect_status(&replay, 200)?; + ensure!( + replay.body["certificate_chain"] == first.body["certificate_chain"], + "pending-key retry did not replay the pending chain" + ); + let replacement = KeyPair::generate()?; + expect_status(&ctx.target.renew(&identity, &replacement, &json!({})).await?, 200)?; + let renewed = identity_record(&ctx.target, &identity.identity_id).await?; + expect_error( + &ctx.target.renew(&identity, &pending, &json!({})).await?, + 400, + "invalid_request", + )?; + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &renewed).await?; + let other_pending = KeyPair::generate()?; + expect_status(&ctx.target.renew(&other, &other_pending, &json!({})).await?, 200)?; + let other_replacement = KeyPair::generate()?; + expect_status(&ctx.target.renew(&other, &other_replacement, &json!({})).await?, 200)?; + for key in [&other.key, &other_pending, &other_replacement] { + expect_error( + &ctx.target.renew(&identity, key, &json!({})).await?, + 400, + "invalid_request", + )?; + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &renewed).await?; + } + ctx.target.management().revoke(&other.identity_id).await?; + ctx.target.management().delete_identity(&other.identity_id).await?; + for key in [&other.key, &other_pending, &other_replacement] { + expect_error( + &ctx.target.renew(&identity, key, &json!({})).await?, + 400, + "invalid_request", + )?; + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &renewed).await?; + } + ensure!( + renewed.certificates.as_ref().is_some_and(|certs| certs.len() == 3 + && certs + .iter() + .filter(|cert| cert.status == CertificateStatus::Pending) + .count() + == 1), + "renewal fixture did not have exactly one pending certificate" + ); + Ok(()) +} + +pub(crate) async fn p_metadata_limits(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata + let token = token(&ctx, 12).await?; + let many = (0..33) + .map(|n| (format!("k{n}"), json!("v"))) + .collect::>(); + let exactly_16_kib = (0..16) + .map(|n| (format!("k{n:02}"), json!("a".repeat(1021)))) + .collect::>(); + let mut oversized_object = exactly_16_kib.clone(); + oversized_object.insert("extra".to_owned(), json!("x")); + let invalid = [ + json!({ "hostname": 42 }), + json!({ "hostname": { "nested": "no" } }), + json!({ "Bad-Key": "x" }), + json!({ "hostname": "z".repeat(1025) }), + json!({ "hostname": "x\u{7f}" }), + json!({ "hostname": "x\u{0085}" }), + Value::Object(many), + Value::Object(oversized_object), + ]; + for metadata in invalid { + let reply = ctx.target.enroll(&token.text, &KeyPair::generate()?, &metadata).await?; + expect_error(&reply, 400, "invalid_request")?; + } + let key = KeyPair::generate()?; + for body in [ + json!({ "csr": key.csr }), + json!({ "csr": key.csr, "metadata": null }), + json!({ "csr": key.csr, "metadata": [] }), + ] { + expect_error( + &ctx.target + .agent(Method::POST, "/enroll", Some(&body), Some(&token.text)) + .await?, + 400, + "invalid_request", + )?; + } + let within_limit = (0..8) + .map(|n| (format!("field{n}"), json!("\"".repeat(1000)))) + .collect::>(); + let escaped = ctx + .target + .enroll(&token.text, &KeyPair::generate()?, &Value::Object(within_limit)) + .await?; + expect_status(&escaped, 200)?; + let record = ctx.target.management().token(&token.id).await?; + ensure!( + record.used_count == 1, + "metadata validation used an incorrect size measure or consumed a failed request" + ); + expect_status( + &ctx.target + .enroll(&token.text, &KeyPair::generate()?, &Value::Object(exactly_16_kib)) + .await?, + 200, + )?; + let key = KeyPair::generate()?; + let enrolled = ctx + .target + .enroll(&token.text, &key, &json!({ "custom_key": "kept" })) + .await?; + let identity = Identity::from_enrollment(key, &enrolled)?; + let record = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + record.metadata_value("custom_key") == Some("kept"), + "unknown metadata key was not retained" + ); + Ok(()) +} + +pub(crate) async fn p_friendly_name_format(ctx: Context) -> anyhow::Result<()> { + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens + let management = ctx.target.management(); + let token = management + .create_token(2, TOKEN_LIFETIME, Some(" {hostname}-{{ok}}-{token_name}-{fqdn} ")) + .await?; + let name = management.token(&token.id).await?.name; + let first_identity = identity(&ctx.target, &token, "hello").await?; + let first_name = identity_record(&ctx.target, &first_identity.identity_id) + .await? + .friendly_name; + ensure!( + first_name == format!("hello-{{ok}}-{name}-"), + "friendly name formatting incorrect: {first_name}" + ); + let empty_token = management.create_token(1, TOKEN_LIFETIME, Some("{hostname}")).await?; + let empty = identity(&ctx.target, &empty_token, "").await?; + ensure!( + identity_record(&ctx.target, &empty.identity_id).await?.friendly_name == empty.identity_id, + "empty friendly name did not fall back to identity ID" + ); + let long_token = management.create_token(1, TOKEN_LIFETIME, Some("{hostname}")).await?; + let long = identity(&ctx.target, &long_token, &"é".repeat(260)).await?; + ensure!( + identity_record(&ctx.target, &long.identity_id) + .await? + .friendly_name + .chars() + .count() + == 255, + "friendly name not limited to 255 characters" + ); + for format in ["{not_a_known_key}", "prefix}"] { + let mut spec = TokenSpec::new(1, TOKEN_LIFETIME)?; + spec.friendly_name_format = Some(format.to_owned()); + ensure!( + refusal(management.create_token_from(&spec).await)? == Refusal::Invalid, + "friendly-name format {format:?} was not rejected" + ); + } + Ok(()) +} + +pub(crate) async fn p_identity_cannot_impersonate_another(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants + let (_, a) = issued(&ctx, "alpha").await?; + let (b_token, b) = issued(&ctx, "bravo").await?; + let b_before = identity_record(&ctx.target, &b.identity_id).await?; + let new_key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ + "csr": new_key.csr, + "identity_id": b.identity_id, + "metadata": { "hostname": "changed", "identity_id": b.identity_id } + }))?; + let forged = a.key.sign_now(&b.thumbprint, "renew", Some(&body)); + expect_error( + &ctx.target.signed_renew(&body, &forged, None).await?, + 401, + "signature_invalid", + )?; + let token_only = ctx + .target + .send( + Method::POST, + "/api/agent-identity/v1/renew", + Some(&body), + Some(&b_token.text), + None, + ) + .await?; + expect_error(&token_only, 401, "signature_invalid")?; + let valid = a.key.sign_now(&a.thumbprint, "renew", Some(&body)); + expect_status(&ctx.target.signed_renew(&body, &valid, Some(&b_token.text)).await?, 200)?; + let a_record = identity_record(&ctx.target, &a.identity_id).await?; + let b_record = identity_record(&ctx.target, &b.identity_id).await?; + ensure!( + a_record.metadata_value("hostname") == Some("changed"), + "A's renewal did not update A" + ); + ensure!( + b_record.metadata_value("hostname") == Some("bravo"), + "A modified B's metadata" + ); + ensure!( + b_record.certificates == b_before.certificates, + "A changed B's certificates" + ); + Ok(()) +} + +pub(crate) async fn p_channel_hello_cannot_impersonate_another(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants + let (_, a) = issued(&ctx, "alpha").await?; + let (_, b) = issued(&ctx, "bravo").await?; + let b_before = identity_record(&ctx.target, &b.identity_id).await?; + let mut stream = channel::open(&ctx.target, &a).await?; + stream + .hello(&a, &[("hostname", "channel-a"), ("identity_id", &b.identity_id)]) + .await?; + let a_after_hello = identity_record(&ctx.target, &a.identity_id).await?; + ensure!( + a_after_hello.metadata_value("hostname") == Some("channel-a") + && a_after_hello.metadata_value("identity_id") == Some(&*b.identity_id), + "A's Hello did not update A's metadata" + ); + let b_after_hello = identity_record(&ctx.target, &b.identity_id).await?; + ensure!( + b_after_hello.metadata == b_before.metadata + && b_after_hello.certificates == b_before.certificates + && b_after_hello.last_seen_at == b_before.last_seen_at + && b_after_hello.connected == b_before.connected, + "A's Hello modified B's identity" + ); + Ok(()) +} + +pub(crate) async fn p_renew_digest_integrity(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, identity) = issued(&ctx, "original").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let key = KeyPair::generate()?; + let original = serde_json::to_vec(&json!({ "csr": key.csr, "metadata": { "hostname": "original" } }))?; + let changed = serde_json::to_vec(&json!({ "csr": key.csr, "metadata": { "hostname": "tampered" } }))?; + let mut last_nonce = String::new(); + for (variant, replace_digest) in [("body only", false), ("body and digest", true)] { + let nonce = loop { + let nonce = fresh_nonce(); + if nonce != last_nonce { + break nonce; + } + }; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let mut signed = identity + .key + .sign(&identity.thumbprint, "renew", Some(&original), now, now + 60, &nonce); + last_nonce = nonce; + if replace_digest { + signed.digest = Some(format!( + "sha-256=:{}:", + base64::engine::general_purpose::STANDARD.encode(sha2::Sha256::digest(&changed)) + )); + } + expect_error( + &ctx.target.signed_renew(&changed, &signed, None).await?, + 401, + "signature_invalid", + ) + .with_context(|| format!("{variant} tampering"))?; + let after = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + after.metadata == before.metadata + && after.certificates == before.certificates + && after.last_seen_at == before.last_seen_at, + "{variant} tampering changed identity state" + ); + ensure!( + after + .certificates + .as_ref() + .is_some_and(|certs| certs.iter().all(|cert| cert.status != CertificateStatus::Pending)), + "{variant} tampering created a pending certificate" + ); + } + Ok(()) +} + +async fn renew_in_alternate_parameter_order(ctx: &Context) -> anyhow::Result<(Identity, SignedHeaders)> { + use SignatureParameter::{Algorithm, Created, Expires, KeyId, Nonce, Tag}; + + let (_, mut identity) = issued(ctx, "parameter-order").await?; + let new_key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ + "csr": new_key.csr, + "metadata": { "hostname": "renewed-in-alternate-order" }, + }))?; + let renew_headers = identity.key.sign_now_with_order( + &identity.thumbprint, + "renew", + Some(&body), + &[Created, Expires, Nonce, Algorithm, KeyId, Tag], + ); + let reply = ctx.target.signed_renew(&body, &renew_headers, None).await?; + expect_status(&reply, 200)?; + identity.adopt_certificate(&reply.body["certificate_chain"], new_key)?; + let renewed = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + renewed.has_certificate(&identity.thumbprint, CertificateStatus::Pending) + && renewed.metadata_value("hostname") == Some("renewed-in-alternate-order"), + "alternate-order renew did not create a pending certificate and update metadata" + ); + Ok((identity, renew_headers)) +} + +pub(crate) async fn p_signature_parameter_order_accepted_renew(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + renew_in_alternate_parameter_order(&ctx).await?; + Ok(()) +} + +pub(crate) async fn p_signature_parameter_order_accepted_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + use SignatureParameter::{Algorithm, Created, Expires, KeyId, Nonce, Tag}; + + let (identity, renew_headers) = renew_in_alternate_parameter_order(&ctx).await?; + let connect_headers = identity.key.sign_now_with_order( + &identity.thumbprint, + "connect", + None, + &[Tag, Created, Expires, Nonce, KeyId, Algorithm], + ); + ensure!( + renew_headers.nonce != connect_headers.nonce, + "tester reused the renew nonce" + ); + let mut stream = channel::open_with_headers(&ctx.target, &identity, connect_headers).await?; + stream + .hello(&identity, &[("hostname", "connected-in-alternate-order")]) + .await?; + Ok(()) +} + +pub(crate) async fn p_renew_rejects_sha384_csr(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + let (_, identity) = issued(&ctx, "original").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let vectors: Value = serde_json::from_str(include_str!("../../../docs/agent-identity/test-vectors.json"))?; + let csr = field(&vectors["csr"]["ecdsa_with_sha384"], "csr")?; + let body = serde_json::to_vec(&json!({ "csr": csr, "metadata": { "hostname": "changed" } }))?; + let signed = identity.key.sign_now(&identity.thumbprint, "renew", Some(&body)); + expect_error( + &ctx.target.signed_renew(&body, &signed, None).await?, + 400, + "invalid_request", + )?; + let after = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + before.metadata == after.metadata + && before.certificates == after.certificates + && after + .certificates + .as_ref() + .is_some_and(|certs| certs.iter().all(|cert| cert.status != CertificateStatus::Pending)), + "SHA-384 CSR changed metadata or created a pending certificate" + ); + Ok(()) +} + +pub(crate) async fn p_replay_nonce_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, identity) = issued(&ctx, "replay").await?; + let new_key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": new_key.csr, "metadata": {} }))?; + let nonce = fresh_nonce(); + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let bad = KeyPair::generate()?.sign(&identity.thumbprint, "renew", Some(&body), now, now + 60, &nonce); + expect_error( + &ctx.target.signed_renew(&body, &bad, None).await?, + 401, + "signature_invalid", + )?; + let headers = identity + .key + .sign(&identity.thumbprint, "renew", Some(&body), now, now + 60, &nonce); + expect_status(&ctx.target.signed_renew(&body, &headers, None).await?, 200)?; + expect_error( + &ctx.target.signed_renew(&body, &headers, None).await?, + 401, + "signature_invalid", + )?; + let shorter = identity + .key + .sign(&identity.thumbprint, "renew", Some(&body), now, now + 1, &nonce); + expect_error( + &ctx.target.signed_renew(&body, &shorter, None).await?, + 401, + "signature_invalid", + )?; + if ctx.mock() { + ctx.target.advance(62).await?; + expect_error( + &ctx.target.signed_renew(&body, &headers, None).await?, + 401, + "signature_invalid", + )?; + ensure!( + count(&ctx.target.requests(None).await?, "renew")? == 5, + "mock did not observe all renewal attempts" + ); + } + Ok(()) +} + +pub(crate) async fn p_replay_window_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, identity) = issued(&ctx, "window").await?; + let new_key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": new_key.csr, "metadata": {} }))?; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + for (created, expires) in [ + (now + 62, now + 122), + (now - 123, now - 63), + (now, now + 301), + (now, now), + (now, now - 1), + ] { + let headers = identity.key.sign( + &identity.thumbprint, + "renew", + Some(&body), + created, + expires, + &fresh_nonce(), + ); + let rejected = ctx.target.signed_renew(&body, &headers, None).await?; + expect_error(&rejected, 401, "clock_skew")?; + } + let old_created = now - 1_000; + let mut wrong_tag = identity.key.sign( + &identity.thumbprint, + "renew", + Some(&body), + old_created, + old_created + 60, + &fresh_nonce(), + ); + wrong_tag.input = wrong_tag.input.replace(";tag=\"renew\"", ";tag=\"connect\""); + resign_input(&identity.key, &mut wrong_tag)?; + expect_error( + &ctx.target.signed_renew(&body, &wrong_tag, None).await?, + 401, + "signature_invalid", + )?; + let unknown_key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x42; 32]); + let wrong_keyid = identity.key.sign( + &unknown_key, + "renew", + Some(&body), + old_created, + old_created + 60, + &fresh_nonce(), + ); + expect_error( + &ctx.target.signed_renew(&body, &wrong_keyid, None).await?, + 401, + "clock_skew", + )?; + Ok(()) +} + +pub(crate) async fn p_connect_signature_replayed_to_renew(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, identity) = issued(&ctx, "cross-tag").await?; + let new_key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": new_key.csr, "metadata": {} }))?; + let mut connect_tag = identity.key.sign_now(&identity.thumbprint, "connect", None); + attach_content_digest(&mut connect_tag, &body); + let rejected = ctx.target.signed_renew(&body, &connect_tag, None).await?; + expect_error(&rejected, 401, "signature_invalid")?; + Ok(()) +} + +pub(crate) async fn p_renew_signature_rejected_on_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "reverse-cross-tag").await?; + let csr = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": csr.csr, "metadata": {} }))?; + let renew_tag = identity.key.sign_now(&identity.thumbprint, "renew", Some(&body)); + let status = channel::open_with_headers(&ctx.target, &identity, renew_tag) + .await + .err() + .context("renew-tagged signature opened a channel")?; + channel::expect_refused_opening( + status.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + Ok(()) +} + +pub(crate) async fn p_channel_duplicate_signature_metadata_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "duplicate-channel-signature").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + for name in ["signature-input", "signature"] { + let failure = channel::open_with_duplicate_header(&ctx.target, &identity, name) + .await + .err() + .with_context(|| format!("duplicate {name} metadata opened a channel"))?; + channel::expect_refused_opening( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + } + let after = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + !after.connected && after.metadata == before.metadata && after.last_seen_at == before.last_seen_at, + "duplicate signed channel metadata authenticated or changed identity state" + ); + Ok(()) +} + +pub(crate) async fn p_confirm_cross_tag_signatures_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + let (_, mut identity) = issued(&ctx, "confirm-cross-tag").await?; + let old_thumb = identity.thumbprint.clone(); + let connect_tag = identity.key.sign_now(&old_thumb, "connect", None); + expect_error( + &ctx.target.signed_confirm(&connect_tag, None).await?, + 401, + "signature_invalid", + )?; + let csr = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": csr.csr, "metadata": {} }))?; + let renew_tag = identity.key.sign_now(&old_thumb, "renew", Some(&body)); + expect_error( + &ctx.target.signed_confirm(&renew_tag, None).await?, + 401, + "signature_invalid", + )?; + let new_key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &new_key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], new_key)?; + let csr = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": csr.csr, "metadata": {} }))?; + let mut confirm_tag = identity.key.sign_now(&identity.thumbprint, "confirm", None); + attach_content_digest(&mut confirm_tag, &body); + expect_error( + &ctx.target.signed_renew(&body, &confirm_tag, None).await?, + 401, + "signature_invalid", + )?; + let unchanged = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + unchanged.has_certificate(&old_thumb, CertificateStatus::Current) + && unchanged.has_certificate(&identity.thumbprint, CertificateStatus::Pending), + "cross-tag probes changed certificate statuses" + ); + Ok(()) +} + +pub(crate) async fn p_confirm_signature_rejected_on_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, mut identity) = issued(&ctx, "confirm-to-connect").await?; + let old_thumb = identity.thumbprint.clone(); + let confirm_tag = identity.key.sign_now(&identity.thumbprint, "confirm", None); + let failure = channel::open_with_headers(&ctx.target, &identity, confirm_tag) + .await + .err() + .context("confirm-tagged signature opened a channel")?; + channel::expect_refused_opening( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + let pending_key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &pending_key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], pending_key)?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .has_certificate(&identity.thumbprint, CertificateStatus::Pending), + "confirm-to-Connect fixture did not issue a pending certificate" + ); + let confirm_tag = identity.key.sign_now(&identity.thumbprint, "confirm", None); + let failure = channel::open_with_headers(&ctx.target, &identity, confirm_tag) + .await + .err() + .context("pending confirm-tagged signature opened a channel")?; + channel::expect_refused_opening( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + let unchanged = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + !unchanged.connected + && unchanged.has_certificate(&old_thumb, CertificateStatus::Current) + && unchanged.has_certificate(&identity.thumbprint, CertificateStatus::Pending), + "confirm-tagged channel probe changed certificate status or connected the identity" + ); + Ok(()) +} + +async fn next_last_seen_second(ctx: &Context) -> anyhow::Result<()> { + if ctx.mock() { + ctx.target.advance(2).await + } else { + tokio::time::sleep(Duration::from_secs(2)).await; + Ok(()) + } +} + +pub(crate) async fn p_check_in_current_and_pending(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, mut identity) = issued(&ctx, "check-in").await?; + let enrolled = identity.config.clone(); + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let initial_seen = before.last_seen_at.context("new identity has no last-seen time")?; + let created_at = before.created_at.context("full identity omitted the creation time")?; + ensure!( + enrolled["revision"].as_u64().is_some() && created_at <= initial_seen, + "enrollment omitted a config revision or the identity's initial last-seen time" + ); + next_last_seen_second(&ctx).await?; + let current = ctx + .target + .check_in(&identity, &json!({ "hostname": "current-check-in" })) + .await?; + expect_status(¤t, 200)?; + let after = identity_record(&ctx.target, &identity.identity_id).await?; + let current_seen = after + .last_seen_at + .context("current check-in did not set the last-seen time")?; + ensure!( + current.body["config"] == enrolled + && current.body["renewal_requested"] == false + && after.metadata_value("hostname") == Some("current-check-in") + && initial_seen < current_seen, + "current-certificate check-in did not return config or refresh metadata and the last-seen time" + ); + ctx.target.management().request_renewal(&identity.identity_id).await?; + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + let before_pending = identity_record(&ctx.target, &identity.identity_id).await?; + next_last_seen_second(&ctx).await?; + let pending = ctx + .target + .check_in(&identity, &json!({ "hostname": "pending-check-in" })) + .await?; + expect_status(&pending, 200)?; + let after_pending = identity_record(&ctx.target, &identity.identity_id).await?; + let pending_seen = after_pending + .last_seen_at + .context("pending check-in did not set the last-seen time")?; + ensure!( + pending.body["config"] == enrolled + && pending.body["renewal_requested"] == true + && after_pending.has_certificate(&identity.thumbprint, CertificateStatus::Pending) + && after_pending.metadata_value("hostname") == Some("pending-check-in") + && before_pending + .last_seen_at + .is_none_or(|previous| pending_seen > previous), + "pending-certificate check-in did not return config or refresh metadata and the last-seen time" + ); + let before_invalid = after_pending; + next_last_seen_second(&ctx).await?; + let signed = identity.key.sign_now(&identity.thumbprint, "check-in", Some(b"{}")); + expect_error( + &ctx.target.signed_check_in(b"{}", &signed).await?, + 400, + "invalid_request", + )?; + let undercovered_body = serde_json::to_vec(&json!({ "metadata": { "hostname": "undercovered" } }))?; + let mut undercovered = identity + .key + .sign_now(&identity.thumbprint, "check-in", Some(&undercovered_body)); + undercovered.input = undercovered + .input + .replace("(\"@method\" \"content-digest\")", "(\"@method\")"); + resign_input(&identity.key, &mut undercovered)?; + expect_error( + &ctx.target.signed_check_in(&undercovered_body, &undercovered).await?, + 401, + "signature_invalid", + )?; + let body = serde_json::to_vec(&json!({ "metadata": { "hostname": "untampered" } }))?; + let signed = identity.key.sign_now(&identity.thumbprint, "check-in", Some(&body)); + let tampered = serde_json::to_vec(&json!({ "metadata": { "hostname": "tampered" } }))?; + expect_error( + &ctx.target.signed_check_in(&tampered, &signed).await?, + 401, + "signature_invalid", + )?; + let after_invalid = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + after_invalid.metadata == before_invalid.metadata && after_invalid.last_seen_at == before_invalid.last_seen_at, + "invalid check-in changed metadata or the last-seen time" + ); + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let confirmed = ctx.target.check_in(&identity, &json!({})).await?; + expect_status(&confirmed, 200)?; + ensure!( + confirmed.body["renewal_requested"] == false && confirmed.body["config"] == enrolled, + "confirm did not clear the check-in renewal flag" + ); + Ok(()) +} + +pub(crate) async fn p_check_in_cross_tag_signatures_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §check-in + let (_, identity) = issued(&ctx, "check-in-cross-tag").await?; + let body = serde_json::to_vec(&json!({ "metadata": {} }))?; + let check_in_tag = identity.key.sign_now(&identity.thumbprint, "check-in", Some(&body)); + expect_error( + &ctx.target.signed_confirm(&check_in_tag, None).await?, + 401, + "signature_invalid", + )?; + expect_error( + &ctx.target.signed_renew(&body, &check_in_tag, None).await?, + 401, + "signature_invalid", + )?; + + for tag in ["connect", "confirm"] { + let mut headers = identity.key.sign_now(&identity.thumbprint, tag, None); + attach_content_digest(&mut headers, &body); + expect_error( + &ctx.target.signed_check_in(&body, &headers).await?, + 401, + "signature_invalid", + )?; + } + let csr = KeyPair::generate()?; + let renew_body = serde_json::to_vec(&json!({ "csr": csr.csr, "metadata": {} }))?; + let renew_tag = identity.key.sign_now(&identity.thumbprint, "renew", Some(&renew_body)); + expect_error( + &ctx.target.signed_check_in(&renew_body, &renew_tag).await?, + 401, + "signature_invalid", + )?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .certificates + .as_ref() + .is_some_and(|certs| certs.len() == 1 && certs[0].status == CertificateStatus::Current), + "cross-tag check-in probes changed certificate statuses" + ); + Ok(()) +} + +pub(crate) async fn p_check_in_signature_rejected_on_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "check-in-to-connect").await?; + let body = serde_json::to_vec(&json!({ "metadata": {} }))?; + let headers = identity.key.sign_now(&identity.thumbprint, "check-in", Some(&body)); + let failure = channel::open_with_headers(&ctx.target, &identity, headers) + .await + .err() + .context("check-in-tagged signature opened a channel")?; + channel::expect_refused_opening( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + ) +} + +fn resign_input(key: &KeyPair, headers: &mut SignedHeaders) -> anyhow::Result<()> { + let input = headers + .input + .split_once('=') + .map(|(_, value)| value) + .context("missing signature-input member")?; + let mut base = String::from("\"@method\": POST\n"); + if input + .split(';') + .next() + .is_some_and(|components| components.contains("\"content-digest\"")) + { + let digest = headers.digest.as_deref().context("missing content digest")?; + base.push_str(&format!("\"content-digest\": {digest}\n")); + } + base.push_str(&format!("\"@signature-params\": {input}")); + let signature: Signature = key.key.sign(base.as_bytes()); + headers.signature = format!( + "sig=:{}:", + base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()) + ); + Ok(()) +} + +fn sign_with_components( + identity: &Identity, + tag: &str, + body: Option<&[u8]>, + path: &str, + components: &[&str], +) -> anyhow::Result { + let mut signed = identity.key.sign_now(&identity.thumbprint, tag, body); + let profile = if body.is_some() { + "(\"@method\" \"content-digest\")" + } else { + "(\"@method\")" + }; + let covered = format!( + "({})", + components + .iter() + .map(|component| format!("\"{component}\"")) + .collect::>() + .join(" ") + ); + ensure!(signed.input.contains(profile), "unexpected signature profile"); + signed.input = signed.input.replacen(profile, &covered, 1); + let mut base = String::new(); + for component in components { + match *component { + "@method" => base.push_str("\"@method\": POST\n"), + "content-digest" => { + base.push_str(&format!( + "\"content-digest\": {}\n", + signed.digest.as_deref().context("missing content-digest")? + )); + } + "@path" => base.push_str(&format!("\"@path\": {path}\n")), + _ => anyhow::bail!("unexpected covered component {component}"), + } + } + base.push_str(&format!( + "\"@signature-params\": {}", + signed.input.strip_prefix("sig=").context("missing signature label")? + )); + let signature: Signature = identity.key.key.sign(base.as_bytes()); + identity.key.key.verifying_key().verify(base.as_bytes(), &signature)?; + signed.signature = format!( + "sig=:{}:", + base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()) + ); + Ok(signed) +} + +pub(crate) async fn p_same_tag_covered_components_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, identity) = issued(&ctx, "same-tag-components").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let http_prefix = reqwest::Url::parse(&ctx.target.base_url)? + .path() + .trim_end_matches('/') + .to_owned(); + let next_key = KeyPair::generate()?; + for tag in ["renew", "check-in", "confirm"] { + let path = format!("{http_prefix}/api/agent-identity/v1/{tag}"); + for variant in ["over-covered", "reordered", "duplicated"] { + let with_body = tag != "confirm"; + let components = match (variant, with_body) { + ("over-covered", true) => vec!["@method", "content-digest", "@path"], + ("over-covered", false) => vec!["@method", "@path"], + ("reordered", true) => vec!["content-digest", "@method"], + ("reordered", false) => continue, + ("duplicated", true) => vec!["@method", "content-digest", "content-digest"], + ("duplicated", false) => vec!["@method", "@method"], + _ => unreachable!("all component variants are known"), + }; + let body = match tag { + "renew" => Some(serde_json::to_vec( + &json!({ "csr": next_key.csr, "metadata": { "hostname": "must-not-change" } }), + )?), + "check-in" => Some(serde_json::to_vec( + &json!({ "metadata": { "hostname": "must-not-change" } }), + )?), + _ => None, + }; + let signed = sign_with_components(&identity, tag, body.as_deref(), &path, &components)?; + let reply = match tag { + "renew" => { + ctx.target + .signed_renew(body.as_deref().context("renew body")?, &signed, None) + .await? + } + "check-in" => { + ctx.target + .signed_check_in(body.as_deref().context("check-in body")?, &signed) + .await? + } + "confirm" => ctx.target.signed_confirm(&signed, None).await?, + _ => unreachable!("all HTTP tags are known"), + }; + expect_error(&reply, 401, "signature_invalid") + .with_context(|| format!("{variant} {tag} components were accepted"))?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "{variant} {tag} components changed identity state" + ); + } + } + Ok(()) +} + +pub(crate) async fn p_same_tag_connect_components_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "same-tag-connect-components").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let channel_prefix = reqwest::Url::parse( + identity + .agent_channel_url + .as_deref() + .context("same-tag connect requires a channel URL")?, + )? + .path() + .trim_end_matches('/') + .to_owned(); + let path = format!("{channel_prefix}/devolutions.agent.channel.v1.AgentChannel/Connect"); + for (variant, components) in [ + ("over-covered", &["@method", "@path"][..]), + ("duplicated", &["@method", "@method"][..]), + ] { + let signed = sign_with_components(&identity, "connect", None, &path, components)?; + let failure = channel::open_with_headers(&ctx.target, &identity, signed) + .await + .err() + .with_context(|| format!("{variant} connect components authenticated a channel"))?; + channel::expect_refused_opening( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "{variant} connect components changed identity state" + ); + } + Ok(()) +} + +pub(crate) async fn p_extra_signature_parameters_rejected_renew(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, identity) = issued(&ctx, "extra-renew-parameter").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": key.csr, "metadata": { "hostname": "unchanged" } }))?; + for parameter in [";conformance=\"extra\"", ";conformance"] { + let mut headers = identity.key.sign_now(&identity.thumbprint, "renew", Some(&body)); + headers.input.push_str(parameter); + resign_input(&identity.key, &mut headers)?; + expect_error( + &ctx.target.signed_renew(&body, &headers, None).await?, + 401, + "signature_invalid", + )?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "extra {parameter} renew signature parameter changed identity state" + ); + } + Ok(()) +} + +pub(crate) async fn p_extra_signature_parameters_rejected_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "extra-connect-parameter").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + for parameter in [";conformance=\"extra\"", ";conformance"] { + let mut headers = identity.key.sign_now(&identity.thumbprint, "connect", None); + headers.input.push_str(parameter); + resign_input(&identity.key, &mut headers)?; + let failure = channel::open_with_headers(&ctx.target, &identity, headers) + .await + .err() + .with_context(|| format!("extra {parameter} parameter authenticated a channel"))?; + channel::expect_refused_opening( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "extra {parameter} connect signature parameter changed identity state" + ); + } + Ok(()) +} + +pub(crate) async fn p_signature_parser_wire_negatives(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, identity) = issued(&ctx, "wire-negatives").await?; + let original = identity_record(&ctx.target, &identity.identity_id).await?; + let key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": key.csr, "metadata": { "hostname": "rejected" } }))?; + for variant in [ + "missing_digest", + "missing_created", + "missing_expires", + "missing_nonce", + "missing_keyid", + "missing_alg", + "missing_tag", + "wrong_alg", + "wrong_label", + "two_signatures", + "digest_not_covered", + "two_digest_members", + "bad_nonce", + "der_signature", + "truncated_signature", + ] { + let mut headers = identity.key.sign_now(&identity.thumbprint, "renew", Some(&body)); + match variant { + "missing_digest" => headers.digest = None, + "missing_created" | "missing_expires" | "missing_nonce" | "missing_keyid" | "missing_alg" + | "missing_tag" => { + let name = variant.strip_prefix("missing_").context("missing parameter")?; + headers.input = headers + .input + .split(';') + .filter(|param| !param.starts_with(&format!("{name}="))) + .collect::>() + .join(";"); + resign_input(&identity.key, &mut headers)?; + } + "wrong_alg" => { + headers.input = headers.input.replace("ecdsa-p256-sha256", "ecdsa-p384-sha384"); + resign_input(&identity.key, &mut headers)?; + } + "wrong_label" => { + headers.input = headers.input.replacen("sig=", "sig1=", 1); + headers.signature = headers.signature.replacen("sig=", "sig1=", 1); + } + "two_signatures" => { + let second_input = headers.input[4..].to_owned(); + let second_signature = headers.signature[4..].to_owned(); + headers.input.push_str(&format!(", sig2={second_input}")); + headers.signature.push_str(&format!(", sig2={second_signature}")); + } + "digest_not_covered" => { + headers.input = headers + .input + .replace("(\"@method\" \"content-digest\")", "(\"@method\")"); + resign_input(&identity.key, &mut headers)?; + } + "two_digest_members" => { + let digest = headers.digest.take().context("signed content digest")?; + let sha512 = base64::engine::general_purpose::STANDARD.encode(sha2::Sha512::digest(&body)); + headers.digest = Some(format!("{digest}, sha-512=:{sha512}:")); + resign_input(&identity.key, &mut headers)?; + } + "bad_nonce" => { + headers.input = headers.input.replace(&headers.nonce, "not-base64url!"); + resign_input(&identity.key, &mut headers)?; + } + "der_signature" | "truncated_signature" => { + let encoded = headers + .signature + .strip_prefix("sig=:") + .and_then(|value| value.strip_suffix(':')) + .context("signature encoding")?; + let raw = base64::engine::general_purpose::STANDARD.decode(encoded)?; + let signature = if variant == "der_signature" { + Signature::from_slice(&raw)?.to_der().as_bytes().to_vec() + } else { + raw[..raw.len() - 1].to_vec() + }; + headers.signature = format!("sig=:{}:", base64::engine::general_purpose::STANDARD.encode(signature)); + } + _ => anyhow::bail!("unknown wire-negative variant"), + } + expect_error( + &ctx.target.signed_renew(&body, &headers, None).await?, + 401, + "signature_invalid", + ) + .with_context(|| format!("wire-negative variant {variant}"))?; + let after = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + after.metadata == original.metadata + && after.certificates == original.certificates + && after.last_seen_at == original.last_seen_at, + "wire-negative variant {variant} changed identity state" + ); + } + let check_in_body = serde_json::to_vec(&json!({ "metadata": { "hostname": "rejected-check-in" } }))?; + let mut check_in_headers = identity + .key + .sign_now(&identity.thumbprint, "check-in", Some(&check_in_body)); + let digest = check_in_headers + .digest + .take() + .context("signed check-in digest missing")?; + let sha512 = base64::engine::general_purpose::STANDARD.encode(sha2::Sha512::digest(&check_in_body)); + check_in_headers.digest = Some(format!("{digest}, sha-512=:{sha512}:")); + resign_input(&identity.key, &mut check_in_headers)?; + expect_error( + &ctx.target.signed_check_in(&check_in_body, &check_in_headers).await?, + 401, + "signature_invalid", + )?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == original, + "correctly signed check-in with two Content-Digest members changed identity state" + ); + for (tag, request_body) in [("renew", &body), ("check-in", &check_in_body)] { + let repeated_fields: &[&str] = if tag == "renew" { + &["content-digest", "signature-input", "signature"] + } else { + &["content-digest"] + }; + for &repeated in repeated_fields { + let mut signed = identity.key.sign_now(&identity.thumbprint, tag, Some(request_body)); + let digest = signed.digest.as_deref().context("signed digest missing")?.to_owned(); + if repeated == "content-digest" { + signed.digest = Some(format!("{digest}, {digest}")); + resign_input(&identity.key, &mut signed)?; + } + let mut headers = http::HeaderMap::new(); + headers.insert("content-digest", digest.parse()?); + headers.insert("signature-input", signed.input.parse()?); + headers.insert("signature", signed.signature.parse()?); + let repeated_value = match repeated { + "content-digest" => &digest, + "signature-input" => &signed.input, + "signature" => &signed.signature, + _ => unreachable!("all repeated names are known"), + }; + headers.append(repeated, repeated_value.parse()?); + let response = ctx + .target + .http + .post(format!("{}/api/agent-identity/v1/{tag}", ctx.target.base_url)) + .headers(headers) + .header("content-type", "application/json") + .body(request_body.clone()) + .send() + .await?; + let reply = Reply { + status: response.status().as_u16(), + body: response.json().await?, + }; + expect_error(&reply, 401, "signature_invalid")?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == original, + "duplicate {repeated} headers on {tag} changed identity state" + ); + } + } + Ok(()) +} + +pub(crate) async fn p_signature_noncanonical_base64_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + let (_, identity) = issued(&ctx, "noncanonical-base64").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": key.csr, "metadata": { "hostname": "rejected" } }))?; + let sign = || identity.key.sign_now(&identity.thumbprint, "renew", Some(&body)); + for (variant, headers) in [ + ( + "signature with non-zero pad bits", + rewrite_signature(sign(), with_nonzero_pad_bits)?, + ), + ("unpadded signature", rewrite_signature(sign(), without_padding)?), + ( + "digest with non-zero pad bits", + rewrite_digest(&identity.key, sign(), with_nonzero_pad_bits)?, + ), + ( + "unpadded digest", + rewrite_digest(&identity.key, sign(), without_padding)?, + ), + ] { + expect_error( + &ctx.target.signed_renew(&body, &headers, None).await?, + 401, + "signature_invalid", + ) + .with_context(|| format!("renew {variant}"))?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "renew {variant} changed identity state" + ); + } + Ok(()) +} + +pub(crate) async fn p_signature_repeated_raw_items_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, identity) = issued(&ctx, "repeated-raw-items").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": key.csr, "metadata": { "hostname": "rejected" } }))?; + let sign = || identity.key.sign_now(&identity.thumbprint, "renew", Some(&body)); + for (variant, headers) in [ + ( + "tag parameter signed over the received member", + resigned(&identity.key, with_repeated_tag(sign(), "renew"))?, + ), + ( + "tag parameter signed over the member a last-wins parser keeps", + with_repeated_tag(sign(), "renew"), + ), + ("Signature-Input label in one field", with_repeated_input_label(sign())), + ("Signature label in one field", with_repeated_signature_label(sign())), + ( + "Content-Digest member signed over the received field", + resigned(&identity.key, with_repeated_digest_member(sign())?)?, + ), + ] { + expect_error( + &ctx.target.signed_renew(&body, &headers, None).await?, + 401, + "signature_invalid", + ) + .with_context(|| format!("renew with a repeated {variant}"))?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "renew with a repeated {variant} changed identity state" + ); + } + Ok(()) +} + +pub(crate) async fn p_connect_signature_noncanonical_or_repeated_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "connect-wire-negatives").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let sign = || identity.key.sign_now(&identity.thumbprint, "connect", None); + for (variant, headers) in [ + ( + "signature with non-zero pad bits", + rewrite_signature(sign(), with_nonzero_pad_bits)?, + ), + ("unpadded signature", rewrite_signature(sign(), without_padding)?), + ( + "repeated tag signed over the received member", + resigned(&identity.key, with_repeated_tag(sign(), "connect"))?, + ), + ( + "repeated tag signed over the member a last-wins parser keeps", + with_repeated_tag(sign(), "connect"), + ), + ( + "Signature-Input label repeated in one value", + with_repeated_input_label(sign()), + ), + ( + "Signature label repeated in one value", + with_repeated_signature_label(sign()), + ), + ] { + let failure = channel::open_with_headers(&ctx.target, &identity, headers) + .await + .err() + .with_context(|| format!("connect {variant} opened a channel"))?; + channel::expect_refused_opening( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + ) + .with_context(|| format!("connect {variant}"))?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "connect {variant} changed identity state" + ); + } + Ok(()) +} + +fn resigned(key: &KeyPair, mut headers: SignedHeaders) -> anyhow::Result { + resign_input(key, &mut headers)?; + Ok(headers) +} + +fn rewrite_signature( + mut headers: SignedHeaders, + rewrite: fn(&str) -> anyhow::Result, +) -> anyhow::Result { + headers.signature = rewrite(&headers.signature)?; + Ok(headers) +} + +/// Rewrites `Content-Digest` and signs the rewritten value, so only its encoding is wrong. +fn rewrite_digest( + key: &KeyPair, + mut headers: SignedHeaders, + rewrite: fn(&str) -> anyhow::Result, +) -> anyhow::Result { + headers.digest = Some(rewrite(headers.digest.as_deref().context("signed content digest")?)?); + resigned(key, headers) +} + +/// Appends a second, identical `tag` parameter without re-signing. +/// +/// The signature still covers the member that an RFC 8941 parser keeps, since it retains only the last occurrence. +fn with_repeated_tag(mut headers: SignedHeaders, tag: &str) -> SignedHeaders { + headers.input.push_str(&format!(";tag=\"{tag}\"")); + headers +} + +/// Repeats the whole `sig` member in one `Signature-Input` value; both copies match the signed member. +fn with_repeated_input_label(mut headers: SignedHeaders) -> SignedHeaders { + headers.input = format!("{0}, {0}", headers.input); + headers +} + +/// Repeats the whole `sig` member in one `Signature` value; both copies carry the valid signature. +fn with_repeated_signature_label(mut headers: SignedHeaders) -> SignedHeaders { + headers.signature = format!("{0}, {0}", headers.signature); + headers +} + +/// Repeats the `sha-256` member in one `Content-Digest` value without re-signing. +/// +/// A covered header's signature-base value is its raw field value (RFC 9421 §2.1), so only a signature over the repeated value isolates the duplicate rule. +fn with_repeated_digest_member(mut headers: SignedHeaders) -> anyhow::Result { + let digest = headers.digest.as_deref().context("signed content digest")?; + headers.digest = Some(format!("{digest}, {digest}")); + Ok(headers) +} + +/// Splits an RFC 8941 byte sequence field value such as `sig=::` around its base64 text. +fn split_byte_sequence(value: &str) -> anyhow::Result<(&str, &str)> { + let (prefix, rest) = value.split_once(':').context("field value has no byte sequence")?; + let encoded = rest.strip_suffix(':').context("byte sequence is not terminated")?; + Ok((prefix, encoded)) +} + +/// Sets a padding bit of the last base64 character, so lenient decoders still return the same bytes. +fn with_nonzero_pad_bits(value: &str) -> anyhow::Result { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let (prefix, encoded) = split_byte_sequence(value)?; + let data = encoded.trim_end_matches('='); + let padding = encoded.len() - data.len(); + ensure!(matches!(padding, 1 | 2), "byte sequence has no padding bits"); + let (head, last) = data.split_at(data.len() - 1); + let index = ALPHABET + .iter() + .position(|symbol| last.as_bytes() == [*symbol]) + .context("byte sequence is not base64")?; + ensure!(index & 1 == 0, "byte sequence already has non-zero padding bits"); + Ok(format!( + "{prefix}:{head}{}{}:", + char::from(ALPHABET[index | 1]), + "=".repeat(padding) + )) +} + +fn without_padding(value: &str) -> anyhow::Result { + let (prefix, encoded) = split_byte_sequence(value)?; + let data = encoded.trim_end_matches('='); + ensure!(data.len() < encoded.len(), "byte sequence has no padding"); + Ok(format!("{prefix}:{data}:")) +} + +pub(crate) async fn p_renew_happy_path(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + let (_, mut identity) = issued(&ctx, "unchanged").await?; + let original_name = identity_record(&ctx.target, &identity.identity_id).await?.friendly_name; + let new_key = KeyPair::generate()?; + let reply = signed_renew(&ctx.target, &identity, &new_key, &json!({ "hostname": "updated" })).await?; + expect_status(&reply, 200)?; + let new_chain = reply.body["certificate_chain"] + .as_array() + .context("renew chain")? + .iter() + .map(|item| item.as_str().context("chain item").map(str::to_owned)) + .collect::>>()?; + ensure!(!new_chain.is_empty(), "renewal returned no certificate"); + verify_chain( + &new_chain, + &ctx.target.trust_anchor().await?, + chain_evaluation_time(&ctx).await?, + )?; + assert_leaf_profile( + &new_chain[0], + new_chain.last().context("missing renewal issuer")?, + &identity.identity_id, + &new_key, + ctx.leaf_lifetime_secs, + )?; + identity.adopt_certificate(&reply.body["certificate_chain"], new_key)?; + let pending = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + pending.has_certificate(&identity.thumbprint, CertificateStatus::Pending), + "new certificate not pending" + ); + ensure!( + pending.friendly_name == original_name, + "friendly name changed on renewal" + ); + ensure!( + pending.metadata_value("hostname") == Some("updated"), + "renewal metadata not updated" + ); + let replay_key = KeyPair { + key: identity.key.key.clone(), + csr: identity.key.csr.clone(), + }; + if ctx.mock() { + ctx.target.advance(2).await?; + } + expect_status( + &ctx.target + .renew(&identity, &replay_key, &json!({ "hostname": "pending-replay" })) + .await?, + 200, + )?; + let replayed = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + replayed.has_certificate(&identity.thumbprint, CertificateStatus::Pending) + && replayed.metadata_value("hostname") == Some("pending-replay"), + "replayed renew did not update metadata or promoted the pending certificate" + ); + if ctx.mock() { + ensure!( + replayed.last_seen_at > pending.last_seen_at, + "replayed renew did not refresh the last-seen time" + ); + } + ensure!( + replayed.friendly_name == original_name, + "replayed renew changed friendly name" + ); + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .has_certificate(&identity.thumbprint, CertificateStatus::Current), + "confirm did not promote the pending certificate" + ); + Ok(()) +} + +pub(crate) async fn p_confirm_promotes_and_is_idempotent(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + let (_, mut identity) = issued(&ctx, "confirm-idempotent").await?; + let old_thumb = identity.thumbprint.clone(); + let original = identity_record(&ctx.target, &identity.identity_id).await?; + let initial_seen = original.last_seen_at.context("new identity has no last-seen time")?; + let original_events = if ctx.mock() { + Some(ctx.target.events(&identity.identity_id).await?) + } else { + None + }; + next_last_seen_second(&ctx).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let already_current = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + already_current.certificates == original.certificates + && initial_seen + < already_current + .last_seen_at + .context("current confirm omitted the last-seen time")?, + "confirm with an already current certificate changed status or did not refresh the last-seen time" + ); + if let Some(events) = &original_events { + ensure!( + ctx.target.events(&identity.identity_id).await? == *events, + "confirm with an already current certificate emitted a lifecycle event" + ); + } + + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + let before_promotion = identity_record(&ctx.target, &identity.identity_id).await?; + next_last_seen_second(&ctx).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let promoted = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + promoted.has_certificate(&old_thumb, CertificateStatus::Retired) + && promoted.has_certificate(&identity.thumbprint, CertificateStatus::Current) + && before_promotion + .last_seen_at + .context("renew omitted the last-seen time")? + < promoted + .last_seen_at + .context("pending confirm omitted the last-seen time")?, + "confirm did not promote pending, retire current or refresh the last-seen time" + ); + let events = if ctx.mock() { + Some(ctx.target.events(&identity.identity_id).await?) + } else { + None + }; + next_last_seen_second(&ctx).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let replayed = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + replayed.certificates == promoted.certificates + && promoted + .last_seen_at + .context("confirmed identity omitted the last-seen time")? + < replayed + .last_seen_at + .context("confirm retry omitted the last-seen time")?, + "idempotent confirm retry changed certificate statuses or failed to refresh the last-seen time" + ); + if let Some(events) = events { + ensure!( + ctx.target.events(&identity.identity_id).await? == events, + "idempotent confirm retry emitted another promotion" + ); + } + Ok(()) +} + +pub(crate) async fn p_confirm_retired_certificate_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (_, identity) = issued(&ctx, "retired-confirm").await?; + let first_key = KeyPair::generate()?; + let first = ctx.target.renew(&identity, &first_key, &json!({})).await?; + expect_status(&first, 200)?; + let first_thumb = thumbprint( + first.body["certificate_chain"][0] + .as_str() + .context("first pending leaf")?, + )?; + let second = ctx.target.renew(&identity, &KeyPair::generate()?, &json!({})).await?; + expect_status(&second, 200)?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + before.has_certificate(&first_thumb, CertificateStatus::Retired), + "replacement did not retire the first pending certificate" + ); + let signed = first_key.sign_now(&first_thumb, "confirm", None); + expect_error( + &ctx.target.signed_confirm(&signed, None).await?, + 401, + "identity_unknown", + )?; + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &before).await?; + Ok(()) +} + +pub(crate) async fn p_confirm_expired_pending_certificate_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + ctx.target.faults(&json!({ "leaf_lifetime_secs": 20 })).await?; + let (_, mut identity) = issued(&ctx, "expired-confirm").await?; + let old_thumb = identity.thumbprint.clone(); + let old_key = KeyPair { + key: identity.key.key.clone(), + csr: identity.key.csr.clone(), + }; + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + ctx.target.advance(21).await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + expect_error(&ctx.target.confirm(&identity).await?, 401, "certificate_expired")?; + expect_error( + &ctx.target + .check_in(&identity, &json!({ "hostname": "expired-pending" })) + .await?, + 401, + "certificate_expired", + )?; + let old_confirm = old_key.sign_now(&old_thumb, "confirm", None); + expect_error( + &ctx.target.signed_confirm(&old_confirm, None).await?, + 401, + "certificate_expired", + )?; + let old_body = serde_json::to_vec(&json!({ "metadata": { "hostname": "expired-current" } }))?; + let old_check_in = old_key.sign_now(&old_thumb, "check-in", Some(&old_body)); + expect_error( + &ctx.target.signed_check_in(&old_body, &old_check_in).await?, + 401, + "certificate_expired", + )?; + let after = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + after.certificates == before.certificates + && after.metadata == before.metadata + && after.last_seen_at == before.last_seen_at + && after.has_certificate(&old_thumb, CertificateStatus::Current) + && after.has_certificate(&identity.thumbprint, CertificateStatus::Pending), + "expired current or pending check-in/confirm changed identity state" + ); + Ok(()) +} + +pub(crate) async fn p_confirm_nonempty_body_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + let (_, mut identity) = issued(&ctx, "confirm-body").await?; + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let signed = identity.key.sign_now(&identity.thumbprint, "confirm", None); + expect_error( + &ctx.target.signed_confirm(&signed, Some(b"{}")).await?, + 400, + "invalid_request", + )?; + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &before).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + Ok(()) +} + +pub(crate) async fn p_confirm_faults_do_not_duplicate_promotion(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + // oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control + let (_, mut identity) = issued(&ctx, "confirm-faults").await?; + let old_thumb = identity.thumbprint.clone(); + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + expect_status( + &ctx.target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": true })) + .await?, + 200, + )?; + ctx.target + .faults(&json!({ "fail_next_response": { "endpoint": "confirm", "status": 503 } })) + .await?; + expect_status(&ctx.target.confirm(&identity).await?, 503)?; + expect_status(&ctx.target.confirm(&identity).await?, 503)?; + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &before).await?; + expect_status( + &ctx.target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": false })) + .await?, + 200, + )?; + + ctx.target.faults(&json!({ "drop_next_response": "confirm" })).await?; + ensure!( + ctx.target.confirm(&identity).await.is_err(), + "dropped confirm returned a response" + ); + let promoted = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + promoted.has_certificate(&old_thumb, CertificateStatus::Retired) + && promoted.has_certificate(&identity.thumbprint, CertificateStatus::Current), + "dropped confirm did not commit the promotion" + ); + let events = ctx.target.events(&identity.identity_id).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let requests = ctx.target.requests(None).await?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await?.certificates == promoted.certificates + && ctx.target.events(&identity.identity_id).await? == events + && count(&requests, "confirm")? == 4 + && count(&requests, "confirm_retry_503")? == 1 + && requests["request_sequence"].as_array().is_some_and(|sequence| sequence + .iter() + .filter(|entry| *entry == "confirm_204") + .count() + == 2), + "idempotent confirm retry duplicated the promotion" + ); + Ok(()) +} + +pub(crate) async fn p_renew_idempotent_lost_response(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + let (_, identity) = issued(&ctx, "lost-renew").await?; + let new_key = KeyPair::generate()?; + expect_status( + &ctx.target + .control("retry-barrier", &json!({ "endpoint": "renew", "pause": true })) + .await?, + 200, + )?; + ctx.target.faults(&json!({ "drop_next_response": "renew" })).await?; + let first = ctx.target.renew(&identity, &new_key, &json!({})).await; + ensure!(first.is_err(), "fault did not drop the renew response"); + let pending = identity_record(&ctx.target, &identity.identity_id).await?; + let pending_certs = pending + .certificates + .as_ref() + .context("missing certificates")? + .iter() + .filter(|cert| cert.status == CertificateStatus::Pending) + .collect::>(); + ensure!( + pending_certs.len() == 1, + "lost response did not commit exactly one pending certificate" + ); + expect_status(&ctx.target.renew(&identity, &new_key, &json!({})).await?, 503)?; + ensure!( + count(&ctx.target.requests(None).await?, "renew_retry_503")? == 1, + "mock did not block the renewal retry" + ); + expect_status( + &ctx.target + .control("retry-barrier", &json!({ "endpoint": "renew", "pause": false })) + .await?, + 200, + )?; + let retry = ctx.target.renew(&identity, &new_key, &json!({})).await?; + expect_status(&retry, 200)?; + let retry_thumbprint = thumbprint(retry.body["certificate_chain"][0].as_str().context("retry leaf")?)?; + ensure!( + pending_certs[0].thumbprint == retry_thumbprint, + "renew retry issued a second certificate" + ); + Ok(()) +} + +pub(crate) async fn p_mock_enroll_retry_barrier(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control + let token = token(&ctx, 1).await?; + let key = KeyPair::generate()?; + expect_status( + &ctx.target + .control("retry-barrier", &json!({ "endpoint": "enroll", "pause": true })) + .await?, + 200, + )?; + ctx.target.faults(&json!({ "drop_next_response": "enroll" })).await?; + ensure!( + ctx.target.enroll(&token.text, &key, &json!({})).await.is_err(), + "fault did not drop the enrollment response" + ); + let listing = ctx + .target + .management() + .identities(&IdentityQuery::enrolled_with(&token)) + .await?; + ensure!( + listing.total_count == 1 && ctx.target.management().token(&token.id).await?.used_count == 1, + "dropped enrollment did not commit once" + ); + expect_status(&ctx.target.enroll(&token.text, &key, &json!({})).await?, 503)?; + ensure!( + count(&ctx.target.requests(Some(&token)).await?, "enroll_retry_503")? == 1, + "mock did not block the enrollment retry" + ); + expect_status( + &ctx.target + .control("retry-barrier", &json!({ "endpoint": "enroll", "pause": false })) + .await?, + 200, + )?; + let replay = ctx.target.enroll(&token.text, &key, &json!({})).await?; + expect_status(&replay, 200)?; + ensure!( + replay.body["identity_id"] == listing.items[0].id + && ctx.target.management().token(&token.id).await?.used_count == 1, + "replay created a second identity or consumed another use" + ); + ctx.target + .management() + .revoke(field(&replay.body, "identity_id")?) + .await?; + expect_error( + &ctx.target.enroll(&token.text, &key, &json!({})).await?, + 403, + "identity_revoked", + )?; + ensure!( + count(&ctx.target.requests(Some(&token)).await?, "enroll_identity_revoked")? == 1, + "mock did not record the revoked-key enrollment rejection" + ); + Ok(()) +} + +pub(crate) async fn p_renew_second_pending_retires_first(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + let (_, identity) = issued(&ctx, "pending").await?; + let first_key = KeyPair::generate()?; + let first = ctx.target.renew(&identity, &first_key, &json!({})).await?; + expect_status(&first, 200)?; + let first_thumb = thumbprint(first.body["certificate_chain"][0].as_str().context("first leaf")?)?; + let second_key = KeyPair::generate()?; + let second = ctx.target.renew(&identity, &second_key, &json!({})).await?; + expect_status(&second, 200)?; + let second_thumb = thumbprint(second.body["certificate_chain"][0].as_str().context("second leaf")?)?; + let current = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + current.has_certificate(&first_thumb, CertificateStatus::Retired), + "first pending certificate was not retired" + ); + ensure!( + current.has_certificate(&second_thumb, CertificateStatus::Pending), + "second pending certificate missing" + ); + let old = Identity { + thumbprint: first_thumb, + certificate_chain: first.body["certificate_chain"] + .as_array() + .context("first chain")? + .iter() + .map(|c| c.as_str().context("chain item").map(str::to_owned)) + .collect::>()?, + key: first_key, + ..identity + }; + expect_error( + &ctx.target.renew(&old, &KeyPair::generate()?, &json!({})).await?, + 401, + "identity_unknown", + )?; + Ok(()) +} + +pub(crate) async fn p_retired_pending_certificate_cannot_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "retired-connect").await?; + let first_key = KeyPair::generate()?; + let first = ctx.target.renew(&identity, &first_key, &json!({})).await?; + expect_status(&first, 200)?; + expect_status( + &ctx.target.renew(&identity, &KeyPair::generate()?, &json!({})).await?, + 200, + )?; + let old = Identity { + thumbprint: thumbprint(first.body["certificate_chain"][0].as_str().context("retired leaf")?)?, + certificate_chain: first.body["certificate_chain"] + .as_array() + .context("retired chain")? + .iter() + .map(|cert| cert.as_str().context("chain item").map(str::to_owned)) + .collect::>>()?, + key: first_key, + ..identity + }; + let error = match channel::open(&ctx.target, &old).await { + Ok(_) => anyhow::bail!("retired pending certificate opened a channel"), + Err(error) => error, + }; + let status = error + .downcast_ref::() + .context("no gRPC status for retired certificate")?; + channel::expect_refused_opening(status, Code::Unauthenticated, "identity_unknown")?; + Ok(()) +} + +pub(crate) async fn p_renew_within_grace(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §renew + ctx.target.faults(&json!({ "leaf_lifetime_secs": 4 })).await?; + let (_, identity) = issued(&ctx, "grace").await?; + ctx.target.advance(5).await?; + let key = KeyPair::generate()?; + let reply = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&reply, 200)?; + let chain = reply.body["certificate_chain"] + .as_array() + .context("grace renewal chain missing")? + .iter() + .map(|value| value.as_str().context("grace chain item").map(str::to_owned)) + .collect::>>()?; + verify_chain( + &chain, + &ctx.target.trust_anchor().await?, + chain_evaluation_time(&ctx).await?, + )?; + assert_leaf_profile( + chain.first().context("grace leaf missing")?, + chain.last().context("grace root missing")?, + &identity.identity_id, + &key, + 4, + )?; + Ok(()) +} + +pub(crate) async fn p_renew_beyond_grace(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + ctx.target.faults(&json!({ "leaf_lifetime_secs": 4 })).await?; + let (_, identity) = issued(&ctx, "too-late").await?; + ctx.target.advance(9).await?; + let reply = ctx.target.renew(&identity, &KeyPair::generate()?, &json!({})).await?; + expect_error(&reply, 401, "certificate_expired")?; + Ok(()) +} + +pub(crate) async fn p_revocation_blocks_renew_and_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + let (_, identity) = issued(&ctx, "revoke").await?; + ctx.target.management().revoke(&identity.identity_id).await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let reply = ctx.target.renew(&identity, &KeyPair::generate()?, &json!({})).await?; + expect_error(&reply, 403, "identity_revoked")?; + expect_error(&ctx.target.confirm(&identity).await?, 403, "identity_revoked")?; + expect_error( + &ctx.target + .check_in(&identity, &json!({ "hostname": "should-not-update" })) + .await?, + 403, + "identity_revoked", + )?; + let next = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": next.csr, "metadata": { "hostname": "correct" } }))?; + let signed = identity.key.sign_now(&identity.thumbprint, "renew", Some(&body)); + let altered = serde_json::to_vec(&json!({ "csr": next.csr, "metadata": { "hostname": "tampered" } }))?; + expect_error( + &ctx.target.signed_renew(&altered, &signed, None).await?, + 403, + "identity_revoked", + )?; + let wrong_signer = KeyPair::generate()?.sign_now(&identity.thumbprint, "renew", Some(&body)); + expect_error( + &ctx.target.signed_renew(&body, &wrong_signer, None).await?, + 403, + "identity_revoked", + )?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "revoked renew, confirm or check-in changed identity state" + ); + Ok(()) +} + +pub(crate) async fn p_revocation_blocks_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "revoked-connect").await?; + ctx.target.management().revoke(&identity.identity_id).await?; + let status = channel::open(&ctx.target, &identity) + .await + .err() + .context("revoked cert connected")?; + channel::expect_refused_opening( + status + .downcast_ref::() + .context("revoked open did not yield gRPC status")?, + Code::PermissionDenied, + "identity_revoked", + )?; + Ok(()) +} + +pub(crate) async fn p_delete_only_when_revoked_then_unknown(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Identities + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let (token, identity) = issued(&ctx, "delete").await?; + let management = ctx.target.management(); + ensure!( + refusal(management.delete_identity(&identity.identity_id).await)? == Refusal::Conflict, + "an identity that is not revoked was deleted" + ); + management.revoke(&identity.identity_id).await?; + management.delete_identity(&identity.identity_id).await?; + let reply = ctx.target.renew(&identity, &KeyPair::generate()?, &json!({})).await?; + expect_error(&reply, 401, "identity_unknown")?; + expect_error(&ctx.target.confirm(&identity).await?, 401, "identity_unknown")?; + expect_error( + &ctx.target + .check_in(&identity, &json!({ "hostname": "should-not-update" })) + .await?, + 401, + "identity_unknown", + )?; + let next = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": next.csr, "metadata": { "hostname": "correct" } }))?; + let signed = identity.key.sign_now(&identity.thumbprint, "renew", Some(&body)); + let altered = serde_json::to_vec(&json!({ "csr": next.csr, "metadata": { "hostname": "tampered" } }))?; + expect_error( + &ctx.target.signed_renew(&altered, &signed, None).await?, + 401, + "identity_unknown", + )?; + let wrong_signer = KeyPair::generate()?.sign_now(&identity.thumbprint, "renew", Some(&body)); + expect_error( + &ctx.target.signed_renew(&body, &wrong_signer, None).await?, + 401, + "identity_unknown", + )?; + ensure!( + ctx.target + .management() + .find_identity(&identity.identity_id) + .await? + .is_none() + && ctx.target.management().identity_count(&token).await? == 0 + && ctx.target.management().token(&token.id).await?.used_count == 1, + "deleted key was reintroduced by renew, confirm or check-in" + ); + Ok(()) +} + +pub(crate) async fn p_deleted_certificate_cannot_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "deleted-connect").await?; + ctx.target.management().revoke(&identity.identity_id).await?; + ctx.target.management().delete_identity(&identity.identity_id).await?; + let status = channel::open(&ctx.target, &identity) + .await + .err() + .context("deleted cert connected")?; + channel::expect_refused_opening( + status + .downcast_ref::() + .context("deleted open did not yield gRPC status")?, + Code::Unauthenticated, + "identity_unknown", + )?; + Ok(()) +} + +pub(crate) async fn p_channel_hello_updates_metadata_and_connected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Identities + let (_, identity) = issued(&ctx, "initial").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + let challenge = stream.challenge().await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!(!before.connected, "identity connected before Hello"); + ensure!( + before.metadata_value("hostname") == Some("initial"), + "metadata updated before Hello" + ); + if ctx.mock() { + ctx.target.advance(2).await?; + } else { + tokio::time::sleep(Duration::from_millis(1100)).await; + } + let hello_id = stream + .send_hello( + &identity, + &challenge, + &[("hostname", "channel"), ("arch", "x86_64")], + None, + ) + .await?; + let welcome = stream.next().await?; + let Some(agent_channel_proto::server_message::Payload::Welcome(payload)) = welcome.payload else { + anyhow::bail!("channel did not send Welcome"); + }; + ensure!( + welcome.correlation_id.as_deref() == Some(hello_id.as_str()), + "Welcome correlation is wrong" + ); + let server_time = payload.server_time.context("Welcome omitted server_time")?; + let after = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!(after.connected, "identity not connected after Hello"); + ensure!( + after.metadata_value("hostname") == Some("channel") && after.metadata_value("arch") == Some("x86_64"), + "Hello metadata not stored" + ); + let after_time = after + .last_seen_at + .context("Hello did not set the last-seen time")? + .unix_timestamp(); + if let Some(before_time) = before.last_seen_at { + ensure!( + after_time > before_time.unix_timestamp(), + "Hello did not advance the last-seen time" + ); + } + ensure!( + server_time.seconds.abs_diff(after_time) <= 2, + "Welcome server_time disagrees with the last-seen time" + ); + Ok(()) +} + +pub(crate) async fn p_channel_agent_message_refreshes_last_seen(ctx: Context) -> anyhow::Result<()> { + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Configuration and metadata + let (_, identity) = issued(&ctx, "agent-message").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let seen_before = before + .last_seen_at + .context("authenticated Hello omitted the last-seen time")?; + next_last_seen_second(&ctx).await?; + ctx.target.management().request_renewal(&identity.identity_id).await?; + let request = stream.next().await?; + ensure!( + matches!( + request.payload.as_ref(), + Some(agent_channel_proto::server_message::Payload::RenewRequested(_)) + ), + "authenticated stream did not receive RenewRequested" + ); + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .last_seen_at + .context("management API request omitted the last-seen time")? + == seen_before, + "management API request or server push refreshed the last-seen time before AgentMessage" + ); + stream.ack(&request).await?; + let started = Instant::now(); + loop { + let after = identity_record(&ctx.target, &identity.identity_id).await?; + if seen_before < after.last_seen_at.context("AgentMessage omitted the last-seen time")? { + break; + } + ensure!( + started.elapsed() < Duration::from_secs(5), + "authenticated AgentMessage did not refresh the last-seen time" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + Ok(()) +} + +pub(crate) async fn p_handshake_barrier_holds_authentication(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + let (_, identity) = issued(&ctx, "barrier").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + expect_status(&ctx.target.control("handshake", &json!({ "pause": true })).await?, 200)?; + let mut stream = channel::open(&ctx.target, &identity).await?; + let challenge = stream.challenge().await?; + let hello_id = stream + .send_hello(&identity, &challenge, &[("hostname", "after-pause")], None) + .await?; + let started = Instant::now(); + loop { + if count(&ctx.target.requests(None).await?, "paused_hellos")? == 1 { + break; + } + ensure!( + started.elapsed() < Duration::from_secs(2), + "valid Hello never reached the handshake barrier" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let paused = ctx.target.paused_stream_ids().await?; + ensure!( + paused.len() == 1 + && ctx + .target + .events(&identity.identity_id) + .await? + .iter() + .any(|event| { event["type"] == "stream_opened" && event["stream_id"] == paused[0] }), + "mock handshake did not identify the held stream" + ); + let waiting = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + !waiting.connected + && waiting.metadata == before.metadata + && waiting.last_seen_at == before.last_seen_at + && ctx + .target + .events(&identity.identity_id) + .await? + .iter() + .all(|event| event["type"] != "stream_authenticated"), + "held Hello authenticated or changed identity state before release" + ); + expect_status(&ctx.target.control("handshake", &json!({ "pause": false })).await?, 200)?; + let welcome = stream.next().await?; + ensure!( + matches!( + welcome.payload, + Some(agent_channel_proto::server_message::Payload::Welcome(_)) + ) && welcome.correlation_id.as_deref() == Some(hello_id.as_str()), + "released Hello did not receive its Welcome" + ); + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .metadata_value("hostname") + == Some("after-pause") + && ctx.target.paused_stream_ids().await?.is_empty(), + "handshake release did not authenticate and clear the barrier" + ); + Ok(()) +} + +pub(crate) async fn p_channel_proof_replay_fails(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Security invariants + let (_, identity) = issued(&ctx, "untouched").await?; + let (_, other) = issued(&ctx, "other-device").await?; + let mut first = channel::open(&ctx.target, &identity).await?; + let first_challenge = first.challenge().await?; + let Some(agent_channel_proto::server_message::Payload::Challenge(first_challenge_bytes)) = + first_challenge.payload.as_ref() + else { + anyhow::bail!("first stream missing challenge"); + }; + let original_challenge = first_challenge_bytes.challenge.clone(); + let original_nonce = first.headers.nonce.clone(); + let mut challenges = HashSet::from([original_challenge.clone()]); + let mut nonces = HashSet::from([original_nonce.clone()]); + let captured = identity.key.channel_proof(&original_challenge, &original_nonce); + drop(first); + + if ctx.mock() { + ctx.target.advance(2).await?; + } else { + tokio::time::sleep(Duration::from_millis(1100)).await; + } + let initial = identity_record(&ctx.target, &identity.identity_id).await?; + let mut replay_headers = None; + for variant in [ + "wrong_challenge", + "wrong_nonce", + "wrong_key", + "wrong_correlation", + "captured_proof", + ] { + let mut stream = channel::open(&ctx.target, &identity).await?; + let challenge = stream.challenge().await?; + let Some(agent_channel_proto::server_message::Payload::Challenge(payload)) = challenge.payload.as_ref() else { + anyhow::bail!("stream missing challenge"); + }; + ensure!( + challenges.insert(payload.challenge.clone()), + "channel reused a challenge from another stream" + ); + ensure!( + nonces.insert(stream.headers.nonce.clone()), + "tester reused a connect nonce" + ); + let proof = match variant { + "wrong_challenge" => identity.key.channel_proof(&original_challenge, &stream.headers.nonce), + "wrong_nonce" => identity.key.channel_proof(&payload.challenge, &original_nonce), + "wrong_key" => other.key.channel_proof(&payload.challenge, &stream.headers.nonce), + "wrong_correlation" => identity.key.channel_proof(&payload.challenge, &stream.headers.nonce), + _ => captured.clone(), + }; + if variant == "wrong_correlation" { + stream + .send(&agent_channel_proto::AgentMessage { + id: uuid::Uuid::new_v4().to_string(), + correlation_id: Some(uuid::Uuid::new_v4().to_string()), + payload: Some(agent_channel_proto::agent_message::Payload::Hello( + agent_channel_proto::Hello { + metadata: [("hostname".to_owned(), "untrusted".to_owned())].into(), + capabilities: Vec::new(), + applied_state_versions: Default::default(), + proof, + }, + )), + }) + .await + .context("send miscorrelated Hello")?; + } else { + stream + .send_hello(&identity, &challenge, &[("hostname", "untrusted")], Some(proof)) + .await?; + } + let rejection = stream.closing_status(Duration::from_secs(3)).await?; + channel::expect_status(&rejection, Code::Unauthenticated, "signature_invalid") + .with_context(|| format!("variant {variant}"))?; + let record = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!(!record.connected, "{variant} connected the identity"); + ensure!( + record.last_seen_at == initial.last_seen_at, + "{variant} updated the last-seen time" + ); + ensure!(record.metadata == initial.metadata, "{variant} changed metadata"); + replay_headers = Some(stream.headers); + } + let replayed = channel::open_with_headers(&ctx.target, &identity, replay_headers.context("no opening headers")?) + .await + .err() + .context("reused opening headers were accepted")?; + channel::expect_refused_opening( + replayed + .downcast_ref::() + .context("missing replay gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + Ok(()) +} + +/// What a test sends on a stream that awaits `Hello`, to check that a closure of [CONTRACT.agent-identity.md] §7.4 comes ahead of every §7.3 step 4 failure. +/// +/// [CONTRACT.agent-identity.md]: ../../../docs/agent-identity/CONTRACT.agent-identity.md +#[derive(Clone, Copy, Debug)] +enum AwaitedHello { + /// A `Hello` whose proof verifies. + Valid, + /// A `Hello` whose proof does not verify. + BadProof, + /// Nothing, so the 10-second `Hello` deadline passes. + Missing, +} + +/// Opens one stream per variant and reads its `Challenge`, so that every stream awaits `Hello`. +async fn streams_awaiting_hello( + ctx: &Context, + identity: &Identity, + variants: &[AwaitedHello], +) -> anyhow::Result> { + let mut streams = Vec::new(); + for &variant in variants { + let mut stream = channel::open(&ctx.target, identity).await?; + let challenge = stream.challenge().await?; + streams.push((variant, stream, challenge)); + } + Ok(streams) +} + +/// Sends the variant's `Hello`, if any, and returns the status that closes the stream. +async fn close_awaiting_hello( + identity: &Identity, + stream: &mut channel::ChannelStream, + challenge: &agent_channel_proto::ServerMessage, + variant: AwaitedHello, +) -> anyhow::Result { + let proof = match variant { + AwaitedHello::Valid => None, + AwaitedHello::BadProof => Some(vec![0; 64]), + AwaitedHello::Missing => return stream.closing_status(Duration::from_secs(11)).await, + }; + // The closing status decides the outcome, and it also explains a stream that closed before this send. + let _ = stream + .send_hello(identity, challenge, &[("hostname", "awaiting-hello")], proof) + .await; + stream.closing_status(Duration::from_secs(3)).await +} + +pub(crate) async fn p_channel_revoked_before_hello(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "before-revocation").await?; + let streams = streams_awaiting_hello( + &ctx, + &identity, + &[AwaitedHello::Valid, AwaitedHello::BadProof, AwaitedHello::Missing], + ) + .await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!(!before.connected, "identity connected before Hello"); + let authenticated = if ctx.mock() { + Some(count(&ctx.target.requests(None).await?, "authenticated_connects")?) + } else { + None + }; + ctx.target.management().revoke(&identity.identity_id).await?; + let revoked = identity_record(&ctx.target, &identity.identity_id).await?; + for (variant, mut stream, challenge) in streams { + let rejected = close_awaiting_hello(&identity, &mut stream, &challenge, variant).await?; + channel::expect_status(&rejected, Code::PermissionDenied, "identity_revoked") + .and_then(|()| channel::expect_server_time(&rejected)) + .with_context(|| format!("revoked stream awaiting Hello, {variant:?} Hello"))?; + } + let after = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + !after.connected + && after.metadata == revoked.metadata + && after.certificates == revoked.certificates + && after.last_seen_at == before.last_seen_at, + "revoked stream changed identity state before Welcome" + ); + if let Some(authenticated) = authenticated { + ensure!( + count(&ctx.target.requests(None).await?, "authenticated_connects")? == authenticated, + "revoked stream was registered as authenticated" + ); + } + Ok(()) +} + +/// A stream awaiting `Hello` whose identity is revoked and then deleted closes as `identity_unknown` ([CONTRACT.agent-identity.md] §7.4). +/// +/// The authority checks revocation and deletion when `Hello` arrives, so both changes precede the check when the test sends `Hello` after them. +/// +/// [CONTRACT.agent-identity.md]: ../../../docs/agent-identity/CONTRACT.agent-identity.md +pub(crate) async fn p_channel_deleted_before_hello(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "before-deletion").await?; + let streams = streams_awaiting_hello(&ctx, &identity, &[AwaitedHello::Valid, AwaitedHello::BadProof]).await?; + let authenticated = if ctx.mock() { + Some(count(&ctx.target.requests(None).await?, "authenticated_connects")?) + } else { + None + }; + ctx.target.management().revoke(&identity.identity_id).await?; + ctx.target.management().delete_identity(&identity.identity_id).await?; + for (variant, mut stream, challenge) in streams { + let rejected = close_awaiting_hello(&identity, &mut stream, &challenge, variant).await?; + channel::expect_status(&rejected, Code::Unauthenticated, "identity_unknown") + .and_then(|()| channel::expect_server_time(&rejected)) + .with_context(|| format!("deleted stream awaiting Hello, {variant:?} Hello"))?; + } + ensure!( + ctx.target + .management() + .find_identity(&identity.identity_id) + .await? + .is_none(), + "a Hello for a deleted identity restored it" + ); + if let Some(authenticated) = authenticated { + ensure!( + count(&ctx.target.requests(None).await?, "authenticated_connects")? == authenticated, + "deleted stream was registered as authenticated" + ); + } + Ok(()) +} + +pub(crate) async fn p_channel_no_hello_timeout(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + let (_, identity) = issued(&ctx, "no-hello").await?; + let initial = identity_record(&ctx.target, &identity.identity_id).await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + let _ = stream.challenge().await?; + let started = Instant::now(); + let rejected = stream.closing_status(Duration::from_secs(11)).await?; + channel::expect_status(&rejected, Code::Unauthenticated, "signature_invalid")?; + ensure!( + started.elapsed() <= Duration::from_millis(10_800), + "channel exceeded the ten-second Hello deadline" + ); + let record = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!(!record.connected, "identity connected without Hello"); + ensure!( + record.last_seen_at == initial.last_seen_at, + "last-seen time updated without Hello" + ); + Ok(()) +} + +pub(crate) async fn p_channel_hello_invalid_metadata_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §metadata + let (_, identity) = issued(&ctx, "invalid-hello-metadata").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let long_key = format!("k{}", "a".repeat(64)); + let long_value = "v".repeat(1025); + let full_value = "v".repeat(1024); + let numbered_keys = (0..33).map(|index| format!("k{index:02}")).collect::>(); + for (variant, metadata) in [ + ("an uppercase key", vec![("Hostname", "host")]), + ("a 65-character key", vec![(long_key.as_str(), "host")]), + ("a control character", vec![("hostname", "bad\u{7}host")]), + ("a 1025-byte value", vec![("hostname", long_value.as_str())]), + ("33 keys", numbered_keys.iter().map(|key| (key.as_str(), "v")).collect()), + ( + "more than 16 KiB in total", + numbered_keys[..16] + .iter() + .map(|key| (key.as_str(), full_value.as_str())) + .collect(), + ), + ] { + let mut stream = channel::open(&ctx.target, &identity).await?; + let challenge = stream.challenge().await?; + stream.send_hello(&identity, &challenge, &metadata, None).await?; + let status = stream + .closing_status(Duration::from_secs(3)) + .await + .with_context(|| format!("Hello metadata with {variant}"))?; + channel::expect_status(&status, Code::InvalidArgument, "invalid_request") + .with_context(|| format!("Hello metadata with {variant}"))?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "Hello metadata with {variant} changed identity state" + ); + } + Ok(()) +} + +/// How a frame departs from a valid `AgentMessage` of exactly [`channel::MAX_ENVELOPE_LEN`] bytes, so that the authority cannot decode it. +#[derive(Clone, Copy, Debug)] +enum Undecodable { + /// The envelope `id`, a protobuf `string`, starts with the byte 0xFF, which UTF-8 never uses. + // oracle: spec RFC 3629 §1 + // oracle: spec Protocol Buffers "Feature Settings for Editions" §features.utf8_validation + InvalidUtf8, + /// The last byte is cut, so the last field holds fewer bytes than its length prefix declares. + // oracle: spec Protocol Buffers "Encoding" §Length-Delimited Records + Truncated, + /// One more byte of padding makes the frame one byte longer than the envelope limit. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service + Oversized, +} + +impl Undecodable { + const ALL: [Self; 3] = [Self::InvalidUtf8, Self::Truncated, Self::Oversized]; + + /// Applies the defect to the frame that `valid` serializes from an envelope `id` and a frame length. + fn frame(self, valid: impl Fn(&[u8], usize) -> anyhow::Result>) -> anyhow::Result> { + let mut id = uuid::Uuid::new_v4().to_string().into_bytes(); + match self { + Self::InvalidUtf8 => { + id[0] = 0xFF; + valid(&id, channel::MAX_ENVELOPE_LEN) + } + Self::Truncated => { + let mut frame = valid(&id, channel::MAX_ENVELOPE_LEN)?; + frame.pop(); + Ok(frame) + } + Self::Oversized => valid(&id, channel::MAX_ENVELOPE_LEN + 1), + } + } +} + +/// A first agent message that the authority cannot decode, including one over the envelope limit, closes the stream as `signature_invalid` and changes nothing. +/// +/// See [CONTRACT.agent-identity.md] §7.3 "Sequence", step 4, and §7.1 "Service". +/// +/// [CONTRACT.agent-identity.md]: ../../../docs/agent-identity/CONTRACT.agent-identity.md +pub(crate) async fn p_channel_undecodable_first_message_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service + let (_, identity) = issued(&ctx, "enrolled").await?; + let metadata = [("hostname", "undecodable")]; + next_last_seen_second(&ctx).await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + for undecodable in Undecodable::ALL { + let mut stream = channel::open(&ctx.target, &identity).await?; + let challenge = stream.challenge().await?; + let frame = undecodable.frame(|id, len| stream.hello_frame(&identity, &challenge, &metadata, id, len))?; + stream.send_frame(frame).await?; + let status = stream + .closing_status(Duration::from_secs(3)) + .await + .with_context(|| format!("{undecodable:?} Hello"))?; + channel::expect_status(&status, Code::Unauthenticated, "signature_invalid") + .with_context(|| format!("{undecodable:?} Hello"))?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "{undecodable:?} Hello changed identity state" + ); + } + // The valid frame that each defect departs from authenticates the stream, so each refusal comes from its defect alone. + let mut stream = channel::open(&ctx.target, &identity).await?; + let challenge = stream.challenge().await?; + let id = uuid::Uuid::new_v4().to_string(); + let frame = stream.hello_frame( + &identity, + &challenge, + &metadata, + id.as_bytes(), + channel::MAX_ENVELOPE_LEN, + )?; + stream.send_frame(frame).await?; + let welcome = stream.next().await?; + ensure!( + matches!( + welcome.payload, + Some(agent_channel_proto::server_message::Payload::Welcome(_)) + ) && welcome.correlation_id.as_deref() == Some(id.as_str()), + "a valid Hello of {} bytes did not receive its Welcome", + channel::MAX_ENVELOPE_LEN + ); + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .metadata_value("hostname") + == Some("undecodable"), + "a valid Hello of {} bytes did not replace the metadata", + channel::MAX_ENVELOPE_LEN + ); + Ok(()) +} + +/// An agent message that the authority cannot decode, including one over the envelope limit, closes an authenticated stream as `invalid_request`. +/// +/// See [CONTRACT.agent-identity.md] §7.4 "Termination" and §7.1 "Service". +/// +/// [CONTRACT.agent-identity.md]: ../../../docs/agent-identity/CONTRACT.agent-identity.md +pub(crate) async fn p_channel_undecodable_message_after_welcome_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service + let (_, identity) = issued(&ctx, "after-welcome").await?; + // An envelope whose only other field is one the schema does not define, so it carries no known payload. + let unknown_payload = + |id: &[u8], len| channel::agent_message_frame(id, &agent_channel_proto::AgentMessage::default(), len); + for undecodable in Undecodable::ALL { + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // The authority ignores the valid frame that the defect departs from, so the stream stays open until the defective one. + let id = uuid::Uuid::new_v4().to_string(); + stream + .send_frame(unknown_payload(id.as_bytes(), channel::MAX_ENVELOPE_LEN)?) + .await?; + ensure!( + tokio::time::timeout(Duration::from_millis(500), stream.stream.message()) + .await + .is_err(), + "a valid message of {} bytes with an unknown payload got a reply or ended the stream", + channel::MAX_ENVELOPE_LEN + ); + stream.send_frame(undecodable.frame(unknown_payload)?).await?; + let status = stream + .closing_status(Duration::from_secs(3)) + .await + .with_context(|| format!("{undecodable:?} message after Welcome"))?; + channel::expect_status(&status, Code::InvalidArgument, "invalid_request") + .with_context(|| format!("{undecodable:?} message after Welcome"))?; + } + Ok(()) +} + +pub(crate) async fn p_channel_clock_skew_refusal_carries_server_time(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + let (_, identity) = issued(&ctx, "skewed-opening").await?; + let created = time::OffsetDateTime::now_utc().unix_timestamp() - 600; + let headers = identity.key.sign( + &identity.thumbprint, + "connect", + None, + created, + created + 60, + &fresh_nonce(), + ); + let failure = channel::open_with_headers(&ctx.target, &identity, headers) + .await + .err() + .context("stale connect signature opened a channel")?; + channel::expect_refused_opening( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "clock_skew", + ) +} + +pub(crate) async fn p_channel_server_time_corrects_skew(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "server-time-retry").await?; + ctx.target.faults(&json!({ "clock_skew_secs": 900 })).await?; + let failure = channel::open(&ctx.target, &identity) + .await + .err() + .context("connect signature 900 seconds behind the authority opened a channel")?; + let status = failure.downcast_ref::().context("missing gRPC status")?; + channel::expect_refused_opening(status, Code::Unauthenticated, "clock_skew")?; + let server_time = status + .metadata() + .get("server-time") + .context("refused opening has no server-time trailer")? + .to_str()?; + let created = time::OffsetDateTime::parse(server_time, &Rfc3339)?.unix_timestamp(); + let headers = identity.key.sign( + &identity.thumbprint, + "connect", + None, + created, + created + 60, + &fresh_nonce(), + ); + let mut stream = channel::open_with_headers(&ctx.target, &identity, headers) + .await + .context("connect signature created at the server-time trailer was refused")?; + stream.hello(&identity, &[]).await +} + +pub(crate) async fn p_channel_unavailable_no_channel_url(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control + ctx.target.faults(&json!({ "channel_available": false })).await?; + let token = token(&ctx, 1).await?; + let key = KeyPair::generate()?; + let reply = ctx + .target + .enroll(&token.text, &key, &json!({ "hostname": "unavailable" })) + .await?; + expect_status(&reply, 200)?; + ensure!( + reply.body["config"].get("agent_channel_url").is_none(), + "enroll included config.agent_channel_url for unavailable channel" + ); + let identity = Identity::from_enrollment(key, &reply)?; + ensure!( + identity.agent_channel_url.is_none(), + "enroll returned config.agent_channel_url for unavailable channel" + ); + let failure = channel::probe_unavailable(&ctx.target, &identity) + .await + .err() + .context("unavailable channel accepted a stream")?; + ensure!( + failure + .downcast_ref::() + .is_some_and(|status| status.code() == Code::Unavailable), + "unavailable channel did not return gRPC UNAVAILABLE" + ); + Ok(()) +} + +pub(crate) async fn p_mock_malformed_https_channel_urls(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control + for (variant, suffix) in [("query", "?identity_probe=1"), ("fragment", "#identity_probe")] { + ctx.target.faults(&json!({ "malformed_channel_url": variant })).await?; + let (_, identity) = issued(&ctx, variant).await?; + ensure!( + identity.agent_channel_url.as_deref() == Some(format!("{}{suffix}", ctx.target.base_url).as_str()), + "mock did not return an HTTPS agent_channel_url with a {variant}" + ); + } + ctx.target.faults(&json!({ "malformed_channel_url": null })).await?; + let (_, restored) = issued(&ctx, "restored-channel-url").await?; + ensure!( + restored.agent_channel_url.as_deref() == Some(ctx.target.base_url.as_str()), + "clearing the malformed-URL fault did not restore the HTTPS channel URL" + ); + let before = ctx.target.requests(None).await?; + let response = ctx + .target + .http + .post(format!("{}/not-the-channel", ctx.target.base_url)) + .header(reqwest::header::CONTENT_TYPE, "application/grpc") + .send() + .await?; + ensure!(response.status() == 404, "mock accepted an unrelated gRPC path"); + let after = ctx.target.requests(None).await?; + ensure!( + count(&after, "channel_attempts")? == count(&before, "channel_attempts")? + 1 + && count(&after, "connect")? == count(&before, "connect")?, + "mock did not count a gRPC channel attempt on an unrelated path" + ); + Ok(()) +} + +pub(crate) async fn p_config_revision_monotonic_changes(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + let (_, mut identity) = issued(&ctx, "config-revisions").await?; + let (_, other) = issued(&ctx, "second-config-revisions").await?; + let initial = check_in_config(&ctx.target, &identity).await?; + ensure!( + initial == identity.config, + "check-in changed the effective config returned at enrollment" + ); + let other_initial = count(&check_in_config(&ctx.target, &other).await?, "revision")?; + ctx.target.faults(&json!({ "channel_broken": true })).await?; + let failure = channel::open(&ctx.target, &identity) + .await + .err() + .context("broken channel accepted a stream")?; + ensure!( + failure + .downcast_ref::() + .is_some_and(|status| status.code() == Code::Unavailable), + "channel_broken did not return UNAVAILABLE" + ); + ensure!( + check_in_config(&ctx.target, &identity).await? == initial, + "transport failure changed the effective config revision" + ); + + ctx.target.faults(&json!({ "channel_available": false })).await?; + let disabled = check_in_config(&ctx.target, &identity).await?; + let other_disabled = count(&check_in_config(&ctx.target, &other).await?, "revision")?; + ensure!( + count(&disabled, "revision")? > count(&initial, "revision")? + && disabled.get("agent_channel_url").is_none() + && other_disabled > other_initial, + "disabling the channel did not raise every identity's config revision" + ); + ctx.target.faults(&json!({ "channel_available": false })).await?; + ensure!( + check_in_config(&ctx.target, &identity).await? == disabled, + "no-op channel toggle raised the config revision" + ); + ctx.target.faults(&json!({ "channel_available": true })).await?; + let enabled = check_in_config(&ctx.target, &identity).await?; + ensure!( + count(&enabled, "revision")? > count(&disabled, "revision")? + && enabled["agent_channel_url"] == ctx.target.base_url, + "re-enabling the channel did not restore its URL at a higher revision" + ); + ctx.target.faults(&json!({ "channel_broken": false })).await?; + ensure!( + check_in_config(&ctx.target, &identity).await? == enabled, + "fixing the transport raised an unchanged config revision" + ); + + let changed = ctx + .target + .control("config", &json!({ "fields": { "conformance_flag": true } })) + .await?; + expect_status(&changed, 200)?; + ensure!( + changed.body["updatedIdentities"] == 2, + "config change did not update both identities" + ); + let extended = check_in_config(&ctx.target, &identity).await?; + ensure!( + count(&extended, "revision")? > count(&enabled, "revision")? && extended["conformance_flag"] == true, + "extra config field did not raise the revision" + ); + let unchanged = ctx + .target + .control("config", &json!({ "fields": { "conformance_flag": true } })) + .await?; + expect_status(&unchanged, 200)?; + ensure!( + unchanged.body["updatedIdentities"] == 0 && check_in_config(&ctx.target, &identity).await? == extended, + "identical config fields raised the revision" + ); + expect_status( + &ctx.target + .control("config", &json!({ "fields": { "revision": 999 } })) + .await?, + 400, + )?; + ensure!( + check_in_config(&ctx.target, &identity).await? == extended, + "reserved config field modified the identity" + ); + expect_status( + &ctx.target + .control("config", &json!({ "fields": { "conformance_flag": false } })) + .await?, + 200, + )?; + let final_config = check_in_config(&ctx.target, &identity).await?; + ensure!( + count(&final_config, "revision")? > count(&extended, "revision")? + && final_config["conformance_flag"] == false + && count(&check_in_config(&ctx.target, &other).await?, "revision")? > other_disabled, + "replacing a field did not advance both identity revisions" + ); + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + expect_status( + &ctx.target + .control("reconnect", &json!({ "identity_id": identity.identity_id })) + .await?, + 202, + )?; + ensure!( + check_in_config(&ctx.target, &identity).await? == final_config, + "renew, confirm or reconnect changed an unaffected config revision" + ); + Ok(()) +} + +/// On DVLS, the identity's revision goes stale through the only config change DVLS's management API makes: switching the agent channel off and on. +/// DVLS's intent, "Certificates and channel", offers the channel as `config.agent_channel_url`, which the switch withdraws and restores. +pub(crate) async fn p_config_hello_reconciles_stale_revision(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Certificates and channel + if ctx.mock() { + return stale_hello_reconciled(&ctx).await; + } + require_settings_changes(&ctx)?; + let original = ctx.target.management().settings().await?; + restoring_settings(&ctx, &original, stale_hello_reconciled(&ctx)).await +} + +async fn stale_hello_reconciled(ctx: &Context) -> anyhow::Result<()> { + let (_, mut identity) = issued(ctx, "stale-config").await?; + let announced_revision = if ctx.mock() { + if count(&identity.config, "revision")? == 0 { + expect_status( + &ctx.target + .control("config", &json!({ "fields": { "conformance_stale_hello": true } })) + .await?, + 200, + )?; + let updated = check_in_config(&ctx.target, &identity).await?; + ensure!( + count(&updated, "revision")? > 0, + "mock did not raise revision zero before the stale-Hello probe" + ); + identity.config = updated; + } + count(&identity.config, "revision")? - 1 + } else { + let enrolled_revision = count(&identity.config, "revision")?; + let management = ctx.target.management(); + for enabled in [false, true] { + management + .change_settings(&SettingsChange { + channel_enabled: Some(enabled), + ..SettingsChange::default() + }) + .await?; + } + let updated = check_in_config(&ctx.target, &identity).await?; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Certificates and channel + ensure!( + count(&updated, "revision")? > enrolled_revision && updated.get("agent_channel_url").is_some(), + "switching the agent channel off and on did not raise the config revision" + ); + identity.config = updated; + enrolled_revision + }; + let current_revision = count(&identity.config, "revision")?; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + let mut stale = channel::open(&ctx.target, &identity).await?; + let challenge = stale.challenge().await?; + let hello_id = stale + .send_hello_with_revision(&identity, &challenge, &[], None, announced_revision) + .await?; + let welcome = stale.next().await?; + ensure!( + matches!( + welcome.payload, + Some(agent_channel_proto::server_message::Payload::Welcome(_)) + ) && welcome.correlation_id.as_deref() == Some(hello_id.as_str()), + "stale Hello did not receive Welcome first" + ); + let (update, config) = next_config_update(&mut stale).await?; + ensure!( + config == identity.config, + "stale Hello did not receive the current config immediately after Welcome" + ); + stale.ack(&update).await?; + + let mut current = channel::open(&ctx.target, &identity).await?; + current.hello(&identity, &[]).await?; + ensure!( + tokio::time::timeout(Duration::from_millis(200), current.stream.message()) + .await + .is_err(), + "up-to-date Hello received an unnecessary ConfigUpdate" + ); + if ctx.mock() { + let events = ctx.target.events(&identity.identity_id).await?; + let applied = events + .iter() + .filter(|event| event["type"] == "stream_authenticated") + .map(|event| event["applied_config_revision"].as_u64()) + .collect::>(); + ensure!( + applied == [Some(announced_revision), Some(current_revision)] + && events + .iter() + .filter(|event| event["type"] == "config_update_sent") + .count() + == 1, + "mock did not distinguish stale from current Hello revisions" + ); + } + Ok(()) +} + +pub(crate) async fn p_config_update_pushes_higher_revision(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll + let (_, identity) = issued(&ctx, "config-push").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + let first = ctx + .target + .control("config", &json!({ "fields": { "conformance_note": "updated" } })) + .await?; + expect_status(&first, 200)?; + let (message, config) = next_config_update(&mut stream).await?; + let enrolled_revision = count(&identity.config, "revision")?; + let updated_revision = count(&config, "revision")?; + ensure!( + updated_revision > enrolled_revision + && config["conformance_note"] == "updated" + && config == check_in_config(&ctx.target, &identity).await?, + "config edit did not push a higher effective revision" + ); + stream.ack(&message).await?; + let stale = ctx + .target + .control( + "config/stale", + &json!({ "identity_id": identity.identity_id, "revision": enrolled_revision }), + ) + .await?; + expect_status(&stale, 200)?; + ensure!(stale.body["sent"] == 1, "stale ConfigUpdate was not delivered"); + let (stale_message, stale_config) = next_config_update(&mut stream).await?; + ensure!( + count(&stale_config, "revision")? == enrolled_revision + && stale_config["mock_stale_marker"] == "ignore-me" + && check_in_config(&ctx.target, &identity).await? == config, + "stale ConfigUpdate modified the server's effective revision" + ); + stream.ack(&stale_message).await?; + ctx.target.faults(&json!({ "channel_available": false })).await?; + let (disabled, config) = next_config_update(&mut stream).await?; + let disabled_revision = count(&config, "revision")?; + ensure!( + disabled_revision > updated_revision && config.get("agent_channel_url").is_none(), + "channel toggle did not push a higher revision without agent_channel_url" + ); + stream.ack(&disabled).await?; + let events = ctx.target.events(&identity.identity_id).await?; + let changed = events + .iter() + .filter(|event| event["type"] == "config_changed") + .map(|event| count(event, "revision")) + .collect::>>()?; + let pushed = events + .iter() + .filter(|event| event["type"] == "config_update_sent") + .map(|event| count(event, "revision")) + .collect::>>()?; + ensure!( + changed == [updated_revision, disabled_revision] && pushed == changed, + "config change and push events did not advance in order" + ); + Ok(()) +} + +pub(crate) async fn p_request_renewal_connected_and_on_connect(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §server-certificate-state + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Identities + let (_, identity) = issued(&ctx, "push").await?; + let mut connected = channel::open(&ctx.target, &identity).await?; + connected.hello(&identity, &[("hostname", "push")]).await?; + ctx.target.management().request_renewal(&identity.identity_id).await?; + let pushed = connected.next().await?; + ensure!( + matches!( + pushed.payload, + Some(agent_channel_proto::server_message::Payload::RenewRequested(ref reason)) + if reason.reason == "admin" + ), + "connected stream did not get admin renewal request" + ); + connected.ack(&pushed).await?; + if ctx.mock() { + let started = Instant::now(); + loop { + if count(&ctx.target.requests(None).await?, "correlated_acks")? >= 1 { + break; + } + ensure!( + started.elapsed() < Duration::from_secs(2), + "mock did not observe correlated Ack" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + let mut reconnect = channel::open(&ctx.target, &identity).await?; + reconnect.hello(&identity, &[("hostname", "push")]).await?; + let repeated = reconnect.next().await?; + ensure!( + matches!( + repeated.payload, + Some(agent_channel_proto::server_message::Payload::RenewRequested(ref reason)) + if reason.reason == "admin" + ), + "renewal request was not repeated on connect" + ); + reconnect.ack(&repeated).await?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .renewal_requested, + "request flag cleared too early" + ); + Ok(()) +} + +pub(crate) async fn p_reconnect_push_and_handoff(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + let (_, identity) = issued(&ctx, "reconnect").await?; + let mut old = channel::open(&ctx.target, &identity).await?; + old.hello(&identity, &[]).await?; + let before = ctx.target.requests(None).await?; + expect_status( + &ctx.target + .control("reconnect", &json!({ "identity_id": identity.identity_id })) + .await?, + 202, + )?; + let pushed = old.next().await?; + ensure!( + matches!( + pushed.payload, + Some(agent_channel_proto::server_message::Payload::Reconnect(ref message)) + if message.reason == "mock" + ), + "mock did not push Reconnect" + ); + let mut replacement = channel::open(&ctx.target, &identity).await?; + let challenge = replacement.challenge().await?; + ensure!( + count(&ctx.target.requests(None).await?, "overlap_open")? == count(&before, "overlap_open")?, + "opening headers counted as an authenticated overlap" + ); + let hello_id = replacement.send_hello(&identity, &challenge, &[], None).await?; + let welcome = replacement.next().await?; + ensure!( + matches!( + welcome.payload, + Some(agent_channel_proto::server_message::Payload::Welcome(_)) + ) && welcome.correlation_id.as_deref() == Some(hello_id.as_str()), + "replacement did not receive correlated Welcome" + ); + ensure!( + count(&ctx.target.requests(None).await?, "authenticated_connects")? > count(&before, "authenticated_connects")?, + "replacement channel did not authenticate" + ); + ensure!( + count(&ctx.target.requests(None).await?, "overlap_open")? > count(&before, "overlap_open")?, + "authenticated replacement did not overlap old stream" + ); + drop(old); + let started = Instant::now(); + loop { + let observed = ctx.target.requests(None).await?; + if count(&observed, "active_streams")? == 1 { + break; + } + ensure!( + started.elapsed() < Duration::from_secs(2), + "old channel did not close after replacement" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + ensure!( + identity_record(&ctx.target, &identity.identity_id).await?.connected, + "replacement did not preserve connection" + ); + let events = ctx.target.events(&identity.identity_id).await?; + let opened = events + .iter() + .filter(|event| event["type"] == "stream_authenticated") + .collect::>(); + ensure!(opened.len() == 2, "reconnect did not authenticate two streams"); + let old_stream_id = field(opened[0], "stream_id")?; + let new_sequence = count(opened[1], "seq")?; + ensure!( + events.iter().any(|event| { + event["type"] == "stream_closed" + && event["stream_id"] == old_stream_id + && event["status"] == "OK" + && event["seq"].as_u64().is_some_and(|seq| seq > new_sequence) + }), + "old stream closed before its replacement authenticated" + ); + Ok(()) +} + +pub(crate) async fn p_request_renewal_flag_cleared_only_on_confirm(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §server-certificate-state + let (_, mut identity) = issued(&ctx, "offline-flag").await?; + ctx.target.management().request_renewal(&identity.identity_id).await?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .renewal_requested, + "offline renewal request flag was not set" + ); + let next_key = KeyPair::generate()?; + let pending = ctx.target.renew(&identity, &next_key, &json!({})).await?; + expect_status(&pending, 200)?; + identity.adopt_certificate(&pending.body["certificate_chain"], next_key)?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .renewal_requested, + "flag cleared when pending certificate was merely issued" + ); + let replay_key = KeyPair { + key: identity.key.key.clone(), + csr: identity.key.csr.clone(), + }; + expect_status(&ctx.target.renew(&identity, &replay_key, &json!({})).await?, 200)?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .renewal_requested, + "pending-signed renew cleared the request flag" + ); + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let current = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + !current.renewal_requested && current.has_certificate(&identity.thumbprint, CertificateStatus::Current), + "confirm did not clear the request flag" + ); + Ok(()) +} + +pub(crate) async fn p_request_renewal_flag_cleared_after_new_cert(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §server-certificate-state + let (_, mut identity) = issued(&ctx, "clear-flag").await?; + let mut old = channel::open(&ctx.target, &identity).await?; + old.hello(&identity, &[]).await?; + ctx.target.management().request_renewal(&identity.identity_id).await?; + let request = old.next().await?; + old.ack(&request).await?; + let next_key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &next_key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], next_key)?; + let pending = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + pending.renewal_requested && pending.has_certificate(&identity.thumbprint, CertificateStatus::Pending), + "renewal request cleared before confirmation" + ); + let mut new_stream = channel::open(&ctx.target, &identity).await?; + let challenge = new_stream.challenge().await?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .renewal_requested, + "opening headers cleared the renewal request" + ); + let hello_id = new_stream.send_hello(&identity, &challenge, &[], None).await?; + let welcome = new_stream.next().await?; + ensure!( + matches!( + welcome.payload, + Some(agent_channel_proto::server_message::Payload::Welcome(_)) + ) && welcome.correlation_id.as_deref() == Some(hello_id.as_str()), + "new certificate did not receive its correlated Welcome" + ); + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .renewal_requested, + "pending certificate's Hello cleared the renewal request" + ); + let still_requested = new_stream.next().await?; + ensure!( + matches!( + still_requested.payload, + Some(agent_channel_proto::server_message::Payload::RenewRequested(ref reason)) + if reason.reason == "admin" + ), + "pending-certificate channel did not receive the outstanding renewal request" + ); + new_stream.ack(&still_requested).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + ensure!( + !identity_record(&ctx.target, &identity.identity_id) + .await? + .renewal_requested, + "confirm did not clear the renewal request" + ); + let mut additional = channel::open(&ctx.target, &identity).await?; + additional.hello(&identity, &[]).await?; + ensure!( + tokio::time::timeout(Duration::from_millis(250), additional.stream.message()) + .await + .is_err(), + "renewal request was pushed after flag cleared" + ); + Ok(()) +} + +pub(crate) async fn p_revocation_closes_stream(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, identity) = issued(&ctx, "live-revoke").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + ctx.target.management().revoke(&identity.identity_id).await?; + let status = stream.closing_status(Duration::from_secs(3)).await?; + channel::expect_status(&status, Code::PermissionDenied, "identity_revoked")?; + ensure!( + !identity_record(&ctx.target, &identity.identity_id).await?.connected, + "revoked stream still connected" + ); + Ok(()) +} + +pub(crate) async fn p_stream_closes_at_not_after(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + if ctx.mock() { + ctx.target.faults(&json!({ "leaf_lifetime_secs": 20 })).await?; + } + let (_, identity) = issued(&ctx, "expires").await?; + let not_after = identity_record(&ctx.target, &identity.identity_id) + .await? + .not_after() + .context("identity has no current certificate")? + .unix_timestamp(); + let mut stream = if ctx.mock() { + let mut authenticated = None; + for _ in 0..3 { + let milliseconds = u64::try_from( + time::OffsetDateTime::now_utc() + .unix_timestamp_nanos() + .rem_euclid(1_000_000_000), + )? / 1_000_000; + if milliseconds > 150 { + tokio::time::sleep(Duration::from_millis(1050 - milliseconds)).await; + } + let now = ctx.target.control("time/advance", &json!({ "secs": 0 })).await?.body["now"] + .as_i64() + .context("mock clock is missing")?; + ctx.target.advance(not_after - now - 1).await?; + let mut opened = match channel::open(&ctx.target, &identity).await { + Ok(stream) => stream, + Err(error) + if error.downcast_ref::().is_some_and(|status| { + status + .metadata() + .get("error-code") + .is_some_and(|code| code == "certificate_expired") + }) => + { + continue; + } + Err(error) => return Err(error), + }; + if let Err(error) = opened.hello(&identity, &[]).await { + if error.downcast_ref::().is_some_and(|status| { + status + .metadata() + .get("error-code") + .is_some_and(|code| code == "certificate_expired") + }) { + continue; + } + return Err(error); + } + let now = ctx.target.control("time/advance", &json!({ "secs": 0 })).await?.body["now"] + .as_i64() + .context("mock clock is missing")?; + if now == not_after - 1 { + authenticated = Some(opened); + break; + } + } + authenticated.context("could not authenticate at notAfter - 1 without a wall-clock second rollover")? + } else { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + ensure!( + not_after > now + 4, + "real-target leaf is too short to open before expiry" + ); + tokio::time::sleep(Duration::from_secs(u64::try_from(not_after - now - 4)?)).await; + let mut opened = channel::open(&ctx.target, &identity).await?; + opened.hello(&identity, &[]).await?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await?.connected, + "stream was not authenticated just before expiry" + ); + opened + }; + let status = if ctx.mock() { + ctx.target.advance(1).await?; + stream.closing_status(Duration::from_millis(900)).await? + } else { + stream.closing_status(Duration::from_secs(6)).await? + }; + channel::expect_status(&status, Code::Unauthenticated, "certificate_expired")?; + if ctx.mock() { + ensure!( + ctx.target.events(&identity.identity_id).await?.iter().any(|event| { + event["type"] == "stream_closed" + && event["status"] == "UNAUTHENTICATED" + && event["error_code"] == "certificate_expired" + }), + "time/advance did not record immediate stream expiry" + ); + } + if !ctx.mock() { + ensure!( + time::OffsetDateTime::now_utc().unix_timestamp() <= not_after + 2, + "real-target stream closed more than two seconds after notAfter" + ); + } + ensure!( + !identity_record(&ctx.target, &identity.identity_id).await?.connected, + "expired stream still connected" + ); + Ok(()) +} + +pub(crate) async fn p_expired_certificate_refused_at_stream_open(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + ctx.target.faults(&json!({ "leaf_lifetime_secs": 20 })).await?; + let (_, identity) = issued(&ctx, "expired-open").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let not_after = before + .not_after() + .context("identity has no current certificate")? + .unix_timestamp(); + // CONTRACT.agent-identity.md §6 accepts a connect while `now <= notAfter`, and §7.4 closes a stream no later than `notAfter`, so an opening at `notAfter` gets its Challenge and then closes. + expect_status( + &ctx.target.control("time/freeze", &json!({ "now": not_after })).await?, + 200, + )?; + let mut at_expiry = channel::open(&ctx.target, &identity).await?; + let _ = at_expiry.challenge().await?; + channel::expect_status( + &at_expiry.closing_status(Duration::from_millis(900)).await?, + Code::Unauthenticated, + "certificate_expired", + )?; + expect_status( + &ctx.target + .control("time/freeze", &json!({ "now": not_after + 1 })) + .await?, + 200, + )?; + let failure = channel::open(&ctx.target, &identity) + .await + .err() + .context("expired certificate opened a stream")?; + channel::expect_refused_opening( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "certificate_expired", + )?; + let after = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + !after.connected + && after.metadata == before.metadata + && after.last_seen_at == before.last_seen_at + && ctx + .target + .events(&identity.identity_id) + .await? + .iter() + .all(|event| event["type"] != "stream_authenticated"), + "expired opening authenticated a stream or changed identity state" + ); + Ok(()) +} + +pub(crate) async fn p_challenged_stream_expires_on_advance(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + ctx.target.faults(&json!({ "leaf_lifetime_secs": 20 })).await?; + let (_, identity) = issued(&ctx, "challenge-expiry").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let not_after = before + .not_after() + .context("identity has no current certificate")? + .unix_timestamp(); + let mut stream = channel::open(&ctx.target, &identity).await?; + let _ = stream.challenge().await?; + ensure!( + !identity_record(&ctx.target, &identity.identity_id).await?.connected, + "Challenge alone authenticated an identity" + ); + let now = ctx.target.control("time/advance", &json!({ "secs": 0 })).await?.body["now"] + .as_i64() + .context("mock clock is missing")?; + ctx.target.advance(not_after - now).await?; + let status = stream.closing_status(Duration::from_millis(900)).await?; + channel::expect_status(&status, Code::Unauthenticated, "certificate_expired")?; + channel::expect_server_time(&status)?; + let after = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + !after.connected && after.metadata == before.metadata && after.last_seen_at == before.last_seen_at, + "expired unproven stream changed identity state" + ); + let events = ctx.target.events(&identity.identity_id).await?; + ensure!( + events.iter().any(|event| { + event["type"] == "stream_closed" + && event["status"] == "UNAUTHENTICATED" + && event["error_code"] == "certificate_expired" + }) && events.iter().all(|event| event["type"] != "stream_authenticated"), + "challenged stream was not closed at the mock clock boundary" + ); + Ok(()) +} + +pub(crate) async fn p_pending_cert_auth_does_not_promote(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §server-certificate-state + let (_, mut identity) = issued(&ctx, "make-before-break").await?; + let old_thumb = identity.thumbprint.clone(); + let mut old = channel::open(&ctx.target, &identity).await?; + old.hello(&identity, &[]).await?; + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + let events_before_replay = if ctx.mock() { + Some(ctx.target.events(&identity.identity_id).await?) + } else { + None + }; + let replay_key = KeyPair { + key: identity.key.key.clone(), + csr: identity.key.csr.clone(), + }; + expect_status(&ctx.target.renew(&identity, &replay_key, &json!({})).await?, 200)?; + if let Some(events) = events_before_replay { + let after = ctx.target.events(&identity.identity_id).await?; + ensure!( + after + .iter() + .filter(|event| event["type"] == "cert_status_changed") + .collect::>() + == events + .iter() + .filter(|event| event["type"] == "cert_status_changed") + .collect::>() + && after.iter().any(|event| { + event["type"] == "renew_received" && event["cert_thumbprint"] == identity.thumbprint + }), + "pending-signed renewal changed certificate statuses before confirm" + ); + } + let before = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + before.has_certificate(&old_thumb, CertificateStatus::Current) + && before.has_certificate(&identity.thumbprint, CertificateStatus::Pending) + && before.connected, + "renewal promoted the pending certificate before confirmation" + ); + let mut unauthenticated = channel::open(&ctx.target, &identity).await?; + let challenge = unauthenticated.challenge().await?; + let opened = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + opened.has_certificate(&old_thumb, CertificateStatus::Current) + && opened.has_certificate(&identity.thumbprint, CertificateStatus::Pending), + "opening headers promoted the pending certificate" + ); + ensure!( + tokio::time::timeout(Duration::from_millis(200), old.stream.message()) + .await + .is_err(), + "opening headers closed the old authenticated stream" + ); + unauthenticated + .send_hello( + &identity, + &challenge, + &[("hostname", "not-authenticated")], + Some(vec![0; 64]), + ) + .await?; + let status = unauthenticated.closing_status(Duration::from_secs(3)).await?; + channel::expect_status(&status, Code::Unauthenticated, "signature_invalid")?; + let rejected = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + rejected.has_certificate(&old_thumb, CertificateStatus::Current) + && rejected.has_certificate(&identity.thumbprint, CertificateStatus::Pending) + && rejected.connected, + "bad proof promoted the pending certificate" + ); + ensure!( + rejected.metadata == before.metadata, + "bad proof changed identity metadata" + ); + ensure!( + tokio::time::timeout(Duration::from_millis(200), old.stream.message()) + .await + .is_err(), + "bad proof closed the old authenticated stream" + ); + let mut pending_stream = channel::open(&ctx.target, &identity).await?; + pending_stream.hello(&identity, &[]).await?; + let after_hello = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + after_hello.has_certificate(&old_thumb, CertificateStatus::Current) + && after_hello.has_certificate(&identity.thumbprint, CertificateStatus::Pending) + && after_hello.connected, + "authenticated pending certificate promoted before confirm" + ); + ensure!( + tokio::time::timeout(Duration::from_millis(200), old.stream.message()) + .await + .is_err(), + "pending-certificate Hello closed the old authenticated stream" + ); + if ctx.mock() { + let events = ctx.target.events(&identity.identity_id).await?; + ensure!( + events.iter().any(|event| { + event["type"] == "stream_authenticated" && event["cert_thumbprint"] == identity.thumbprint + }) && events.iter().all(|event| { + event["type"] != "cert_status_changed" + || event["cert_thumbprint"] != identity.thumbprint + || event["to"] != "current" + }), + "pending-certificate Hello promoted the certificate in the event log" + ); + } + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let confirmed = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + confirmed.has_certificate(&old_thumb, CertificateStatus::Retired) + && confirmed.has_certificate(&identity.thumbprint, CertificateStatus::Current), + "confirm did not promote pending and retire old current" + ); + Ok(()) +} + +pub(crate) async fn p_confirm_reconnects_and_closes_retired_stream_at_grace(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §confirm + let (_, mut identity) = issued(&ctx, "retired-stream-grace").await?; + let old_thumb = identity.thumbprint.clone(); + let mut old = channel::open(&ctx.target, &identity).await?; + old.hello(&identity, &[]).await?; + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + let mut replacement = channel::open(&ctx.target, &identity).await?; + replacement.hello(&identity, &[]).await?; + let now = chain_evaluation_time(&ctx).await?; + expect_status(&ctx.target.control("time/freeze", &json!({ "now": now })).await?, 200)?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + + let pushed = old.next().await?; + ensure!( + matches!( + pushed.payload, + Some(agent_channel_proto::server_message::Payload::Reconnect(ref message)) + if message.reason == "certificate_rotated" + ), + "retired stream did not receive Reconnect(certificate_rotated)" + ); + let record = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + record.has_certificate(&old_thumb, CertificateStatus::Retired) + && record.has_certificate(&identity.thumbprint, CertificateStatus::Current) + && record.connected, + "confirm did not promote while keeping the replacement stream connected" + ); + let events = ctx.target.events(&identity.identity_id).await?; + let promoted = events + .iter() + .find(|event| { + event["type"] == "cert_status_changed" + && event["cert_thumbprint"] == identity.thumbprint + && event["to"] == "current" + }) + .context("confirm promotion event missing")?; + let reconnect = events + .iter() + .find(|event| { + event["type"] == "reconnect_sent" + && event["cert_thumbprint"] == old_thumb + && event["reason"] == "certificate_rotated" + }) + .context("retired stream reconnect event missing")?; + ensure!( + count(promoted, "seq")? < count(reconnect, "seq")?, + "reconnect push was sent before confirmation promoted the certificate" + ); + + expect_status( + &ctx.target.control("time/freeze", &json!({ "now": now + 59 })).await?, + 200, + )?; + ensure!( + ctx.target + .events(&identity.identity_id) + .await? + .iter() + .all(|event| { event["type"] != "stream_closed" || event["cert_thumbprint"] != old_thumb }), + "retired stream closed before its 60-second reconnect grace elapsed" + ); + ensure!( + tokio::time::timeout(Duration::from_millis(150), old.stream.message()) + .await + .is_err(), + "retired stream closed before the 60-second boundary" + ); + expect_status( + &ctx.target.control("time/freeze", &json!({ "now": now + 60 })).await?, + 200, + )?; + ensure!( + tokio::time::timeout(Duration::from_secs(3), old.stream.message()) + .await + .context("retired stream did not close at 60 seconds")?? + .is_none(), + "retired stream did not close cleanly at 60 seconds" + ); + let events = ctx.target.events(&identity.identity_id).await?; + let closed = events + .iter() + .find(|event| { + event["type"] == "stream_closed" && event["cert_thumbprint"] == old_thumb && event["status"] == "OK" + }) + .context("retired stream has no clean close event")?; + let new_auth = events + .iter() + .find(|event| event["type"] == "stream_authenticated" && event["cert_thumbprint"] == identity.thumbprint) + .context("replacement stream was not authenticated")?; + ensure!( + count(closed, "seq")? > count(reconnect, "seq")? + && count(closed, "seq")? > count(new_auth, "seq")? + && identity_record(&ctx.target, &identity.identity_id).await?.connected, + "retired stream closed before reconnect or replacement authentication" + ); + Ok(()) +} + +pub(crate) async fn p_channel_retired_before_hello_completes_then_reconnects(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-termination + let (_, mut identity) = issued(&ctx, "retired-before-hello").await?; + let old_thumb = identity.thumbprint.clone(); + let mut stream = channel::open(&ctx.target, &identity).await?; + let challenge = stream.challenge().await?; + let Some(agent_channel_proto::server_message::Payload::Challenge(payload)) = challenge.payload.as_ref() else { + anyhow::bail!("stream missing challenge"); + }; + let old_proof = identity.key.channel_proof(&payload.challenge, &stream.headers.nonce); + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + let now = chain_evaluation_time(&ctx).await?; + expect_status(&ctx.target.control("time/freeze", &json!({ "now": now })).await?, 200)?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .has_certificate(&old_thumb, CertificateStatus::Retired), + "confirm did not retire the certificate that opened the stream" + ); + + let hello_id = stream.send_hello(&identity, &challenge, &[], Some(old_proof)).await?; + let welcome = stream.next().await?; + ensure!( + matches!( + welcome.payload, + Some(agent_channel_proto::server_message::Payload::Welcome(_)) + ) && welcome.correlation_id.as_deref() == Some(hello_id.as_str()), + "stream retired while awaiting Hello did not complete its handshake" + ); + let pushed = stream.next().await?; + ensure!( + matches!( + pushed.payload, + Some(agent_channel_proto::server_message::Payload::Reconnect(ref message)) + if message.reason == "certificate_rotated" + ), + "stream retired while awaiting Hello did not receive Reconnect(certificate_rotated) after Welcome" + ); + ensure!( + identity_record(&ctx.target, &identity.identity_id).await?.connected, + "stream retired while awaiting Hello did not authenticate" + ); + + expect_status( + &ctx.target.control("time/freeze", &json!({ "now": now + 59 })).await?, + 200, + )?; + ensure!( + tokio::time::timeout(Duration::from_millis(150), stream.stream.message()) + .await + .is_err(), + "stream retired while awaiting Hello closed before the 60-second boundary" + ); + expect_status( + &ctx.target.control("time/freeze", &json!({ "now": now + 60 })).await?, + 200, + )?; + ensure!( + tokio::time::timeout(Duration::from_secs(3), stream.stream.message()) + .await + .context("stream retired while awaiting Hello did not close at 60 seconds")?? + .is_none(), + "stream retired while awaiting Hello did not close with OK at 60 seconds" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_publishes_both_roots_and_issues_from_new(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + if !ctx.mock() { + wait_rotation_idle(&ctx.target, ctx.dvls_rotation_window_secs).await?; + } + let before = ctx.target.trust_anchor().await?; + let old = before["roots"].as_array().context("old roots")?; + ensure!(old.len() == 1, "expected exactly one root before rotation"); + let old_thumb = field(&old[0], "thumbprint")?.to_owned(); + let _ = issued(&ctx, "old-root").await?; + begin_rotation(&ctx).await?; + let roots = ctx.target.trust_anchor().await?; + let published = roots["roots"].as_array().context("rotation roots")?; + ensure!(published.len() == 2, "rotation did not publish exactly two roots"); + ensure!( + published.iter().any(|root| root["thumbprint"] == old_thumb), + "old root disappeared before rotation deadline" + ); + for root in published { + assert_root(root)?; + } + let (_, identity) = issued(&ctx, "new-root").await?; + verify_chain(&identity.certificate_chain, &roots, chain_evaluation_time(&ctx).await?)?; + let new_root = thumbprint(identity.certificate_chain.last().context("missing issued root")?)?; + ensure!(new_root != old_thumb, "rotation issued from the old root"); + let record = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + record.issuer() == Some(&*new_root), + "identity issuer did not switch to new root" + ); + finish_rotation(&ctx).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_status_counts(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Rotation + let (_, mut a) = issued(&ctx, "old-a").await?; + let (_, b) = issued(&ctx, "old-b").await?; + let old_root = identity_record(&ctx.target, &a.identity_id) + .await? + .issuer() + .context("identity has no current certificate")? + .to_owned(); + let expected = active_old_root_ids(&ctx, &old_root).await?; + ensure!( + expected.contains(&a.identity_id) && expected.contains(&b.identity_id), + "new identities are missing from the full old-root listing" + ); + let started = begin_rotation(&ctx).await?; + ensure!(started.rotating(), "rotation not in progress"); + ensure!( + started.active_identities_on_old_root == expected.len() as u64, + "rotation did not count all active old-root identities" + ); + ensure!( + identity_record(&ctx.target, &a.identity_id).await?.renewal_requested + && identity_record(&ctx.target, &b.identity_id).await?.renewal_requested, + "rotation did not flag offline old-root identities" + ); + let new_key = KeyPair::generate()?; + let renewed = ctx.target.renew(&a, &new_key, &json!({})).await?; + expect_status(&renewed, 200)?; + a.adopt_certificate(&renewed.body["certificate_chain"], new_key)?; + expect_status(&ctx.target.confirm(&a).await?, 204)?; + ensure!( + !identity_record(&ctx.target, &a.identity_id).await?.renewal_requested, + "confirm did not clear the rotation flag" + ); + let status = ctx.target.management().rotation().await?; + ensure!( + status.active_identities_on_old_root == active_old_root_ids(&ctx, &old_root).await?.len() as u64, + "migration old-root count disagrees with all listing pages" + ); + ctx.target.management().revoke(&b.identity_id).await?; + let status = ctx.target.management().rotation().await?; + ensure!( + status.active_identities_on_old_root == active_old_root_ids(&ctx, &old_root).await?.len() as u64, + "revoked identity remained in the old-root count" + ); + finish_rotation(&ctx).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_conflict_409(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Rotation + let _ = issued(&ctx, "rotation").await?; + begin_rotation(&ctx).await?; + ensure!( + refusal(ctx.target.management().rotate(RotationDeadline::Default).await)? == Refusal::Conflict, + "a second rotation started while one was running" + ); + finish_rotation(&ctx).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_completes_early_when_last_identity_migrates(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + let (_, mut first) = issued(&ctx, "first-old-root").await?; + let (_, mut last) = issued(&ctx, "last-old-root").await?; + let old_root = thumbprint(first.certificate_chain.last().context("missing old root")?)?; + ensure!( + thumbprint(last.certificate_chain.last().context("missing old root")?)? == old_root, + "identities were not enrolled under the same old root" + ); + + let started = ctx.target.management().rotate(RotationDeadline::Default).await?; + ensure!( + started.rotating() && started.active_identities_on_old_root == 2, + "rotation did not start with both old-root identities" + ); + let deadline = started.deadline()?.unix_timestamp(); + ensure!( + deadline - chain_evaluation_time(&ctx).await? > 60, + "rotation deadline is not far enough away to test early completion" + ); + let new_root = started.new_root()?.to_owned(); + ensure!(new_root != old_root, "rotation did not create a new root"); + + for (remaining, identity) in [(1, &mut first), (0, &mut last)] { + let new_key = KeyPair::generate()?; + let renewed = ctx.target.renew(identity, &new_key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], new_key)?; + let pending = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + pending.has_certificate(&identity.thumbprint, CertificateStatus::Pending) + && pending.issuer() == Some(&*old_root), + "renewal migrated the identity before confirmation" + ); + let before_auth = ctx.target.management().rotation().await?; + ensure!( + before_auth.rotating() && before_auth.active_identities_on_old_root == remaining + 1, + "rotation completed before the old-root certificate was replaced" + ); + + expect_status(&ctx.target.confirm(identity).await?, 204)?; + let migrated = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + migrated.has_certificate(&identity.thumbprint, CertificateStatus::Current) + && migrated.issuer() == Some(&*new_root), + "confirm did not migrate the identity to the new root" + ); + let status = ctx.target.management().rotation().await?; + let roots = ctx.target.trust_anchor().await?; + let published = roots["roots"].as_array().context("published roots")?; + ensure!( + status.active_identities_on_old_root == remaining, + "migration did not update the old-root identity count" + ); + if remaining == 1 { + ensure!( + status.rotating() + && published.len() == 2 + && published.iter().any(|root| root["thumbprint"] == old_root), + "rotation stopped while an old-root identity was still active" + ); + } else { + ensure!( + !status.rotating() + && published.len() == 1 + && published[0]["thumbprint"] == new_root + && chain_evaluation_time(&ctx).await? < deadline - 30, + "rotation did not finish early with only the new root published" + ); + } + } + Ok(()) +} + +pub(crate) async fn p_rotation_completes_immediately_without_old_root_identities(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + let original = ctx.target.trust_anchor().await?; + let original_roots = original["roots"].as_array().context("original roots")?; + ensure!(original_roots.len() == 1, "expected one root before rotation"); + let old_root = field(&original_roots[0], "thumbprint")?; + + let started = ctx.target.management().rotate(RotationDeadline::Default).await?; + ensure!( + !started.rotating() + && started.active_identities_on_old_root == 0 + && started.deadline.is_none() + && started.old_root.is_none() + && started.new_root.is_none(), + "rotation without old-root identities did not complete in the POST response" + ); + let status = ctx.target.management().rotation().await?; + ensure!(status == started, "completed rotation did not remain idle"); + let roots = ctx.target.trust_anchor().await?; + let published = roots["roots"].as_array().context("published roots")?; + ensure!( + published.len() == 1 && published[0]["thumbprint"] != old_root, + "rotation without old-root identities did not replace the published root" + ); + assert_root(&published[0])?; + Ok(()) +} + +pub(crate) async fn p_rotation_deadline_bound(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Rotation + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Terminology + ctx.target.faults(&json!({ "leaf_lifetime_secs": 30 })).await?; + let (_, expired) = issued(&ctx, "expired-old-root").await?; + ctx.target.faults(&json!({ "leaf_lifetime_secs": 120 })).await?; + let (_, active) = issued(&ctx, "active-old-root").await?; + let (_, revoked) = issued(&ctx, "revoked-old-root").await?; + ctx.target.management().revoke(&revoked.identity_id).await?; + ctx.target.faults(&json!({ "leaf_lifetime_secs": 60 })).await?; + let (_, pending_only) = issued(&ctx, "expired-current-pending-old-root").await?; + ctx.target.faults(&json!({ "leaf_lifetime_secs": 240 })).await?; + expect_status( + &ctx.target + .renew(&pending_only, &KeyPair::generate()?, &json!({})) + .await?, + 200, + )?; + ctx.target.faults(&json!({ "leaf_lifetime_secs": 120 })).await?; + let (_, both) = issued(&ctx, "current-and-pending-old-root").await?; + ctx.target.faults(&json!({ "leaf_lifetime_secs": 180 })).await?; + expect_status(&ctx.target.renew(&both, &KeyPair::generate()?, &json!({})).await?, 200)?; + ctx.target.advance(61).await?; + + let expired_record = identity_record(&ctx.target, &expired.identity_id).await?; + ensure!( + expired_record.status == IdentityStatus::Expired, + "fixture certificate did not expire" + ); + let pending_record = identity_record(&ctx.target, &pending_only.identity_id).await?; + let current_expiry = pending_record + .not_after() + .context("identity has no current certificate")?; + ensure!( + current_expiry.unix_timestamp() < chain_evaluation_time(&ctx).await? + && pending_record + .certificates + .as_ref() + .is_some_and(|certs| certs.iter().any(|cert| cert.status == CertificateStatus::Pending)), + "fixture lacks a live pending old-root certificate with an expired current certificate" + ); + // DVLS's intent, "Terminology": `active` holds "an unexpired current or pending certificate". + ensure!( + pending_record.status == IdentityStatus::Active, + "an identity with only an unexpired pending certificate is not active" + ); + let both_record = identity_record(&ctx.target, &both.identity_id).await?; + ensure!( + both_record.status == IdentityStatus::Active + && both_record + .certificates + .as_ref() + .is_some_and(|certs| certs.iter().any(|cert| cert.status == CertificateStatus::Pending)), + "fixture lacks both current and pending old-root certificates" + ); + let pending_cert = pending_record + .certificates + .as_ref() + .context("pending identity certificates")? + .iter() + .find(|cert| cert.status == CertificateStatus::Pending) + .context("pending old-root certificate")?; + let maximum = pending_cert.not_after; + ensure!( + identity_record(&ctx.target, &active.identity_id) + .await? + .not_after() + .context("identity has no current certificate")? + < maximum, + "pending certificate does not determine the rotation maximum" + ); + let too_late = RotationDeadline::At(maximum + time::Duration::seconds(1)); + ensure!( + refusal(ctx.target.management().rotate(too_late).await)? == Refusal::Unprocessable, + "a deadline after the rotation maximum was accepted" + ); + ensure!( + !ctx.target.management().rotation().await?.rotating() + && ctx.target.trust_anchor().await?["roots"] + .as_array() + .is_some_and(|roots| roots.len() == 1), + "rejected late deadline started a rotation" + ); + let started = ctx.target.management().rotate(RotationDeadline::Default).await?; + ensure!( + started.deadline == Some(maximum) + && started.active_identities_on_old_root == 3 + && active_old_root_ids(&ctx, started.old_root()?).await?.len() == 3, + "rotation maximum or identity count excluded a pending certificate or counted one identity twice" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_disposable_deadline_above_own_max_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Rotation + ensure!( + ctx.mock() || ctx.disposable_dvls_target, + "requires a disposable DVLS target" + ); + wait_rotation_idle(&ctx.target, ctx.dvls_rotation_window_secs).await?; + let original_root = field(&ctx.target.trust_anchor().await?["roots"][0], "thumbprint")?.to_owned(); + isolated_old_root_fixture(&ctx, &original_root, &[]).await?; + let (_, first) = issued(&ctx, "disposable-deadline-first").await?; + let (_, second) = issued(&ctx, "disposable-deadline-second").await?; + expect_status( + &ctx.target.renew(&second, &KeyPair::generate()?, &json!({})).await?, + 200, + )?; + if let Err(error) = + isolated_old_root_fixture(&ctx, &original_root, &[&first.identity_id, &second.identity_id]).await + { + for id in [&first.identity_id, &second.identity_id] { + let _ = ctx.target.management().revoke(id).await; + } + return Err(error); + } + let now = chain_evaluation_time(&ctx).await?; + let mut candidate_dates = Vec::new(); + for id in [&first.identity_id, &second.identity_id] { + let record = identity_record(&ctx.target, id).await?; + for cert in record.certificates.as_ref().context("own certificates are missing")? { + let not_after = cert.not_after; + if matches!(cert.status, CertificateStatus::Current | CertificateStatus::Pending) + && cert.issuer == original_root + && not_after.unix_timestamp() >= now + { + candidate_dates.push(not_after); + } + } + } + ensure!( + candidate_dates.len() == 3, + "fixture did not issue two current and one pending old-root certificate" + ); + let maximum = candidate_dates + .into_iter() + .max() + .context("own old-root maximum is missing")?; + let too_late = RotationDeadline::At(maximum + time::Duration::days(1)); + ensure!( + refusal(ctx.target.management().rotate(too_late).await)? == Refusal::Unprocessable, + "a deadline after the rotation maximum was accepted" + ); + let status = ctx.target.management().rotation().await?; + ensure!( + !status.rotating() + && ctx.target.trust_anchor().await?["roots"] + .as_array() + .is_some_and(|roots| roots.len() == 1 && roots[0]["thumbprint"] == original_root), + "deadline above the maximum of own enrolled certificates started a rotation" + ); + for id in [&first.identity_id, &second.identity_id] { + ctx.target.management().revoke(id).await?; + } + Ok(()) +} + +pub(crate) async fn p_rotation_disposable_completes_before_future_deadline(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + ensure!( + ctx.mock() || ctx.disposable_dvls_target, + "requires a disposable DVLS target" + ); + wait_rotation_idle(&ctx.target, ctx.dvls_rotation_window_secs).await?; + let old_root = field(&ctx.target.trust_anchor().await?["roots"][0], "thumbprint")?.to_owned(); + isolated_old_root_fixture(&ctx, &old_root, &[]).await?; + let (_, mut identity) = issued(&ctx, "disposable-early-completion").await?; + let original = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + original.issuer().context("identity has no current certificate")? == old_root, + "disposable fixture enrolled under a different root" + ); + if let Err(error) = isolated_old_root_fixture(&ctx, &old_root, &[&identity.identity_id]).await { + let _ = ctx.target.management().revoke(&identity.identity_id).await; + return Err(error); + } + let maximum = original.not_after().context("identity has no current certificate")?; + let now = chain_evaluation_time(&ctx).await?; + let deadline = maximum.min(time::OffsetDateTime::from_unix_timestamp( + now + i64::try_from(ctx.dvls_rotation_window_secs)?, + )?); + ensure!( + deadline.unix_timestamp() > now + 2, + "own certificate expires before an early-completion probe" + ); + let started = ctx.target.management().rotate(RotationDeadline::At(deadline)).await?; + ensure!( + started.rotating() && started.active_identities_on_old_root == 1, + "disposable target does not have exactly one active old-root identity" + ); + let new_root = started.new_root()?.to_owned(); + let new_key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &new_key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], new_key)?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let status = ctx.target.management().rotation().await?; + let published = ctx.target.trust_anchor().await?; + ensure!( + old_root != new_root + && !status.rotating() + && status.active_identities_on_old_root == 0 + && chain_evaluation_time(&ctx).await? < deadline.unix_timestamp() + && published["roots"] + .as_array() + .is_some_and(|roots| roots.len() == 1 && roots[0]["thumbprint"] == new_root) + && identity_record(&ctx.target, &identity.identity_id).await?.issuer() == Some(&*new_root), + "rotation did not complete when the last old-root identity migrated before its deadline" + ); + ctx.target.management().revoke(&identity.identity_id).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_grace_renewal_after_early_completion(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + ctx.target.faults(&json!({ "leaf_lifetime_secs": 60 })).await?; + let (_, expired) = issued(&ctx, "grace-old-root").await?; + let old_root = thumbprint(expired.certificate_chain.last().context("missing old root")?)?; + let expiry = identity_record(&ctx.target, &expired.identity_id) + .await? + .not_after() + .context("identity has no current certificate")? + .unix_timestamp(); + ctx.target.faults(&json!({ "leaf_lifetime_secs": 600 })).await?; + let (_, revoked) = issued(&ctx, "long-lived-old-root").await?; + + let started = ctx.target.management().rotate(RotationDeadline::Default).await?; + ensure!( + started.rotating() && started.active_identities_on_old_root == 2, + "rotation did not count both old-root identities" + ); + let deadline = started.deadline()?.unix_timestamp(); + let new_root = started.new_root()?; + ctx.target.management().revoke(&revoked.identity_id).await?; + let remaining = ctx.target.management().rotation().await?; + ensure!( + remaining.rotating() + && remaining.active_identities_on_old_root == 1 + && ctx.target.trust_anchor().await?["roots"] + .as_array() + .is_some_and(|roots| roots.len() == 2), + "rotation finished while the short-lived old-root certificate was unexpired" + ); + + let until_expiry = expiry - chain_evaluation_time(&ctx).await?; + ensure!( + until_expiry > 0, + "old-root certificate expired before the boundary test" + ); + // A certificate is unexpired while `now <= notAfter` (CONTRACT.agent-identity.md §6), so it expires one second + // after `notAfter`. + ctx.target.advance(until_expiry + 1).await?; + let now = chain_evaluation_time(&ctx).await?; + ensure!( + now > expiry && now < expiry + 60 && now < deadline - 60, + "certificate is not expired within grace and before the rotation deadline" + ); + let completed = ctx.target.management().rotation().await?; + let roots = ctx.target.trust_anchor().await?; + ensure!( + !completed.rotating() + && completed.active_identities_on_old_root == 0 + && roots["roots"] + .as_array() + .is_some_and(|entries| entries.len() == 1 && entries[0]["thumbprint"] == new_root), + "expiry of the last active old-root certificate did not finish the rotation early" + ); + // DVLS's intent, "Terminology": `grace` holds "no unexpired certificate but one within renewal grace". + ensure!( + identity_record(&ctx.target, &expired.identity_id).await?.status == IdentityStatus::Grace, + "an identity whose only certificate expired within renewal grace is not in grace" + ); + + let new_key = KeyPair::generate()?; + let renewed = ctx.target.renew(&expired, &new_key, &json!({})).await?; + expect_status(&renewed, 200)?; + let chain = renewed.body["certificate_chain"] + .as_array() + .context("grace renewal chain")? + .iter() + .map(|cert| cert.as_str().context("chain element").map(str::to_owned)) + .collect::>>()?; + verify_chain(&chain, &roots, chain_evaluation_time(&ctx).await?)?; + ensure!( + thumbprint(chain.last().context("new root")?)? == new_root + && new_root != old_root + && !ctx.target.management().rotation().await?.rotating(), + "expired old-root certificate did not renew onto the new root after early completion" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_completes_early_at_certificate_expiry_via_freeze(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + ctx.target.faults(&json!({ "leaf_lifetime_secs": 30 })).await?; + let (_, short) = issued(&ctx, "freeze-short-old-root").await?; + let expiry = identity_record(&ctx.target, &short.identity_id) + .await? + .not_after() + .context("identity has no current certificate")? + .unix_timestamp(); + let old_root = thumbprint(short.certificate_chain.last().context("missing old root")?)?; + ctx.target.faults(&json!({ "leaf_lifetime_secs": 180 })).await?; + let (_, long) = issued(&ctx, "freeze-long-old-root").await?; + let started = ctx.target.management().rotate(RotationDeadline::Default).await?; + ensure!( + started.rotating() && started.active_identities_on_old_root == 2, + "rotation did not start with two active old-root certificates" + ); + let deadline = started.deadline()?.unix_timestamp(); + ctx.target.management().revoke(&long.identity_id).await?; + + // A certificate is unexpired while `now <= notAfter` (CONTRACT.agent-identity.md §6), so the rotation completes one second after `notAfter`. + let at_expiry = ctx.target.control("time/freeze", &json!({ "now": expiry })).await?; + expect_status(&at_expiry, 200)?; + ensure!( + at_expiry.body["published_roots"] + .as_array() + .is_some_and(|roots| roots.len() == 2) + && ctx.target.management().rotation().await?.active_identities_on_old_root == 1, + "old root was removed while the last certificate was unexpired at its notAfter" + ); + let expired = ctx.target.control("time/freeze", &json!({ "now": expiry + 1 })).await?; + expect_status(&expired, 200)?; + ensure!( + expiry + 1 < deadline + && expired.body["published_roots"] + .as_array() + .is_some_and(|roots| roots.len() == 1 && roots[0] != old_root) + && !ctx.target.management().rotation().await?.rotating(), + "freezing the clock just after certificate expiry did not complete the rotation immediately" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_push_rate_survives_deadline(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Load + ctx.target + .faults(&json!({ "rotation_rate_limit_per_sec": 1, "leaf_lifetime_secs": 30 })) + .await?; + let mut pushes = tokio::task::JoinSet::new(); + for index in 0..3 { + let (_, identity) = issued(&ctx, &format!("rate-{index}")).await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + pushes.spawn(async move { + let push = stream.next().await?; + ensure!( + matches!( + push.payload, + Some(agent_channel_proto::server_message::Payload::RenewRequested(ref renewal)) + if renewal.reason == "rotation" + ), + "queued rotation push has the wrong payload" + ); + Ok::<_, anyhow::Error>(Instant::now()) + }); + } + let start = Instant::now(); + ctx.target.management().rotate(RotationDeadline::Now).await?; + let first = tokio::time::timeout(Duration::from_millis(500), pushes.join_next()) + .await? + .context("first rotation push missing")???; + ensure!( + first.duration_since(start) < Duration::from_millis(500), + "first rotation push was not immediate" + ); + for _ in 0..10 { + let roots = ctx.target.trust_anchor().await?; + ensure!( + roots["roots"].as_array().is_some_and(|items| items.len() == 1), + "deadline did not apply" + ); + } + if start.elapsed() < Duration::from_millis(800) { + ensure!( + pushes.try_join_next().is_none(), + "HTTP requests reset the rotation push rate budget" + ); + } + let second = tokio::time::timeout(Duration::from_secs(3), pushes.join_next()) + .await? + .context("second rotation push did not drain after deadline")???; + let third = tokio::time::timeout(Duration::from_secs(3), pushes.join_next()) + .await? + .context("third rotation push did not drain after deadline")???; + ensure!( + second.duration_since(first) >= Duration::from_millis(800) + && third.duration_since(second) >= Duration::from_millis(800), + "rotation pushes exceeded one per elapsed second" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_deadline_removes_old_root(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + if !ctx.mock() { + wait_rotation_idle(&ctx.target, ctx.dvls_rotation_window_secs).await?; + } + let original = ctx.target.trust_anchor().await?; + let old_thumb = field(&original["roots"][0], "thumbprint")?.to_owned(); + let _ = issued(&ctx, "deadline-old-root").await?; + let started = if ctx.mock() { + let now = ctx.target.control("time/advance", &json!({ "secs": 0 })).await?.body["now"] + .as_i64() + .context("mock clock is missing")?; + let deadline = time::OffsetDateTime::from_unix_timestamp(now + 20)?; + ctx.target.management().rotate(RotationDeadline::At(deadline)).await? + } else { + begin_rotation(&ctx).await? + }; + let deadline = started.deadline()?.unix_timestamp(); + ensure!( + ctx.target.trust_anchor().await?["roots"] + .as_array() + .context("roots")? + .len() + == 2, + "old root not published" + ); + if ctx.mock() { + let before = ctx + .target + .control("time/freeze", &json!({ "now": deadline - 1 })) + .await?; + expect_status(&before, 200)?; + ensure!( + before.body["now"] == deadline - 1 + && before.body["published_roots"] + .as_array() + .is_some_and(|roots| roots.len() == 2 && roots.iter().any(|root| root == &old_thumb)), + "old root was removed before the deadline" + ); + ensure!( + ctx.target.trust_anchor().await?["roots"] + .as_array() + .is_some_and(|roots| { roots.len() == 2 && roots.iter().any(|root| root["thumbprint"] == old_thumb) }), + "trust-anchor omitted the old root before the deadline" + ); + let at_deadline = ctx.target.control("time/freeze", &json!({ "now": deadline })).await?; + expect_status(&at_deadline, 200)?; + ensure!( + at_deadline.body["now"] == deadline + && at_deadline.body["published_roots"] + .as_array() + .is_some_and(|roots| roots.len() == 1 && roots.iter().all(|root| root != &old_thumb)), + "old root remained published at the exact deadline" + ); + } else { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + ensure!(deadline > now + 2, "DVLS deadline passed before boundary check"); + tokio::time::sleep(Duration::from_secs(u64::try_from(deadline - now - 2)?)).await; + ensure!( + ctx.target.trust_anchor().await?["roots"] + .as_array() + .is_some_and(|roots| roots.len() == 2), + "DVLS removed the old root before the deadline" + ); + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + if now < deadline { + tokio::time::sleep(Duration::from_secs(u64::try_from(deadline - now)?)).await; + } + } + let mut after = ctx.target.trust_anchor().await?; + if !ctx.mock() && after["roots"].as_array().is_none_or(|roots| roots.len() != 1) { + let started = Instant::now(); + loop { + after = ctx.target.trust_anchor().await?; + if after["roots"].as_array().is_some_and(|roots| roots.len() == 1) { + break; + } + ensure!( + started.elapsed() <= Duration::from_secs(2), + "DVLS removed the old root more than two seconds after the deadline" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + let entries = after["roots"].as_array().context("roots after deadline")?; + ensure!(entries.len() == 1, "old root remained after the exact mock deadline"); + ensure!( + ctx.mock() || time::OffsetDateTime::now_utc().unix_timestamp() <= deadline + 2, + "old root was removed more than two seconds after deadline" + ); + ensure!(entries[0]["thumbprint"] != old_thumb, "old root still trusted"); + Ok(()) +} + +pub(crate) async fn p_rotation_old_root_cert_renewable_after_deadline(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + let (_, old) = issued(&ctx, "still-allowed").await?; + let original_root = thumbprint(old.certificate_chain.last().context("missing old root")?)?; + ctx.target.management().rotate(RotationDeadline::Now).await?; + ctx.target.advance(1).await?; + let roots = ctx.target.trust_anchor().await?; + ensure!( + roots["roots"] + .as_array() + .context("published roots")? + .iter() + .all(|root| root["thumbprint"] != original_root), + "old root remained published after emergency deadline" + ); + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&old, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + let new_chain = renewed.body["certificate_chain"] + .as_array() + .context("renewal chain")? + .iter() + .map(|value| value.as_str().context("chain item").map(str::to_owned)) + .collect::>>()?; + verify_chain(&new_chain, &roots, chain_evaluation_time(&ctx).await?)?; + ensure!( + thumbprint(new_chain.last().context("root")?)? != original_root, + "renewal issued under old root" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_emergency_deadline(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Rotation + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + if !ctx.mock() { + ensure!( + ctx.disposable_dvls_target, + "DVLS rotation requires --disposable-dvls-target" + ); + wait_rotation_idle(&ctx.target, ctx.dvls_rotation_window_secs).await?; + } + let (_, identity) = issued(&ctx, "emergency-old-root").await?; + let before = ctx.target.trust_anchor().await?; + let roots = before["roots"].as_array().context("roots before emergency rotation")?; + ensure!( + roots.len() == 1, + "emergency rotation did not start with one published root" + ); + let old_thumb = field(&roots[0], "thumbprint")?.to_owned(); + let old_record = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + old_record.status == IdentityStatus::Active + && !old_record.connected + && old_record.issuer() == Some(&*old_thumb) + && old_record.has_certificate(&identity.thumbprint, CertificateStatus::Current), + "emergency rotation fixture lacks an active, unconnected old-root identity" + ); + ctx.target.management().rotate(RotationDeadline::Now).await?; + let started = Instant::now(); + loop { + let after = ctx.target.trust_anchor().await?; + let published = after["roots"].as_array().context("emergency roots")?; + if published.len() == 1 && published[0]["thumbprint"] != old_thumb { + break; + } + ensure!( + !ctx.mock() && started.elapsed() < Duration::from_secs(2), + "emergency deadline retained the old root while its identity was active" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let remaining = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + remaining.status == IdentityStatus::Active + && !remaining.connected + && remaining.issuer() == Some(&*old_thumb) + && remaining.has_certificate(&identity.thumbprint, CertificateStatus::Current), + "emergency rotation changed the old-root identity rather than only publishing the new root" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_request_renewal_pushed(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + let (_, identity) = issued(&ctx, "rotation-push").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + begin_rotation(&ctx).await?; + let push = stream + .next_with_timeout(if ctx.mock() { + Duration::from_secs(5) + } else { + Duration::from_secs(ctx.dvls_rotation_window_secs.saturating_add(5)) + }) + .await?; + ensure!( + matches!( + push.payload, + Some(agent_channel_proto::server_message::Payload::RenewRequested(ref reason)) + if reason.reason == "rotation" + ), + "rotation did not push RenewRequested(rotation)" + ); + stream.ack(&push).await?; + finish_rotation(&ctx).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_pending_only_old_root_receives_push(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation + ctx.target.faults(&json!({ "leaf_lifetime_secs": 30 })).await?; + let (_, mut identity) = issued(&ctx, "pending-only-old-root").await?; + let old_root = thumbprint(identity.certificate_chain.last().context("missing old root")?)?; + let current_expiry = identity_record(&ctx.target, &identity.identity_id) + .await? + .not_after() + .context("identity has no current certificate")? + .unix_timestamp(); + ctx.target.faults(&json!({ "leaf_lifetime_secs": 120 })).await?; + let key = KeyPair::generate()?; + let renewed = ctx.target.renew(&identity, &key, &json!({})).await?; + expect_status(&renewed, 200)?; + identity.adopt_certificate(&renewed.body["certificate_chain"], key)?; + ensure!( + thumbprint(identity.certificate_chain.last().context("missing pending root")?)? == old_root, + "pending certificate was not issued under the old root" + ); + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + let until_expiry = current_expiry - chain_evaluation_time(&ctx).await?; + ensure!(until_expiry > 0, "current certificate expired before the boundary test"); + // A certificate is unexpired while `now <= notAfter` (CONTRACT.agent-identity.md §6), so the current certificate expires one second after `notAfter`. + ctx.target.advance(until_expiry + 1).await?; + let record = identity_record(&ctx.target, &identity.identity_id).await?; + ensure!( + record.connected && record.has_certificate(&identity.thumbprint, CertificateStatus::Pending), + "pending-only old-root stream was not connected after current expiry" + ); + // DVLS's intent, "Terminology": `active` holds "an unexpired current or pending certificate". + ensure!( + record.status == IdentityStatus::Active, + "an identity with only an unexpired pending certificate is not active" + ); + let started = ctx.target.management().rotate(RotationDeadline::Default).await?; + ensure!( + started.rotating() + && started.active_identities_on_old_root == 1 + && ctx.target.trust_anchor().await?["roots"] + .as_array() + .is_some_and(|roots| roots.len() == 2), + "rotation omitted the pending-only old-root identity" + ); + let push = stream.next().await?; + ensure!( + matches!( + push.payload, + Some(agent_channel_proto::server_message::Payload::RenewRequested(ref reason)) + if reason.reason == "rotation" + ), + "pending-only old-root stream did not receive RenewRequested(rotation)" + ); + stream.ack(&push).await?; + ensure!( + identity_record(&ctx.target, &identity.identity_id) + .await? + .renewal_requested, + "rotation did not persist the request-renewal flag for the pending-only identity" + ); + Ok(()) +} + +pub(crate) async fn p_listing_pagination_stable_during_concurrent_enrollment(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Identities + let token = token(&ctx, 10).await?; + let mut created = Vec::new(); + for n in 0..4 { + created.push( + identity(&ctx.target, &token, &format!("initial-{n}")) + .await? + .identity_id, + ); + } + let initial = created.clone(); + let mut overlapping = Vec::new(); + let mut page = 1; + loop { + ensure!(page <= 20, "overlapping page walk did not reach the last page"); + let listing = listing_page(&ctx.target, &token, page).await?; + overlapping.extend(listing.rows.into_iter().map(|(id, _)| id)); + if page >= listing.total_pages { + break; + } + if page <= 3 { + for n in 0..2 { + created.push( + identity(&ctx.target, &token, &format!("overlapping-{page}-{n}")) + .await? + .identity_id, + ); + } + } + page += 1; + } + let mut distinct: Vec = Vec::new(); + for id in overlapping { + if !distinct.contains(&id) { + distinct.push(id); + } + } + ensure!( + initial.iter().all(|id| distinct.contains(id)), + "overlapping page walk missed an identity that existed when it started" + ); + let mut in_creation_order = created.iter(); + ensure!( + distinct.iter().all(|id| in_creation_order.any(|created| created == id)), + "overlapping page walk listed identities out of creation order" + ); + + let mut walked = Vec::new(); + let mut timestamps = Vec::new(); + for page in 1..=5 { + let listing = listing_page(&ctx.target, &token, page).await?; + ensure!( + listing.total_count == created.len() as u64 && listing.total_pages == 5, + "page {page} reports {} identities on {} pages instead of {} on 5", + listing.total_count, + listing.total_pages, + created.len() + ); + for (id, created_at) in listing.rows { + walked.push(id); + timestamps.push(created_at); + } + } + ensure!( + walked == created, + "page walk without concurrent enrollment did not list every identity exactly once in creation order" + ); + ensure!( + timestamps.windows(2).all(|pair| pair[0] <= pair[1]), + "creation time decreased in creation order" + ); + ensure!( + refusal(listing_page(&ctx.target, &token, 6).await)? == Refusal::Invalid, + "page past the last page was not refused" + ); + Ok(()) +} + +/// One two-row page of the token's identities as `(id, creation time)` pairs, with the reported totals. +struct ListingPage { + rows: Vec<(String, time::OffsetDateTime)>, + total_count: u64, + total_pages: u64, +} + +async fn listing_page(target: &Target, token: &Token, page: u64) -> anyhow::Result { + let listing = target + .management() + .identities(&IdentityQuery::enrolled_with(token).full().page(page, 2)) + .await?; + let rows = listing + .items + .into_iter() + .map(|row| { + Ok(( + row.id, + row.created_at.context("full listing omitted the creation time")?, + )) + }) + .collect::>>()?; + Ok(ListingPage { + rows, + total_count: listing.total_count, + total_pages: listing.total_pages, + }) +} + +pub(crate) async fn p_listing_views_filters_and_bounds(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Management API + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Identities + let management = ctx.target.management(); + let decoy_token = token(&ctx, 1).await?; + let token = token(&ctx, 5).await?; + let active_key = KeyPair::generate()?; + let active_reply = ctx + .target + .enroll( + &token.text, + &active_key, + &json!({ "hostname": "filter-host", "os_name": "Linux" }), + ) + .await?; + let active = Identity::from_enrollment(active_key, &active_reply)?; + let revoked = identity(&ctx.target, &token, "other-host").await?; + let decoy = identity(&ctx.target, &decoy_token, "decoy-host").await?; + ensure!( + management.identity_count(&decoy_token).await? == 1, + "decoy token identity not listed" + ); + ensure!( + identity_record(&ctx.target, &decoy.identity_id).await?.id == decoy.identity_id, + "decoy identity missing" + ); + management.revoke(&revoked.identity_id).await?; + let renamed = management.rename(&active.identity_id, "Filter-Host-Renamed").await?; + ensure!( + renamed.friendly_name == "Filter-Host-Renamed", + "identity rename was not persisted" + ); + for invalid in ["".to_owned(), "x".repeat(256)] { + ensure!( + refusal(management.rename(&active.identity_id, &invalid).await)? == Refusal::Invalid, + "friendly name of {} characters was accepted", + invalid.chars().count() + ); + } + let of_token = || IdentityQuery::enrolled_with(&token); + let summary = management + .identities( + &of_token() + .summary() + .metadata(&["hostname", "arch"]) + .status(IdentityStatus::Active), + ) + .await?; + let [row] = summary.items.as_slice() else { + anyhow::bail!("active filter returned {} identities", summary.items.len()); + }; + ensure!( + summary.total_count == 1 && row.id == active.identity_id, + "active filter selected wrong identity" + ); + ensure!( + row.friendly_name == "Filter-Host-Renamed", + "summary did not reflect renamed identity" + ); + ensure!( + row.metadata_value("hostname") == Some("filter-host"), + "metadata subset missing hostname" + ); + ensure!( + row.metadata_value("arch").is_none() && row.metadata_value("os_name").is_none(), + "metadata subset included a key outside the subset" + ); + ensure!(row.certificates.is_none(), "summary included full certificates"); + let full = management + .identities(&of_token().full().status(IdentityStatus::Revoked)) + .await?; + ensure!( + full.total_count == 1 + && full.items.len() == 1 + && full.items[0].id == revoked.identity_id + && full.items[0].status == IdentityStatus::Revoked, + "revoked filter selected wrong identity or status" + ); + ensure!(full.items[0].certificates.is_some(), "full view has no certificates"); + let by_name = management.identities(&of_token().name_contains("FILTER-HOST")).await?; + ensure!( + by_name.total_count == 1 && by_name.items.first().is_some_and(|row| row.id == active.identity_id), + "friendly-name substring filter failed" + ); + let all_metadata = management + .identities(&of_token().full().status(IdentityStatus::Active)) + .await?; + ensure!( + all_metadata + .items + .first() + .is_some_and(|row| row.metadata_value("os_name") == Some("Linux")), + "full view omitted metadata" + ); + let issuer = row.issuer().context("summary omitted the current certificate")?; + let by_issuer = management.identities(&of_token().issuer(issuer)).await?; + ensure!( + by_issuer.total_count == 2 + && by_issuer.items.iter().map(|row| row.id.as_str()).collect::>() + == [active.identity_id.as_str(), revoked.identity_id.as_str()], + "issuer filter returned incorrect identity IDs" + ); + let paged = management.identities(&of_token().page(2, 1)).await?; + ensure!( + paged.size == 1 + && paged.number == 2 + && paged.total_count == 2 + && paged.total_pages == 2 + && paged.items.len() == 1 + && paged.items[0].id == revoked.identity_id, + "page fields incorrect" + ); + // A page past the last, and a page number beyond the management API's integers, are invalid. + for (number, size) in [ + (1, 0), + (1, dvls::MAX_PAGE_SIZE + 1), + (0, 1), + (3, 1), + (u64::MAX, dvls::MAX_PAGE_SIZE), + ] { + ensure!( + refusal(management.identities(&of_token().page(number, size)).await)? == Refusal::Invalid, + "page {number} of size {size} was accepted" + ); + } + expect_status( + &ctx.target + .renew(&active, &KeyPair::generate()?, &json!({ "hostname": "filter-host" })) + .await?, + 200, + )?; + next_last_seen_second(&ctx).await?; + expect_status( + &ctx.target + .check_in(&active, &json!({ "hostname": "filter-host" })) + .await?, + 200, + )?; + let observed = identity_record(&ctx.target, &active.identity_id).await?; + let instant = observed + .last_seen_at + .context("check-in did not set the last-seen time for filtering")?; + let after = instant - time::Duration::seconds(1); + let before = instant + time::Duration::seconds(1); + for (query, expected) in [ + (of_token().status(IdentityStatus::Active).last_seen_from(after), true), + (of_token().status(IdentityStatus::Active).last_seen_to(before), true), + (of_token().status(IdentityStatus::Active).last_seen_from(before), false), + ] { + let result = management.identities(&query).await?; + ensure!( + result.total_count == u64::from(expected) + && if expected { + result.items.len() == 1 + && result.items[0].id == active.identity_id + && result.items[0].status == IdentityStatus::Active + } else { + result.items.is_empty() + }, + "last-seen filter returned the wrong identity IDs or statuses" + ); + } + if ctx.mock() { + // The active identity's current and pending certificates both last 90 days, so its renewal grace lasts 90 more. + for (days, status) in [(91, IdentityStatus::Grace), (90, IdentityStatus::Expired)] { + ctx.target.advance(days * 24 * 3600).await?; + let filtered = management.identities(&of_token().status(status)).await?; + ensure!( + filtered.total_count == 1 + && filtered.items.len() == 1 + && filtered.items[0].id == active.identity_id + && filtered.items[0].status == status, + "{status:?} status filter selected wrong identity ID or status" + ); + } + } + Ok(()) +} + +pub(crate) async fn p_management_tokens_crud_and_states(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens + let management = ctx.target.management(); + let token = token(&ctx, 1).await?; + let record = management.token(&token.id).await?; + ensure!( + record.state == TokenState::Active && record.used_count == 0, + "new token not active" + ); + ensure!( + !management.token_discloses_secret(&token).await?, + "token secret leaked in its management API record" + ); + ensure!( + management + .all_tokens() + .await? + .iter() + .any(|record| record.id == token.id), + "created token not listed" + ); + let _ = identity(&ctx.target, &token, "exhaust").await?; + let record = management.token(&token.id).await?; + ensure!( + record.state == TokenState::Exhausted && record.used_count == 1, + "exhausted state incorrect" + ); + if ctx.mock() { + let expiring = management.create_token(1, Duration::from_secs(2), None).await?; + ctx.target.advance(4).await?; + ensure!( + management.token(&expiring.id).await?.state == TokenState::Expired, + "expired token did not enter expired state" + ); + } + management.delete_token(&token.id).await?; + ensure!( + refusal(management.token(&token.id).await)? == Refusal::NotFound, + "deleted token is still readable" + ); + // DVLS's intent, "Enrollment tokens": a token allows from 1 to 1,000,000 uses and expires at most 365 days ahead. + for uses in [0, 1_000_001] { + let spec = TokenSpec::new(uses, TOKEN_LIFETIME)?; + ensure!( + refusal(management.create_token_from(&spec).await)? == Refusal::Invalid, + "token with {uses} uses was accepted" + ); + } + let spec = TokenSpec::new(1, Duration::from_secs(366 * 24 * 3600))?; + ensure!( + refusal(management.create_token_from(&spec).await)? == Refusal::Invalid, + "token expiring after 365 days was accepted" + ); + // The document: a token page holds at most 500 tokens. + management.tokens(1, dvls::MAX_TOKEN_PAGE_SIZE).await?; + for (number, size) in [(1, 0), (1, dvls::MAX_TOKEN_PAGE_SIZE + 1), (0, 1)] { + ensure!( + refusal(management.tokens(number, size).await)? == Refusal::Invalid, + "token page {number} of size {size} was accepted" + ); + } + Ok(()) +} + +pub(crate) async fn p_management_requires_auth(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Management API + assert_management_routes_denied(&ctx, Credential::Absent, |_| true, Refusal::Unauthenticated).await?; + // oracle: spec DVLS public API contract §6 + let unknown = unknown_token_id(); + assert_management_routes_denied(&ctx, Credential::TokenId(&unknown), |_| true, Refusal::Unauthenticated).await?; + Ok(()) +} + +/// A `tokenId` that names no session. +/// +/// It is a GUID, so that DVLS refuses it as an invalid token, with a `PublicApiError`, rather than as a malformed one, with an empty 401 (DVLS's public API contract, "6. Errors"). +fn unknown_token_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +fn trust_anchor_snapshot(value: &Value) -> anyhow::Result> { + let roots = value["roots"] + .as_array() + .context("trust-anchor response omitted roots")?; + let mut known = roots + .iter() + .map(|root| { + let mut known = serde_json::Map::new(); + for name in ["certificate", "thumbprint", "not_before", "not_after"] { + known.insert(name.to_owned(), json!(field(root, name)?)); + } + Ok(Value::Object(known)) + }) + .collect::>>()?; + known.sort_by(|left, right| left["thumbprint"].as_str().cmp(&right["thumbprint"].as_str())); + Ok(known) +} + +/// Checks that `credential` gets `expected` from every management API route that `selected` keeps, without any state change. +async fn assert_management_routes_denied( + ctx: &Context, + credential: Credential<'_>, + selected: impl Fn(Permission) -> bool, + expected: Refusal, +) -> anyhow::Result<()> { + let token = token(ctx, 1).await?; + let identity = identity(&ctx.target, &token, "untouched").await?; + let mut stream = if ctx.mock() && ctx.channel_available { + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + Some(stream) + } else { + None + }; + let management = ctx.target.management(); + let identity_before = identity_record(&ctx.target, &identity.identity_id).await?; + let token_before = management.token(&token.id).await?; + let roots_before = trust_anchor_snapshot(&ctx.target.trust_anchor().await?)?; + let rotation_before = management.rotation().await?; + let settings_before = management.settings().await?; + let events_before = if ctx.mock() { + Some(ctx.target.events(&identity.identity_id).await?) + } else { + None + }; + let mut create = TokenSpec::new(1, TOKEN_LIFETIME)?; + create.name = format!("unauthorized-{}", uuid::Uuid::new_v4()); + // On a non-disposable DVLS target, the rotation request is malformed and the settings change empty, so that neither can change the target even if authorization is broken. + let routes = dvls::routes(&token.id, &identity.identity_id, &create, "unauthorized", !ctx.mock())?; + let denied = ctx.target.management_as(credential); + for route in routes.iter().filter(|route| selected(route.permission)) { + ensure!( + refusal(denied.call(route).await)? == expected, + "unauthorized {route} was not refused as {expected:?}" + ); + ensure!( + management.token(&token.id).await? == token_before, + "unauthorized {route} changed the enrollment token" + ); + ensure!( + management + .all_tokens() + .await? + .iter() + .all(|record| record.name != create.name), + "unauthorized {route} created a token" + ); + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == identity_before, + "unauthorized {route} changed the identity" + ); + ensure!( + management.rotation().await? == rotation_before + && trust_anchor_snapshot(&ctx.target.trust_anchor().await?)? == roots_before, + "unauthorized {route} changed CA rotation state" + ); + ensure!( + management.settings().await? == settings_before, + "unauthorized {route} changed the settings" + ); + if let Some(events) = &events_before { + ensure!( + ctx.target.events(&identity.identity_id).await? == *events, + "unauthorized {route} changed the channel or certificate events" + ); + } + } + if let Some(stream) = stream.as_mut() { + ensure!( + tokio::time::timeout(Duration::from_millis(100), stream.stream.message()) + .await + .is_err(), + "unauthorized management API request pushed a channel message" + ); + } + Ok(()) +} + +/// The OpenAPI document gives each operation a 403 response that names the permission it lacks, such as "Missing the AgentIdentityManage permission" for a settings change. +/// +/// Each route group, the settings included, is called with a credential lacking that group's permission; on the mock it holds every other permission, so none stands in for the missing one. +pub(crate) async fn p_management_permission_denied(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Management API + for permission in Permission::ALL { + let session = + dvls::session_lacking(&ctx, permission).ok_or(crate::Incomplete("requires --unprivileged-app-key"))?; + assert_management_routes_denied( + &ctx, + Credential::Session(&session), + |required| required == permission, + Refusal::Forbidden, + ) + .await + .with_context(|| format!("credential lacking {permission:?}"))?; + } + Ok(()) +} + +pub(crate) async fn p_rotation_start_requires_auth(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Management API + ensure!( + ctx.mock() || ctx.disposable_dvls_target, + "rotation authorization requires a disposable DVLS target" + ); + let before = trust_anchor_snapshot(&ctx.target.trust_anchor().await?)?; + let rotation = ctx.target.management().rotation().await?; + // oracle: spec DVLS public API contract §6 + let unknown = unknown_token_id(); + let lacking = dvls::session_lacking(&ctx, Permission::RotateRoot); + let mut credentials = vec![ + (Credential::Absent, Refusal::Unauthenticated), + (Credential::TokenId(&unknown), Refusal::Unauthenticated), + ]; + credentials.extend( + lacking + .as_deref() + .map(|session| (Credential::Session(session), Refusal::Forbidden)), + ); + for (credential, expected) in credentials { + ensure!( + refusal(ctx.target.management_as(credential).rotate(RotationDeadline::Now).await)? == expected, + "unauthorized rotation was not refused as {expected:?}" + ); + ensure!( + trust_anchor_snapshot(&ctx.target.trust_anchor().await?)? == before, + "unauthorized rotation changed published roots" + ); + ensure!( + ctx.target.management().rotation().await? == rotation, + "unauthorized rotation changed state" + ); + } + Ok(()) +} + +/// The OpenAPI document, on a token's `expiresOn`: "in the future and at most 365 days ahead ... (Default: 90 days ahead)". +pub(crate) async fn p_token_expiry_default_and_maximum(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens + let management = ctx.target.management(); + let mut spec = TokenSpec::new(1, TOKEN_LIFETIME)?; + spec.expires_on = None; + let default = management.create_token_from(&spec).await?; + let record = management.token(&default.id).await?; + let ahead = record.expires_on - record.created_on; + ensure!( + (ahead - time::Duration::days(90)).abs() <= time::Duration::MINUTE, + "a token created without an expiry expires {ahead} after its creation instead of 90 days" + ); + let longest = management + .create_token_from(&TokenSpec::new(1, Duration::from_secs(365 * DAY_SECS - 3600))?) + .await?; + let mut past = TokenSpec::new(1, TOKEN_LIFETIME)?; + past.expires_on = Some(time::OffsetDateTime::now_utc() - time::Duration::HOUR); + ensure!( + refusal(management.create_token_from(&past).await)? == Refusal::Invalid, + "a token that has already expired was accepted" + ); + for token in [default, longest] { + management.delete_token(&token.id).await?; + } + Ok(()) +} + +/// The OpenAPI document: starting a rotation is 409 when "no root exists yet", and "The first token also creates the Agent Identity root". +pub(crate) async fn p_rotation_without_root_conflicts(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Root CA + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Management API + expect_status( + &ctx.target.control("reset", &json!({ "without_root": true })).await?, + 200, + )?; + let published = |roots: &Value| roots["roots"].as_array().map(Vec::len); + ensure!( + published(&ctx.target.trust_anchor().await?) == Some(0), + "an authority without a root published one" + ); + ensure!( + refusal(ctx.target.management().rotate(RotationDeadline::Default).await)? == Refusal::Conflict, + "a rotation started without a root" + ); + ensure!( + !ctx.target.management().rotation().await?.rotating() + && published(&ctx.target.trust_anchor().await?) == Some(0), + "a refused rotation without a root changed the rotation or published a root" + ); + let _ = token(&ctx, 1).await?; + ensure!( + published(&ctx.target.trust_anchor().await?) == Some(1), + "the first token did not create the root" + ); + Ok(()) +} + +/// The OpenAPI document: starting a rotation is 409 when "the root key is unavailable", and creating the first token is 409 when "The first root cannot be created because the data encryption key is unavailable". +pub(crate) async fn p_root_key_unavailable_conflicts(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Root CA + let _ = issued(&ctx, "root-key").await?; + let roots = trust_anchor_snapshot(&ctx.target.trust_anchor().await?)?; + ctx.target.faults(&json!({ "root_key_unavailable": true })).await?; + ensure!( + refusal(ctx.target.management().rotate(RotationDeadline::Default).await)? == Refusal::Conflict, + "a rotation started with an unavailable root key" + ); + ensure!( + !ctx.target.management().rotation().await?.rotating() + && trust_anchor_snapshot(&ctx.target.trust_anchor().await?)? == roots, + "a refused rotation changed the rotation or the published roots" + ); + + expect_status( + &ctx.target.control("reset", &json!({ "without_root": true })).await?, + 200, + )?; + ctx.target.faults(&json!({ "root_key_unavailable": true })).await?; + let spec = TokenSpec::new(1, TOKEN_LIFETIME)?; + ensure!( + refusal(ctx.target.management().create_token_from(&spec).await)? == Refusal::Conflict, + "the first token was created although its root could not be" + ); + ensure!( + ctx.target + .management() + .all_tokens() + .await? + .iter() + .all(|record| record.name != spec.name) + && ctx.target.trust_anchor().await?["roots"] + .as_array() + .is_some_and(Vec::is_empty), + "a refused first token was kept or published a root" + ); + Ok(()) +} + +/// The OpenAPI document, on the settings: `authorityId` is "The authority_id agents receive at enrollment", and `isAgentChannelAvailable` is "Whether DVLS offers the agent channel: it is enabled and the hosting supports it". +/// DVLS's intent, "Certificates and channel", offers the channel as `config.agent_channel_url`. +pub(crate) async fn p_settings_read(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Management API + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Certificates and channel + let (_, identity) = issued(&ctx, "settings").await?; + let settings = ctx.target.management().settings().await?; + ensure!( + uuid::Uuid::parse_str(&settings.authority_id)? == uuid::Uuid::parse_str(&identity.authority_id)?, + "the settings report another authority ID than enrollment" + ); + ensure!( + settings.channel_available == identity.agent_channel_url.is_some(), + "the settings and enrollment disagree on whether the agent channel is offered" + ); + ensure!( + settings.channel_enabled || !settings.channel_available, + "the agent channel is offered while it is disabled" + ); + Ok(()) +} + +/// The OpenAPI document, on a settings change: `certificateLifetimeDays` is "from 1 to 365 days" and `rotationNotificationsPerSecond` "from 1 to 1000". +pub(crate) async fn p_settings_change_bounds(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Management API + require_settings_changes(&ctx)?; + let management = ctx.target.management(); + let original = management.settings().await?; + restoring_settings(&ctx, &original, async { + let lifetime = |days| SettingsChange { + certificate_lifetime_days: Some(days), + ..SettingsChange::default() + }; + let rate = |per_second| SettingsChange { + rotation_notifications_per_second: Some(per_second), + ..SettingsChange::default() + }; + for (change, name) in [ + (lifetime(0), "a certificate lifetime of 0 days"), + (lifetime(366), "a certificate lifetime of 366 days"), + (rate(0), "0 rotation notifications per second"), + (rate(1001), "1001 rotation notifications per second"), + ] { + ensure!( + refusal(management.change_settings(&change).await)? == Refusal::Invalid, + "{name} was accepted" + ); + } + ensure!( + management.settings().await? == original, + "a refused settings change changed the settings" + ); + for (change, name) in [ + (lifetime(1), "a certificate lifetime of 1 day"), + (lifetime(365), "a certificate lifetime of 365 days"), + (rate(1), "1 rotation notification per second"), + (rate(1000), "1000 rotation notifications per second"), + ] { + let changed = management.change_settings(&change).await.context(name)?; + ensure!( + changed == management.settings().await? + && change + .certificate_lifetime_days + .is_none_or(|days| changed.certificate_lifetime_days == days) + && change + .rotation_notifications_per_second + .is_none_or(|per_second| changed.rotation_notifications_per_second == per_second), + "{name} was not applied" + ); + } + Ok(()) + }) + .await +} + +/// The OpenAPI document: `certificateLifetimeDays` is "The lifetime of the identity certificates issued from now on, in days". +pub(crate) async fn p_settings_certificate_lifetime_applies_to_new_certificates(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Certificates and channel + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Management API + require_settings_changes(&ctx)?; + let management = ctx.target.management(); + let original = management.settings().await?; + if !ctx.mock() && ctx.leaf_lifetime_secs != original.certificate_lifetime_days * DAY_SECS { + return Err( + crate::Incomplete("requires --leaf-lifetime-secs to match the DVLS certificate lifetime in days").into(), + ); + } + let (_, earlier) = issued(&ctx, "lifetime-before").await?; + let earlier_expiry = identity_record(&ctx.target, &earlier.identity_id) + .await? + .not_after() + .context("identity has no current certificate")?; + let days = if original.certificate_lifetime_days == 7 { 8 } else { 7 }; + restoring_settings(&ctx, &original, async { + let changed = management + .change_settings(&SettingsChange { + certificate_lifetime_days: Some(days), + ..SettingsChange::default() + }) + .await?; + ensure!( + changed.certificate_lifetime_days == days && management.settings().await? == changed, + "the certificate lifetime change was not applied" + ); + let issued_from = time::OffsetDateTime::now_utc(); + let (_, later) = issued(&ctx, "lifetime-after").await?; + expect_status( + &ctx.target.renew(&earlier, &KeyPair::generate()?, &json!({})).await?, + 200, + )?; + let issued_until = time::OffsetDateTime::now_utc(); + // The tolerance covers the clock difference between the runner and the authority; a lifetime one day off falls outside it. + let tolerance = time::Duration::minutes(5); + let lifetime = time::Duration::days(i64::try_from(days)?); + let expected = (issued_from + lifetime - tolerance)..=(issued_until + lifetime + tolerance); + let later = identity_record(&ctx.target, &later.identity_id).await?; + let earlier = identity_record(&ctx.target, &earlier.identity_id).await?; + for (certificate, name) in [ + ( + later.certificate_with(CertificateStatus::Current), + "an enrolled certificate", + ), + ( + earlier.certificate_with(CertificateStatus::Pending), + "a renewed certificate", + ), + ] { + let not_after = certificate.with_context(|| format!("{name} is missing"))?.not_after; + ensure!( + expected.contains(¬_after), + "{name} issued after the change expires at {not_after} instead of {days} days after its issuance" + ); + } + ensure!( + earlier.not_after() == Some(earlier_expiry), + "the certificate lifetime change altered a certificate issued before it" + ); + Ok(()) + }) + .await +} + +/// The OpenAPI document: `isAgentChannelEnabled` "turns the agent channel off behind a reverse proxy that breaks gRPC", and `isAgentChannelAvailable` is "Whether DVLS offers the agent channel: it is enabled and the hosting supports it". +/// DVLS's intent, "Certificates and channel", offers the channel as `config.agent_channel_url`. +pub(crate) async fn p_settings_channel_switch_withdraws_and_restores_channel_url(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Certificates and channel + require_settings_changes(&ctx)?; + let management = ctx.target.management(); + let original = management.settings().await?; + let (_, identity) = issued(&ctx, "channel-switch").await?; + let url = identity + .agent_channel_url + .clone() + .context("enrollment offered no agent channel")?; + ensure!( + original.channel_enabled && original.channel_available, + "the settings do not report the agent channel that enrollment offered" + ); + restoring_settings(&ctx, &original, async { + let switch = |enabled| SettingsChange { + channel_enabled: Some(enabled), + ..SettingsChange::default() + }; + let disabled = management.change_settings(&switch(false)).await?; + ensure!( + !disabled.channel_enabled && !disabled.channel_available, + "the agent channel is still offered after it was disabled" + ); + ensure!( + check_in_config(&ctx.target, &identity) + .await? + .get("agent_channel_url") + .is_none(), + "check-in config kept the agent channel URL after the channel was disabled" + ); + let enabled = management.change_settings(&switch(true)).await?; + ensure!( + enabled.channel_enabled && enabled.channel_available, + "the agent channel is not offered after it was enabled again" + ); + ensure!( + check_in_config(&ctx.target, &identity).await?["agent_channel_url"] == url.as_str(), + "check-in config did not restore the agent channel URL" + ); + Ok(()) + }) + .await +} + +const DAY_SECS: u64 = 24 * 3600; + +/// Changing the settings of a DVLS target affects every agent it serves, so only a disposable one allows it. +fn require_settings_changes(ctx: &Context) -> anyhow::Result<()> { + if ctx.mock() || ctx.disposable_dvls_target { + Ok(()) + } else { + Err(crate::Incomplete("changing settings requires --disposable-dvls-target").into()) + } +} + +/// Runs `test`, then restores `original`, so that a failing test leaves the target's settings as it found them. +async fn restoring_settings( + ctx: &Context, + original: &Settings, + test: impl Future>, +) -> anyhow::Result<()> { + let result = test.await; + let restored = ctx + .target + .management() + .change_settings(&SettingsChange { + channel_enabled: Some(original.channel_enabled), + certificate_lifetime_days: Some(original.certificate_lifetime_days), + rotation_notifications_per_second: Some(original.rotation_notifications_per_second), + }) + .await; + result?; + ensure!( + restored? == *original, + "restoring the settings did not return them to their original values" + ); + Ok(()) +} + +pub(crate) async fn p_error_body_shape(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + let token = token(&ctx, 2).await?; + let invalid = ctx + .target + .agent( + Method::POST, + "/enroll", + Some(&json!({ "csr": "not valid base64!" })), + Some(&token.text), + ) + .await?; + expect_error(&invalid, 400, "invalid_request")?; + let unknown = ctx + .target + .enroll( + &format!( + "{}.{}", + token.text.rsplit_once('.').context("token structure")?.0, + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x19; 32]) + ), + &KeyPair::generate()?, + &json!({}), + ) + .await?; + expect_error(&unknown, 401, "token_invalid")?; + let issued = identity(&ctx.target, &token, "errors").await?; + let key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": key.csr, "metadata": {} }))?; + let mut wrong_tag = issued.key.sign_now(&issued.thumbprint, "connect", None); + attach_content_digest(&mut wrong_tag, &body); + expect_error( + &ctx.target.signed_renew(&body, &wrong_tag, None).await?, + 401, + "signature_invalid", + )?; + for (method, path, body, status) in [ + ( + Method::POST, + "/api/agent-identity/v1/enroll", + Some(b"{".as_slice()), + 400, + ), + (Method::GET, "/api/agent-identity/v1/enroll", None, 405), + (Method::GET, "/api/agent-identity/v1/unknown", None, 404), + ] { + expect_error( + &ctx.target.send(method, path, body, Some(&token.text), None).await?, + status, + "invalid_request", + )?; + } + if ctx.mock() { + let oversized = vec![b' '; 64 * 1024 + 1]; + expect_error( + &ctx.target + .send( + Method::POST, + "/api/agent-identity/v1/enroll", + Some(&oversized), + Some(&token.text), + None, + ) + .await?, + 413, + "invalid_request", + )?; + let signed = issued.key.sign_now(&issued.thumbprint, "renew", Some(&oversized)); + expect_error( + &ctx.target.signed_renew(&oversized, &signed, None).await?, + 413, + "invalid_request", + )?; + } + Ok(()) +} + +pub(crate) async fn p_enroll_malformed_authorization_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §token-rules + let token = token(&ctx, 1).await?; + let key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": key.csr, "metadata": {} }))?; + let bearer = format!("Bearer {}", token.text); + for (variant, values) in [ + ("missing", Vec::new()), + ("repeated", vec![bearer.clone(), bearer.clone()]), + ("Basic scheme", vec![format!("Basic {}", token.text)]), + ("token without a scheme", vec![token.text.clone()]), + ] { + let mut headers = http::HeaderMap::new(); + for value in values { + headers.append(http::header::AUTHORIZATION, value.parse()?); + } + expect_error( + &post_with_headers(&ctx.target, "enroll", headers, body.clone()).await?, + 401, + "token_invalid", + ) + .with_context(|| format!("{variant} Authorization header"))?; + } + ensure!( + ctx.target.management().token(&token.id).await?.used_count == 0 + && ctx.target.management().identity_count(&token).await? == 0, + "malformed Authorization header consumed a token use or created an identity" + ); + expect_status(&ctx.target.enroll(&token.text, &key, &json!({})).await?, 200) + .context("well-formed Authorization header was not accepted after the malformed ones") +} + +pub(crate) async fn p_authentication_error_precedes_invalid_body(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + let token = token(&ctx, 2).await?; + let unknown_token = format!( + "{}.{}", + token.text.rsplit_once('.').context("token structure")?.0, + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x5a; 32]) + ); + let invalid_enroll = serde_json::to_vec(&json!({ "csr": "not base64!", "metadata": [] }))?; + for (variant, body, bearer) in [ + ( + "invalid fields with an unknown token", + invalid_enroll.as_slice(), + Some(unknown_token.as_str()), + ), + ("invalid fields without Authorization", invalid_enroll.as_slice(), None), + ( + "malformed JSON with an unknown token", + b"{".as_slice(), + Some(unknown_token.as_str()), + ), + ] { + expect_error( + &ctx.target + .send(Method::POST, "/api/agent-identity/v1/enroll", Some(body), bearer, None) + .await?, + 401, + "token_invalid", + ) + .with_context(|| format!("enroll body with {variant}"))?; + } + ensure!( + ctx.target.management().token(&token.id).await?.used_count == 0, + "rejected enrollments consumed a token use" + ); + + let revoked = identity(&ctx.target, &token, "auth-precedence-revoked").await?; + let identity = identity(&ctx.target, &token, "auth-precedence").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let invalid_renew = serde_json::to_vec(&json!({ "csr": "not base64!", "metadata": { "Bad Key": 1 } }))?; + let impostor = KeyPair::generate()?; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let unknown_keyid = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x33; 32]); + for (variant, headers, status, code) in [ + ( + "a signature by another key", + impostor.sign_now(&identity.thumbprint, "renew", Some(&invalid_renew)), + 401, + "signature_invalid", + ), + ( + "an unregistered keyid", + impostor.sign_now(&unknown_keyid, "renew", Some(&invalid_renew)), + 401, + "identity_unknown", + ), + ( + "an expired signature window", + identity.key.sign( + &identity.thumbprint, + "renew", + Some(&invalid_renew), + now - 600, + now - 540, + &fresh_nonce(), + ), + 401, + "clock_skew", + ), + ] { + expect_error( + &ctx.target.signed_renew(&invalid_renew, &headers, None).await?, + status, + code, + ) + .with_context(|| format!("invalid renew body with {variant}"))?; + } + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "rejected renewals changed identity state" + ); + + ctx.target.management().revoke(&revoked.identity_id).await?; + let invalid_check_in = serde_json::to_vec(&json!({ "metadata": "not an object" }))?; + let headers = revoked + .key + .sign_now(&revoked.thumbprint, "check-in", Some(&invalid_check_in)); + expect_error( + &ctx.target.signed_check_in(&invalid_check_in, &headers).await?, + 403, + "identity_revoked", + ) + .context("invalid check-in body from a revoked identity") +} + +pub(crate) async fn p_oversized_body_precedes_authentication(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §errors + let (_, identity) = issued(&ctx, "oversized-precedence").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let oversized = vec![b' '; 64 * 1024 + 1]; + expect_error( + &ctx.target + .send( + Method::POST, + "/api/agent-identity/v1/enroll", + Some(&oversized), + None, + None, + ) + .await?, + 413, + "invalid_request", + ) + .context("oversized enroll without Authorization")?; + let impostor = KeyPair::generate()?; + expect_error( + &ctx.target + .signed_renew( + &oversized, + &impostor.sign_now(&identity.thumbprint, "renew", Some(&oversized)), + None, + ) + .await?, + 413, + "invalid_request", + ) + .context("oversized renew signed by another key")?; + let unknown_keyid = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x44; 32]); + expect_error( + &ctx.target + .signed_check_in( + &oversized, + &impostor.sign_now(&unknown_keyid, "check-in", Some(&oversized)), + ) + .await?, + 413, + "invalid_request", + ) + .context("oversized check-in with an unregistered keyid")?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "oversized requests changed identity state" + ); + Ok(()) +} + +async fn post_with_headers( + target: &Target, + endpoint: &str, + headers: http::HeaderMap, + body: Vec, +) -> anyhow::Result { + let response = target + .http + .post(format!("{}/api/agent-identity/v1/{endpoint}", target.base_url)) + .headers(headers) + .header("content-type", "application/json") + .body(body) + .send() + .await + .context("send HTTP request")?; + Ok(Reply { + status: response.status().as_u16(), + body: response.json().await.context("decode JSON response")?, + }) +} + +pub(crate) async fn p_mock_fault_response_not_processed(ctx: Context) -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control + let token = token(&ctx, 1).await?; + let key = KeyPair::generate()?; + ctx.target + .faults(&json!({ "fail_next_response": { "endpoint": "enroll", "status": 503 } })) + .await?; + let failed = ctx.target.enroll(&token.text, &key, &json!({})).await?; + expect_status(&failed, 503)?; + ensure!(failed.body.is_null(), "fault without an error code returned a body"); + ensure!( + count(&ctx.target.requests(Some(&token)).await?, "enroll")? == 1 + && ctx.target.management().token(&token.id).await?.used_count == 0 + && ctx.target.management().identity_count(&token).await? == 0, + "failed enrollment was processed or not counted" + ); + ctx.target + .faults(&json!({ "fail_next_response": { "endpoint": "enroll", "status": 400 } })) + .await?; + let empty_bad_request = ctx.target.enroll(&token.text, &key, &json!({})).await?; + expect_status(&empty_bad_request, 400)?; + ensure!( + empty_bad_request.body.is_null(), + "injected empty 400 gained a framework error body" + ); + ensure!( + ctx.target.management().token(&token.id).await?.used_count == 0, + "injected empty 400 processed an enrollment" + ); + let enrolled = ctx.target.enroll(&token.text, &key, &json!({})).await?; + let identity = Identity::from_enrollment(key, &enrolled)?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let next_key = KeyPair::generate()?; + ctx.target + .faults(&json!({ + "fail_next_response": { "endpoint": "renew", "status": 400, "error": "token_invalid" } + })) + .await?; + expect_error( + &ctx.target + .renew(&identity, &next_key, &json!({ "hostname": "unprocessed" })) + .await?, + 400, + "token_invalid", + )?; + ctx.target + .faults(&json!({ + "fail_next_response": { "endpoint": "renew", "status": 400, "error": "invalid_request" } + })) + .await?; + expect_error( + &ctx.target + .renew(&identity, &next_key, &json!({ "hostname": "unprocessed" })) + .await?, + 400, + "invalid_request", + )?; + ensure!( + identity_record(&ctx.target, &identity.identity_id).await? == before, + "injected renewal error processed a certificate or metadata" + ); + expect_status(&ctx.target.renew(&identity, &next_key, &json!({})).await?, 200)?; + ensure!( + ctx.target.control("faults", &json!({})).await?.body["fail_next_response"].is_null(), + "one-shot fault was not consumed" + ); + Ok(()) +} + +/// Sends `body` to `enroll` exactly as given. +async fn enroll_raw(target: &Target, token: &Token, body: &[u8]) -> anyhow::Result { + target + .send( + Method::POST, + "/api/agent-identity/v1/enroll", + Some(body), + Some(&token.text), + None, + ) + .await +} + +/// Signs `body` with `identity`'s current key and sends it to `endpoint` (`renew` or `check-in`) exactly as given. +async fn signed_raw(target: &Target, identity: &Identity, endpoint: &str, body: &[u8]) -> anyhow::Result { + let headers = identity.key.sign_now(&identity.thumbprint, endpoint, Some(body)); + match endpoint { + "renew" => target.signed_renew(body, &headers, None).await, + "check-in" => target.signed_check_in(body, &headers).await, + _ => anyhow::bail!("{endpoint} takes no body"), + } +} + +/// CONTRACT.agent-identity.md §1: "A JSON request body that repeats a member name at any depth, such as `csr` twice or one metadata key twice, → `invalid_request`". +/// +/// Every repeated member holds a valid value, so a parser that keeps either occurrence would accept the request. +pub(crate) async fn p_duplicate_json_members_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + let token = token(&ctx, 2).await?; + let (first, second) = (KeyPair::generate()?, KeyPair::generate()?); + let (a, b) = (&first.csr, &second.csr); + for (variant, body) in [ + ("csr", format!(r#"{{"csr":"{a}","csr":"{b}","metadata":{{}}}}"#)), + ( + "metadata", + format!(r#"{{"csr":"{a}","metadata":{{}},"metadata":{{}}}}"#), + ), + ( + "metadata key", + format!(r#"{{"csr":"{a}","metadata":{{"hostname":"one","hostname":"two"}}}}"#), + ), + ( + "unknown member", + format!(r#"{{"csr":"{a}","metadata":{{}},"extra":1,"extra":2}}"#), + ), + ( + "nested unknown member", + format!(r#"{{"csr":"{a}","metadata":{{}},"extra":{{"inner":1,"inner":2}}}}"#), + ), + ] { + expect_error( + &enroll_raw(&ctx.target, &token, body.as_bytes()).await?, + 400, + "invalid_request", + ) + .with_context(|| format!("enroll body repeating {variant}"))?; + } + let management = ctx.target.management(); + ensure!( + management.token(&token.id).await?.used_count == 0 && management.identity_count(&token).await? == 0, + "an enrollment body with a repeated member created an identity" + ); + let identity = identity(&ctx.target, &token, "duplicates").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + for (endpoint, variant, body) in [ + ( + "renew", + "csr", + format!(r#"{{"csr":"{a}","csr":"{b}","metadata":{{}}}}"#), + ), + ( + "renew", + "metadata key", + format!(r#"{{"csr":"{a}","metadata":{{"hostname":"one","hostname":"two"}}}}"#), + ), + ("check-in", "metadata", r#"{"metadata":{},"metadata":{}}"#.to_owned()), + ( + "check-in", + "metadata key", + r#"{"metadata":{"hostname":"one","hostname":"two"}}"#.to_owned(), + ), + ] { + expect_error( + &signed_raw(&ctx.target, &identity, endpoint, body.as_bytes()).await?, + 400, + "invalid_request", + ) + .with_context(|| format!("{endpoint} body repeating {variant}"))?; + } + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &before).await?; + expect_status(&ctx.target.enroll(&token.text, &first, &json!({})).await?, 200) + .context("the repeated members' values were not themselves valid") +} + +/// A body for `csr` whose unknown member `extra` nests arrays and objects until the body holds `depth` of them, counting the top-level object. +/// +/// The nesting alternates arrays and objects, so a limit that counts only one kind accepts the deeper body. +fn nested_body(csr: &str, depth: usize) -> String { + let mut extra = "0".to_owned(); + for level in 1..depth { + extra = if level % 2 == 0 { + format!(r#"{{"n":{extra}}}"#) + } else { + format!("[{extra}]") + }; + } + format!(r#"{{"csr":"{csr}","metadata":{{}},"extra":{extra}}}"#) +} + +/// CONTRACT.agent-identity.md §1: "A JSON request body with more than 64 nested objects and arrays, counting the top-level object, [...] → `invalid_request`". +pub(crate) async fn p_json_body_nesting_limit(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + let token = token(&ctx, 2).await?; + let (enrolled, renewed) = (KeyPair::generate()?, KeyPair::generate()?); + expect_error( + &enroll_raw(&ctx.target, &token, nested_body(&enrolled.csr, 65).as_bytes()).await?, + 400, + "invalid_request", + ) + .context("enroll body nested 65 deep")?; + let management = ctx.target.management(); + ensure!( + management.token(&token.id).await?.used_count == 0 && management.identity_count(&token).await? == 0, + "an enrollment body nested 65 deep created an identity" + ); + let identity = identity(&ctx.target, &token, "nesting").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + expect_error( + &signed_raw( + &ctx.target, + &identity, + "renew", + nested_body(&renewed.csr, 65).as_bytes(), + ) + .await?, + 400, + "invalid_request", + ) + .context("renew body nested 65 deep")?; + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &before) + .await + .context("a renew body nested 65 deep changed the identity")?; + expect_status( + &enroll_raw(&ctx.target, &token, nested_body(&enrolled.csr, 64).as_bytes()).await?, + 200, + ) + .context("enroll body nested 64 deep")?; + expect_status( + &signed_raw( + &ctx.target, + &identity, + "renew", + nested_body(&renewed.csr, 64).as_bytes(), + ) + .await?, + 200, + ) + .context("renew body nested 64 deep") +} + +/// Strings that are not valid Unicode, as JSON escapes and as raw bytes that are not UTF-8. +const INVALID_UNICODE: [(&str, &[u8]); 6] = [ + ("escaped lone high surrogate", br"\ud800"), + ("escaped lone low surrogate", br"\udc00"), + ("escaped reversed surrogate pair", br"\udc00\ud800"), + // oracle: spec RFC 3629 §1 + ("raw byte 0xFF", b"\xff"), + // oracle: spec RFC 3629 §3 + ("raw UTF-8 form of a surrogate", b"\xed\xa0\x80"), + // oracle: spec RFC 3629 §3 + ("raw overlong encoding", b"\xc0\xaf"), +]; + +/// Bodies for `csr` holding `content` inside a string, in the unknown member `extra` and in the `hostname` metadata value, which takes any valid string. +fn bodies_with_string(csr: &str, content: &[u8]) -> [(&'static str, Vec); 2] { + let unknown = format!(r#"{{"csr":"{csr}","metadata":{{}},"extra":""#); + let hostname = format!(r#"{{"csr":"{csr}","metadata":{{"hostname":"host"#); + [ + ( + "unknown member", + [unknown.as_bytes(), content, br#""}"#.as_slice()].concat(), + ), + ( + "hostname metadata value", + [hostname.as_bytes(), content, br#""}}"#.as_slice()].concat(), + ), + ] +} + +/// CONTRACT.agent-identity.md §1: "A JSON request body [...] holding a string that is not valid Unicode, such as an escaped lone surrogate, in any member, including an unknown one → `invalid_request`". +pub(crate) async fn p_invalid_unicode_json_strings_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + let token = token(&ctx, 2).await?; + let (enrolled, renewed) = (KeyPair::generate()?, KeyPair::generate()?); + for (variant, content) in INVALID_UNICODE { + for (member, body) in bodies_with_string(&enrolled.csr, content) { + expect_error(&enroll_raw(&ctx.target, &token, &body).await?, 400, "invalid_request") + .with_context(|| format!("enroll body with {variant} in the {member}"))?; + } + } + let management = ctx.target.management(); + ensure!( + management.token(&token.id).await?.used_count == 0 && management.identity_count(&token).await? == 0, + "an enrollment body holding a string that is not valid Unicode created an identity" + ); + let identity = identity(&ctx.target, &token, "unicode").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + for (variant, content) in INVALID_UNICODE { + for (member, body) in bodies_with_string(&renewed.csr, content) { + expect_error( + &signed_raw(&ctx.target, &identity, "renew", &body).await?, + 400, + "invalid_request", + ) + .with_context(|| format!("renew body with {variant} in the {member}"))?; + } + } + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &before) + .await + .context("a renew body holding a string that is not valid Unicode changed the identity")?; + // oracle: spec RFC 8259 §7 + // RFC 8259's example escapes U+1D11E as this surrogate pair, so the same members holding a valid escape are accepted and decoded. + let pair = br"\uD834\uDD1E"; + let [_, (_, body)] = bodies_with_string(&enrolled.csr, pair); + let control = Identity::from_enrollment(enrolled, &enroll_raw(&ctx.target, &token, &body).await?) + .context("enroll body with an escaped surrogate pair in the hostname metadata value")?; + let [_, (_, body)] = bodies_with_string(&renewed.csr, pair); + expect_status(&signed_raw(&ctx.target, &identity, "renew", &body).await?, 200) + .context("renew body with an escaped surrogate pair in the hostname metadata value")?; + for id in [&control.identity_id, &identity.identity_id] { + ensure!( + identity_record(&ctx.target, id).await?.metadata_value("hostname") == Some("host\u{1D11E}"), + "an escaped surrogate pair in the hostname was not stored as U+1D11E" + ); + } + Ok(()) +} + +/// A CSR whose canonical `base64` encoding has padding and uses both `+` or `/` characters, so that every non-canonical rewrite applies. +fn key_with_rewritable_csr() -> anyhow::Result { + for _ in 0..64 { + let key = KeyPair::generate()?; + if key.csr.ends_with('=') && key.csr.contains(['+', '/']) { + return Ok(key); + } + } + anyhow::bail!("no generated CSR had a padded encoding with '+' or '/'") +} + +/// Other encodings of the bytes `canonical` encodes, each of which a lenient decoder accepts. +fn noncanonical_base64(canonical: &str) -> anyhow::Result> { + let wrapped = format!("csr:{canonical}:"); + let unwrap = |value: String| -> anyhow::Result { Ok(split_byte_sequence(&value)?.1.to_owned()) }; + let (head, tail) = canonical.split_at(canonical.len() / 2); + Ok(vec![ + ("non-zero padding bits", unwrap(with_nonzero_pad_bits(&wrapped)?)?), + ("missing padding", unwrap(without_padding(&wrapped)?)?), + ("line break", format!("{head}\n{tail}")), + ("URL-safe alphabet", canonical.replace('+', "-").replace('/', "_")), + ]) +} + +/// CONTRACT.agent-identity.md §1: "Senders use the canonical form of every `base64` and `base64url` value, so that each byte string has one encoding; a request body holding another form → `invalid_request`." +pub(crate) async fn p_noncanonical_base64_csr_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + let (token, identity) = issued(&ctx, "noncanonical-csr").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let key = key_with_rewritable_csr()?; + for (variant, csr) in noncanonical_base64(&key.csr)? { + let body = serde_json::to_vec(&json!({ "csr": csr, "metadata": {} }))?; + expect_error(&enroll_raw(&ctx.target, &token, &body).await?, 400, "invalid_request") + .with_context(|| format!("enroll CSR with {variant}"))?; + expect_error( + &signed_raw(&ctx.target, &identity, "renew", &body).await?, + 400, + "invalid_request", + ) + .with_context(|| format!("renew CSR with {variant}"))?; + } + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &before).await?; + ensure!( + ctx.target.management().token(&token.id).await?.used_count == 1, + "a non-canonical CSR consumed a token use" + ); + expect_status(&ctx.target.enroll(&token.text, &key, &json!({})).await?, 200) + .context("the canonical CSR of the same key was not accepted") +} + +/// CONTRACT.agent-identity.md §2: "The authority authenticates a token by its exact `secret` text alone and never checks the `bag`, which carries only routing data." +/// +/// The rewritten secrets decode to the same 32 bytes, so only a comparison of the exact text refuses them. +pub(crate) async fn p_token_secret_must_match_exactly(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + const URL_SAFE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let token = token(&ctx, 2).await?; + let (prefix_and_bag, secret) = token.text.rsplit_once('.').context("token secret")?; + let (prefix, bag) = prefix_and_bag.split_once('.').context("token bag")?; + // 32 bytes leave two unused low bits in the last of the 43 secret characters. + let (secret_head, last) = secret.split_at(secret.len() - 1); + let index = URL_SAFE + .iter() + .position(|symbol| last.as_bytes() == [*symbol]) + .context("token secret is not base64url")?; + ensure!(index & 3 == 0, "token secret is not canonical base64url"); + let noisy_secret = format!("{secret_head}{}", char::from(URL_SAFE[index | 1])); + for (variant, text) in [ + ( + "non-zero unused secret bits", + format!("{prefix_and_bag}.{noisy_secret}"), + ), + ("padded secret", format!("{prefix_and_bag}.{secret}=")), + ] { + expect_error( + &ctx.target.enroll(&text, &KeyPair::generate()?, &json!({})).await?, + 401, + "token_invalid", + ) + .with_context(|| format!("token with {variant}"))?; + } + let management = ctx.target.management(); + ensure!( + management.token(&token.id).await?.used_count == 0 && management.identity_count(&token).await? == 0, + "a token whose secret text differs from the issued one created an identity" + ); + let route: Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(bag) + .context("decode token bag")?, + )?; + let reencoded_bag = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec_pretty(&route)?); + ensure!(reencoded_bag != bag, "re-encoding the token bag did not change it"); + expect_status( + &ctx.target + .enroll( + &format!("{prefix}.{reencoded_bag}.{secret}"), + &KeyPair::generate()?, + &json!({}), + ) + .await?, + 200, + ) + .context("a token with another bag and the exact secret text was not accepted")?; + expect_status( + &ctx.target + .enroll(&token.text, &KeyPair::generate()?, &json!({})) + .await?, + 200, + ) + .context("the issued token string was not accepted")?; + ensure!( + management.token(&token.id).await?.used_count == 2, + "the exact secret text did not authenticate the token's uses" + ); + Ok(()) +} + +/// A CSR for `key` whose public key is the compressed form of its point, validly self-signed. +fn compressed_point_csr(key: &KeyPair) -> anyhow::Result { + let mut request = x509_cert::request::CertReq::from_der(&decoded_certificate(&key.csr)?)?; + let point = request.info.public_key.subject_public_key.raw_bytes(); + ensure!( + point.len() == 65 && point[0] == 4, + "generated CSR does not hold an uncompressed P-256 point" + ); + let mut compressed = vec![2 | (point[64] & 1)]; + compressed.extend_from_slice(&point[1..33]); + request.info.public_key.subject_public_key = der::asn1::BitString::from_bytes(&compressed)?; + let signature: p256::ecdsa::DerSignature = key.key.try_sign(&request.info.to_der()?)?; + request.signature = der::asn1::BitString::from_bytes(signature.as_bytes())?; + Ok(base64::engine::general_purpose::STANDARD.encode(request.to_der()?)) +} + +/// CONTRACT.agent-identity.md §4: the server "only takes the public key (an uncompressed P-256 point required, else `invalid_request`, so that each key has exactly one SPKI SHA-256)". +pub(crate) async fn p_compressed_point_csr_rejected(ctx: Context) -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + let (token, identity) = issued(&ctx, "compressed-point").await?; + let before = identity_record(&ctx.target, &identity.identity_id).await?; + let key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ "csr": compressed_point_csr(&key)?, "metadata": {} }))?; + expect_error(&enroll_raw(&ctx.target, &token, &body).await?, 400, "invalid_request") + .context("enroll CSR with a compressed point")?; + expect_error( + &signed_raw(&ctx.target, &identity, "renew", &body).await?, + 400, + "invalid_request", + ) + .context("renew CSR with a compressed point")?; + assert_identity_metadata_and_certificates_unchanged(&ctx.target, &identity.identity_id, &before).await?; + expect_status(&ctx.target.enroll(&token.text, &key, &json!({})).await?, 200) + .context("the uncompressed CSR of the same key was not accepted") +} + +/// Shared intent, "Enrollment": "The result is trimmed, truncated to 255 Unicode scalar values without splitting one, and trimmed again, so a stored name never starts or ends with white space (Unicode `White_Space`)". +pub(crate) async fn p_friendly_name_white_space_and_truncation(ctx: Context) -> anyhow::Result<()> { + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Enrollment + let cases = [ + ("\u{3000}\u{a0}name\u{2003}\u{1680}", "name".to_owned()), + // U+200B ZERO WIDTH SPACE is not `White_Space`. + ("\u{200b}name\u{200b}", "\u{200b}name\u{200b}".to_owned()), + (&*format!("{}{}", " ".repeat(10), "x".repeat(300)), "x".repeat(255)), + ( + &*format!("{}\u{3000}{}", "a".repeat(254), "b".repeat(10)), + "a".repeat(254), + ), + ( + &*format!("{}{}", "a".repeat(200), "\u{1f600}".repeat(60)), + format!("{}{}", "a".repeat(200), "\u{1f600}".repeat(55)), + ), + ]; + let token = ctx + .target + .management() + .create_token(cases.len() as u64, TOKEN_LIFETIME, Some("{hostname}")) + .await?; + for (hostname, expected) in cases { + let enrolled = identity(&ctx.target, &token, hostname).await?; + let name = identity_record(&ctx.target, &enrolled.identity_id).await?.friendly_name; + ensure!( + name == expected, + "hostname {hostname:?} produced friendly name {name:?} instead of {expected:?}" + ); + } + Ok(()) +} + +/// DVLS's intent, "Terminology": an identity is `active` while it "holds an unexpired current or pending certificate", in `grace` while it "holds no unexpired certificate but one within renewal grace", `expired` "beyond renewal grace", or `revoked`. +/// +/// A certificate is unexpired while `now <= notAfter` (CONTRACT.agent-identity.md §6), and renewal grace lasts while `now < notAfter + (notAfter − notBefore)`. +pub(crate) async fn p_identity_statuses(ctx: Context) -> anyhow::Result<()> { + // oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Terminology + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + let management = ctx.target.management(); + ctx.target.faults(&json!({ "leaf_lifetime_secs": 60 })).await?; + let (_, pending_only) = issued(&ctx, "pending-only").await?; + ctx.target.faults(&json!({ "leaf_lifetime_secs": 600 })).await?; + expect_status( + &ctx.target + .renew(&pending_only, &KeyPair::generate()?, &json!({})) + .await?, + 200, + )?; + ctx.target.faults(&json!({ "leaf_lifetime_secs": 60 })).await?; + let (token, current_only) = issued(&ctx, "current-only").await?; + let pending_only_expiry = identity_record(&ctx.target, &pending_only.identity_id) + .await? + .not_after() + .context("pending-only identity has no current certificate")? + .unix_timestamp(); + let current = identity_record(&ctx.target, ¤t_only.identity_id) + .await? + .certificate_with(CertificateStatus::Current) + .context("identity has no current certificate")? + .clone(); + let not_after = current.not_after.unix_timestamp(); + let lifetime = not_after - current.not_before.unix_timestamp(); + ensure!( + pending_only_expiry <= not_after && lifetime > 1, + "status fixture certificates have unexpected validity" + ); + for (now, status) in [ + (not_after, IdentityStatus::Active), + (not_after + 1, IdentityStatus::Grace), + (not_after + lifetime - 1, IdentityStatus::Grace), + (not_after + lifetime, IdentityStatus::Expired), + ] { + expect_status(&ctx.target.control("time/freeze", &json!({ "now": now })).await?, 200)?; + let record = identity_record(&ctx.target, ¤t_only.identity_id).await?; + let listed = management + .identities(&IdentityQuery::enrolled_with(&token).status(status)) + .await?; + ensure!( + record.status == status && listed.items.iter().any(|row| row.id == current_only.identity_id), + "{}s after notAfter, the identity is {:?} instead of {status:?}", + now - not_after, + record.status + ); + if now == not_after + 1 { + ensure!( + identity_record(&ctx.target, &pending_only.identity_id).await?.status == IdentityStatus::Active, + "an identity with an expired current certificate and an unexpired pending one is not active" + ); + } + } + management.revoke(¤t_only.identity_id).await?; + ensure!( + identity_record(&ctx.target, ¤t_only.identity_id).await?.status == IdentityStatus::Revoked + && management + .identities(&IdentityQuery::enrolled_with(&token).status(IdentityStatus::Expired)) + .await? + .items + .iter() + .all(|row| row.id != current_only.identity_id), + "a revoked identity is not reported as revoked only" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use base64::engine::general_purpose::STANDARD; + use prost::Message as _; + + use super::*; + + #[test] + fn only_the_defective_frames_fail_a_reference_decoder() -> anyhow::Result<()> { + // oracle: reference prost + let message = agent_channel_proto::AgentMessage { + id: String::new(), + correlation_id: Some("challenge".to_owned()), + payload: Some(agent_channel_proto::agent_message::Payload::Ack( + agent_channel_proto::Ack {}, + )), + }; + let valid = |id: &[u8], len| channel::agent_message_frame(id, &message, len); + let frame = valid(b"id", channel::MAX_ENVELOPE_LEN)?; + ensure!( + frame.len() == channel::MAX_ENVELOPE_LEN + && agent_channel_proto::AgentMessage::decode(frame.as_slice())? + == agent_channel_proto::AgentMessage { + id: "id".to_owned(), + ..message.clone() + }, + "the padded frame is not its message at the envelope limit" + ); + for undecodable in [Undecodable::InvalidUtf8, Undecodable::Truncated] { + ensure!( + agent_channel_proto::AgentMessage::decode(undecodable.frame(valid)?.as_slice()).is_err(), + "the {undecodable:?} frame decodes" + ); + } + let oversized = Undecodable::Oversized.frame(valid)?; + ensure!( + oversized.len() == channel::MAX_ENVELOPE_LEN + 1 + && agent_channel_proto::AgentMessage::decode(oversized.as_slice()).is_ok(), + "the oversized frame is not a valid message one byte over the envelope limit" + ); + Ok(()) + } + + #[test] + fn byte_sequence_rewrites_keep_lenient_bytes_but_lose_canonical_form() -> anyhow::Result<()> { + // oracle: spec RFC 4648 §3.2 + // oracle: spec RFC 4648 §3.5 + use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; + + let lenient = GeneralPurpose::new( + &base64::alphabet::STANDARD, + GeneralPurposeConfig::new() + .with_decode_allow_trailing_bits(true) + .with_decode_padding_mode(DecodePaddingMode::Indifferent), + ); + // A 64-byte signature ends with two padding characters and a 32-byte digest with one. + for bytes in [vec![0x5a; 64], vec![0xa5; 32]] { + let value = format!("sig=:{}:", STANDARD.encode(&bytes)); + for rewritten in [with_nonzero_pad_bits(&value)?, without_padding(&value)?] { + let (prefix, encoded) = split_byte_sequence(&rewritten)?; + ensure!( + prefix == "sig=" && lenient.decode(encoded)? == bytes && STANDARD.decode(encoded).is_err(), + "{rewritten} does not decode leniently to the original bytes while failing strict decoding" + ); + } + } + Ok(()) + } + + #[test] + fn disposable_rotation_requires_an_isolated_old_root() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + // oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control + let active = HashSet::from(["own".to_owned(), "other".to_owned()]); + let error = require_isolated_old_root_fixture(&active, &["own"], false) + .expect_err("occupied disposable DVLS fixture must be incomplete"); + ensure!( + error.downcast_ref::().is_some(), + "occupied disposable DVLS fixture did not report SKIP" + ); + ensure!( + require_isolated_old_root_fixture(&active, &["own"], true).is_err(), + "mock reset left an unexpected active old-root identity" + ); + require_isolated_old_root_fixture(&HashSet::from(["own".to_owned()]), &["own"], false)?; + require_isolated_old_root_fixture(&HashSet::new(), &[], false)?; + Ok(()) + } + + #[test] + fn trust_anchor_snapshot_ignores_unknown_fields() -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor + let roots = json!({ + "roots": [{ + "certificate": "root", + "thumbprint": "root-id", + "not_before": "start", + "not_after": "end" + }] + }); + let mut noisy_roots = roots.clone(); + noisy_roots["request_id"] = json!("one"); + noisy_roots["roots"][0]["request_id"] = json!("two"); + ensure!( + trust_anchor_snapshot(&roots)? == trust_anchor_snapshot(&noisy_roots)?, + "unknown trust-anchor fields changed the published-root snapshot" + ); + noisy_roots["roots"][0]["thumbprint"] = json!("other-root"); + ensure!( + trust_anchor_snapshot(&roots)? != trust_anchor_snapshot(&noisy_roots)?, + "changed root identity was ignored" + ); + noisy_roots["roots"][0]["thumbprint"] = Value::Null; + ensure!( + trust_anchor_snapshot(&noisy_roots).is_err(), + "explicit null root thumbprint was accepted" + ); + Ok(()) + } + + #[test] + fn chain_requires_published_root_validity_and_signed_p256_links() -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §trust-anchor + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + // oracle: spec RFC 5280 §6.1 + let vectors: Value = serde_json::from_str(include_str!("../../../docs/agent-identity/test-vectors.json"))?; + let leaf = field(&vectors["keys"][0], "certificate")?.to_owned(); + let root = field(&vectors["root"], "certificate")?.to_owned(); + let chain = vec![leaf.clone(), root.clone()]; + let anchors = json!({ "roots": [{ "certificate": root }] }); + verify_chain(&chain, &anchors, 1_790_000_000)?; + ensure!( + verify_chain(&chain, &anchors, 1_790_000_000 + 10_000_000).is_err(), + "expired leaf was accepted" + ); + ensure!( + verify_chain(&chain, &json!({ "roots": [] }), 1_790_000_000).is_err(), + "unpublished root was accepted" + ); + ensure!( + verify_chain(std::slice::from_ref(&root), &anchors, 1_790_000_000).is_err(), + "root-only chain was accepted" + ); + let mut tampered_leaf = decoded_certificate(&leaf)?; + *tampered_leaf.last_mut().context("missing certificate signature")? ^= 1; + let tampered = vec![STANDARD.encode(tampered_leaf), root]; + ensure!( + verify_chain(&tampered, &anchors, 1_790_000_000).is_err(), + "invalid P-256 certificate signature was accepted" + ); + Ok(()) + } + + #[test] + fn chain_verifies_p384_intermediate_and_rejects_unknown_curve() -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: intent crates/agent-identity-conformance/INTENT.md §Shape + let ca = |name: &str| -> anyhow::Result { + let mut params = rcgen::CertificateParams::new(Vec::::new())?; + params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign]; + params.distinguished_name.push(rcgen::DnType::CommonName, name); + Ok(params) + }; + let root_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let root = ca("root")?.self_signed(&root_key)?; + let issuer_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P384_SHA384)?; + let issuer = ca("p384 issuer")?.signed_by(&issuer_key, &root, &root_key)?; + let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let leaf = + rcgen::CertificateParams::new(vec!["leaf.local".to_owned()])?.signed_by(&leaf_key, &issuer, &issuer_key)?; + let chain = vec![ + STANDARD.encode(leaf.der()), + STANDARD.encode(issuer.der()), + STANDARD.encode(root.der()), + ]; + let roots = json!({ "roots": [{ "certificate": chain[2] }] }); + verify_chain(&chain, &roots, time::OffsetDateTime::now_utc().unix_timestamp())?; + let mut bad_leaf = leaf.der().as_ref().to_vec(); + *bad_leaf.last_mut().context("leaf signature missing")? ^= 1; + let tampered = vec![STANDARD.encode(bad_leaf), chain[1].clone(), chain[2].clone()]; + ensure!( + verify_chain(&tampered, &roots, time::OffsetDateTime::now_utc().unix_timestamp()).is_err(), + "invalid P-384-signed certificate was accepted" + ); + let mut unknown_curve = issuer.der().as_ref().to_vec(); + let p384_oid = [0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22]; + let offset = unknown_curve + .windows(p384_oid.len()) + .position(|window| window == p384_oid) + .context("issuer does not use secp384r1")?; + unknown_curve[offset + p384_oid.len() - 1] = 0x23; + let unsupported = vec![chain[0].clone(), STANDARD.encode(unknown_curve), chain[2].clone()]; + ensure!( + verify_chain(&unsupported, &roots, time::OffsetDateTime::now_utc().unix_timestamp()) + .expect_err("unverifiable issuer curve was accepted") + .downcast_ref::() + .is_some(), + "an issuer algorithm the tester cannot verify was reported as a conformance failure" + ); + Ok(()) + } + + #[test] + fn chain_requires_ca_intermediates_but_no_root_profile() -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + // oracle: spec RFC 5280 §6.1.4 + let plain = |name: &str| -> anyhow::Result { + let mut params = rcgen::CertificateParams::new(Vec::::new())?; + params.distinguished_name.push(rcgen::DnType::CommonName, name); + Ok(params) + }; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let root_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let root = plain("root without extensions")?.self_signed(&root_key)?; + let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let leaf = plain("leaf")?.signed_by(&leaf_key, &root, &root_key)?; + let roots = json!({ "roots": [{ "certificate": STANDARD.encode(root.der()) }] }); + verify_chain(&[STANDARD.encode(leaf.der()), STANDARD.encode(root.der())], &roots, now) + .context("a root without CA extensions was rejected")?; + let intermediate_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let intermediate = plain("non-CA intermediate")?.signed_by(&intermediate_key, &root, &root_key)?; + let leaf = plain("leaf")?.signed_by(&leaf_key, &intermediate, &intermediate_key)?; + let chain = [ + STANDARD.encode(leaf.der()), + STANDARD.encode(intermediate.der()), + STANDARD.encode(root.der()), + ]; + ensure!( + verify_chain(&chain, &roots, now).is_err(), + "a non-CA intermediate was accepted" + ); + Ok(()) + } + + /// The conformance runs show the check passing a conforming leaf, but only a leaf with another extension shows it can fail. + #[test] + fn leaf_extensions_outside_the_profile_are_named() -> anyhow::Result<()> { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates + let issuer_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let mut issuer = rcgen::CertificateParams::new(Vec::::new())?; + issuer.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let issuer = issuer.self_signed(&issuer_key)?; + let leaf_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?; + let leaf = |is_ca: rcgen::IsCa| -> anyhow::Result { + let mut params = rcgen::CertificateParams::new(vec!["leaf.local".to_owned()])?; + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth]; + params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + params.use_authority_key_identifier_extension = true; + params.is_ca = is_ca; + Ok(Certificate::from_der( + params.signed_by(&leaf_key, &issuer, &issuer_key)?.der(), + )?) + }; + assert_leaf_extensions(&leaf(rcgen::IsCa::NoCa)?).context("SAN, EKU, KeyUsage and AKI were refused")?; + // `ExplicitNoCa` adds a subject key identifier and a basicConstraints without cA. + let error = assert_leaf_extensions(&leaf(rcgen::IsCa::ExplicitNoCa)?) + .expect_err("a leaf with basicConstraints was accepted") + .to_string(); + // oracle: spec RFC 5280 §4.2.1.9 + ensure!( + error.ends_with(": 2.5.29.19"), + "the failure does not name basicConstraints alone: {error}" + ); + Ok(()) + } +} diff --git a/crates/agent-identity-conformance/src/signer.rs b/crates/agent-identity-conformance/src/signer.rs new file mode 100644 index 000000000..dca4590a5 --- /dev/null +++ b/crates/agent-identity-conformance/src/signer.rs @@ -0,0 +1,283 @@ +use anyhow::Context as _; +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use der::Encode as _; +use p256::ecdsa::signature::Signer as _; +use p256::ecdsa::{DerSignature, Signature, SigningKey}; +use p256::elliptic_curve::Generate as _; +use rand::RngExt as _; +use sha2::{Digest as _, Sha256}; +use x509_cert::builder::{Builder as _, RequestBuilder}; +use x509_cert::name::Name; + +pub(crate) struct KeyPair { + pub(crate) key: SigningKey, + pub(crate) csr: String, +} + +#[derive(Clone, Copy)] +pub(crate) enum SignatureParameter { + Created, + Expires, + Nonce, + KeyId, + Algorithm, + Tag, +} + +const DEFAULT_PARAMETER_ORDER: [SignatureParameter; 6] = [ + SignatureParameter::Created, + SignatureParameter::Expires, + SignatureParameter::Nonce, + SignatureParameter::KeyId, + SignatureParameter::Algorithm, + SignatureParameter::Tag, +]; + +impl KeyPair { + pub(crate) fn generate() -> anyhow::Result { + let key = SigningKey::generate_from_rng(&mut rand::rng()); + let subject: Name = "CN=conformance request subject ignored" + .parse() + .context("parse CSR subject")?; + let csr = RequestBuilder::new(subject) + .context("build CSR")? + .build::<_, DerSignature>(&key) + .context("sign CSR")? + .to_der() + .context("encode CSR")?; + Ok(Self { + key, + csr: STANDARD.encode(csr), + }) + } + + pub(crate) fn sign( + &self, + keyid: &str, + tag: &str, + body: Option<&[u8]>, + created: i64, + expires: i64, + nonce: &str, + ) -> SignedHeaders { + self.sign_with_order(keyid, tag, body, (created, expires), nonce, &DEFAULT_PARAMETER_ORDER) + } + + pub(crate) fn sign_with_order( + &self, + keyid: &str, + tag: &str, + body: Option<&[u8]>, + time_bounds: (i64, i64), + nonce: &str, + order: &[SignatureParameter; 6], + ) -> SignedHeaders { + let (created, expires) = time_bounds; + let (components, digest) = match tag { + "renew" | "check-in" => { + let body = body.expect("renew and check-in signatures require a body"); + ( + "(\"@method\" \"content-digest\")", + Some(format!("sha-256=:{}:", STANDARD.encode(Sha256::digest(body)))), + ) + } + "connect" | "confirm" => { + assert!(body.is_none(), "{tag} signatures require an empty body"); + ("(\"@method\")", None) + } + _ => panic!("unsupported signature tag {tag}"), + }; + let parameters = order + .iter() + .map(|parameter| match parameter { + SignatureParameter::Created => format!("created={created}"), + SignatureParameter::Expires => format!("expires={expires}"), + SignatureParameter::Nonce => format!("nonce=\"{nonce}\""), + SignatureParameter::KeyId => format!("keyid=\"{keyid}\""), + SignatureParameter::Algorithm => "alg=\"ecdsa-p256-sha256\"".to_owned(), + SignatureParameter::Tag => format!("tag=\"{tag}\""), + }) + .collect::>() + .join(";"); + let input = format!("sig={components};{parameters}"); + let mut base = String::from("\"@method\": POST\n"); + if let Some(value) = &digest { + base.push_str(&format!("\"content-digest\": {value}\n")); + } + base.push_str("\"@signature-params\": "); + base.push_str(&input[4..]); + let signature: Signature = self.key.sign(base.as_bytes()); + SignedHeaders { + input, + signature: format!("sig=:{}:", STANDARD.encode(signature.to_bytes())), + digest, + nonce: nonce.to_owned(), + #[cfg(test)] + base, + } + } + + pub(crate) fn sign_now(&self, keyid: &str, tag: &str, body: Option<&[u8]>) -> SignedHeaders { + self.sign_now_with_order(keyid, tag, body, &DEFAULT_PARAMETER_ORDER) + } + + pub(crate) fn sign_now_with_order( + &self, + keyid: &str, + tag: &str, + body: Option<&[u8]>, + order: &[SignatureParameter; 6], + ) -> SignedHeaders { + let mut nonce_bytes = [0u8; 16]; + rand::rng().fill(&mut nonce_bytes[..]); + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + self.sign_with_order( + keyid, + tag, + body, + (now, now + 60), + &URL_SAFE_NO_PAD.encode(nonce_bytes), + order, + ) + } + + pub(crate) fn channel_proof(&self, challenge: &[u8], nonce: &str) -> Vec { + let mut message = b"devolutions-agent-identity/v1/channel-proof\0".to_vec(); + message.extend_from_slice(challenge); + message.extend_from_slice(nonce.as_bytes()); + let signature: Signature = self.key.sign(&message); + signature.to_bytes().to_vec() + } +} + +#[derive(Clone)] +pub(crate) struct SignedHeaders { + pub(crate) input: String, + pub(crate) signature: String, + pub(crate) digest: Option, + pub(crate) nonce: String, + #[cfg(test)] + pub(crate) base: String, +} + +pub(crate) fn thumbprint(cert_base64: &str) -> anyhow::Result { + let cert = STANDARD.decode(cert_base64).context("decode certificate")?; + Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(cert))) +} + +#[cfg(test)] +mod tests { + use p256::ecdsa::VerifyingKey; + use p256::ecdsa::signature::Verifier as _; + use p256::pkcs8::{DecodePrivateKey as _, DecodePublicKey as _}; + + use super::*; + + #[test] + fn signer_reproduces_vector_inputs_and_verifies_under_vector_keys() -> anyhow::Result<()> { + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Conformance + const ALGORITHM_BEFORE_KEY_ID: [SignatureParameter; 6] = [ + SignatureParameter::Created, + SignatureParameter::Expires, + SignatureParameter::Nonce, + SignatureParameter::Algorithm, + SignatureParameter::KeyId, + SignatureParameter::Tag, + ]; + let vectors: serde_json::Value = + serde_json::from_str(include_str!("../../../docs/agent-identity/test-vectors.json"))?; + let cases = vectors["http_signature"]["cases"].as_array().context("vector cases")?; + for (name, key_name) in [ + ("valid_renew", "device-a"), + ("valid_connect", "device-a"), + ("valid_confirm", "device-g"), + ("valid_check_in", "device-a"), + ("valid_renew_alg_before_keyid", "device-a"), + ("valid_connect_alg_before_keyid", "device-a"), + ("valid_confirm_alg_before_keyid", "device-g"), + ("valid_check_in_alg_before_keyid", "device-a"), + ] { + let case = cases + .iter() + .find(|case| case["name"] == name) + .context("missing signature vector")?; + let vector_key = vectors["keys"] + .as_array() + .context("vector keys")? + .iter() + .find(|key| key["name"] == key_name) + .context("missing vector key")?; + let key_der = STANDARD.decode(vector_key["private_key_pkcs8"].as_str().context("vector private key")?)?; + let pair = KeyPair { + key: SigningKey::from_pkcs8_der(&key_der)?, + csr: String::new(), + }; + let input = case["headers"]["signature-input"].as_str().context("signature-input")?; + let tag = case["endpoint"].as_str().context("endpoint")?; + let nonce = input + .split(";nonce=\"") + .nth(1) + .and_then(|value| value.split('"').next()) + .context("nonce")?; + let keyid = vector_key["thumbprint"].as_str().context("thumbprint")?; + let body = case["body"].as_str().map(str::as_bytes); + let order = if name.ends_with("_alg_before_keyid") { + &ALGORITHM_BEFORE_KEY_ID + } else { + &DEFAULT_PARAMETER_ORDER + }; + let signed = pair.sign_with_order(keyid, tag, body, (1_790_000_000, 1_790_000_060), nonce, order); + assert_eq!(signed.input, input, "{name} input"); + assert_eq!(signed.base, case["signature_base"], "{name} signature base"); + assert_eq!(signed.digest.as_deref(), case["headers"]["content-digest"].as_str()); + let raw = signed + .signature + .strip_prefix("sig=:") + .and_then(|value| value.strip_suffix(':')) + .context("signature header encoding")?; + vector_public_key(vector_key)? + .verify( + case["signature_base"].as_str().context("signature base")?.as_bytes(), + &Signature::from_slice(&STANDARD.decode(raw)?)?, + ) + .with_context(|| format!("{name} signature does not verify under the vector key"))?; + } + let key_der = STANDARD.decode(vectors["keys"][0]["private_key_pkcs8"].as_str().context("vector key")?)?; + let pair = KeyPair { + key: SigningKey::from_pkcs8_der(&key_der)?, + csr: String::new(), + }; + let proof = &vectors["channel_proof"]; + let challenge = STANDARD.decode(proof["challenge"].as_str().context("challenge")?)?; + let nonce = proof["connect_nonce"].as_str().context("connect nonce")?; + let message_hex = proof["message_hex"].as_str().context("message hex")?; + let message = (0..message_hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&message_hex[index..index + 2], 16)) + .collect::, _>>()?; + let proof_key = VerifyingKey::from_sec1_bytes( + &STANDARD.decode(proof["public_key_sec1"].as_str().context("channel proof public key")?)?, + )?; + proof_key + .verify( + &message, + &Signature::from_slice(&pair.channel_proof(&challenge, nonce))?, + ) + .context("channel proof does not verify over the vector message")?; + let rfc = &vectors["rfc9421_b_2_4"]; + let pub_der = STANDARD.decode(rfc["public_key_spki"].as_str().context("rfc public key")?)?; + let key = VerifyingKey::from_public_key_der(&pub_der)?; + let signature = Signature::from_slice(&STANDARD.decode(rfc["signature"].as_str().context("rfc signature")?)?)?; + key.verify( + rfc["signature_base"].as_str().context("rfc base")?.as_bytes(), + &signature, + )?; + Ok(()) + } + + fn vector_public_key(vector_key: &serde_json::Value) -> anyhow::Result { + let sec1 = STANDARD.decode(vector_key["public_key_sec1"].as_str().context("vector public key")?)?; + Ok(VerifyingKey::from_sec1_bytes(&sec1)?) + } +} diff --git a/crates/agent-identity-conformance/src/system_fixture.rs b/crates/agent-identity-conformance/src/system_fixture.rs new file mode 100644 index 000000000..a8ac01d8c --- /dev/null +++ b/crates/agent-identity-conformance/src/system_fixture.rs @@ -0,0 +1,805 @@ +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Output, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, ensure}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; + +use super::{AgentCase, POLL, WAIT, new_token, reset_mocks}; +use crate::Context; +use crate::client::{Target, Token}; +use crate::dvls::IdentityQuery; +use crate::system_probe::{Request, ResultFile}; + +const TASK_TIMEOUT: Duration = Duration::from_secs(10); +const PROBE_TIMEOUT: Duration = Duration::from_secs(20); +const INSPECTION_TIMEOUT: Duration = Duration::from_secs(35); + +fn task_output(output: Output, operation: &str) -> anyhow::Result<()> { + ensure!( + output.status.success(), + "scheduled task {operation} failed ({}): {} {}", + output.status, + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ); + Ok(()) +} + +async fn task_command_output(args: &[&str]) -> anyhow::Result { + tokio::time::timeout( + TASK_TIMEOUT, + tokio::process::Command::new("schtasks.exe") + .args(args) + .kill_on_drop(true) + .output(), + ) + .await + .context("scheduled task command timed out")? + .context("execute schtasks.exe") +} + +async fn task_command(args: &[&str]) -> anyhow::Result<()> { + task_output(task_command_output(args).await?, args[0]) +} + +fn task_command_blocking_output(args: &[&str]) -> anyhow::Result { + let mut child = std::process::Command::new("schtasks.exe") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("execute schtasks.exe for cleanup")?; + let started = Instant::now(); + loop { + if child.try_wait()?.is_some() { + return child.wait_with_output().map_err(Into::into); + } + if started.elapsed() >= TASK_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("scheduled task {} timed out during cleanup", args[0]); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn task_command_blocking(args: &[&str]) -> anyhow::Result<()> { + task_output(task_command_blocking_output(args)?, args[0]) +} + +fn task_absent_from_query(output: &Output) -> anyhow::Result { + if output.status.success() { + return Ok(false); + } + let message = format!( + "{} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + ensure!( + message.to_ascii_lowercase().contains("cannot find the file specified"), + "cannot verify scheduled task absence: {message}" + ); + Ok(true) +} + +async fn task_absent(name: &str) -> anyhow::Result { + task_absent_from_query(&task_command_output(&["/Query", "/TN", name, "/FO", "LIST"]).await?) +} + +fn task_absent_blocking(name: &str) -> anyhow::Result { + task_absent_from_query(&task_command_blocking_output(&["/Query", "/TN", name, "/FO", "LIST"])?) +} + +async fn delete_task(name: &str) -> anyhow::Result<()> { + let deleted = task_command(&["/Delete", "/TN", name, "/F"]).await; + ensure!( + task_absent(name).await?, + "scheduled task {name} remains after deletion attempt: {}", + deleted + .err() + .map_or_else(|| "delete returned success".to_owned(), |error| format!("{error:#}")) + ); + Ok(()) +} + +fn delete_task_blocking(name: &str) -> anyhow::Result<()> { + let deleted = task_command_blocking(&["/Delete", "/TN", name, "/F"]); + ensure!( + task_absent_blocking(name)?, + "scheduled task {name} remains after deletion attempt: {}", + deleted + .err() + .map_or_else(|| "delete returned success".to_owned(), |error| format!("{error:#}")) + ); + Ok(()) +} + +async fn end_task(name: &str) -> anyhow::Result<()> { + let result = task_command(&["/End", "/TN", name]).await; + if result.is_ok() || task_absent(name).await? { + return Ok(()); + } + result +} + +fn end_task_blocking(name: &str) -> anyhow::Result<()> { + let result = task_command_blocking(&["/End", "/TN", name]); + if result.is_ok() || task_absent_blocking(name)? { + return Ok(()); + } + result +} + +fn powershell_literal(path: &Path) -> String { + format!("'{}'", path.to_string_lossy().replace('\'', "''")) +} + +fn absolute(path: &Path) -> anyhow::Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +fn write_powershell_script(path: &Path, script: &str) -> anyhow::Result<()> { + let mut bytes = vec![0xff, 0xfe]; + for code_unit in script.encode_utf16() { + bytes.extend_from_slice(&code_unit.to_le_bytes()); + } + crate::windows::write_protected_file(path, &bytes) +} + +fn task_action(script: &Path) -> String { + format!("cmd.exe /d /s /c \"\"{}\"\"", script.display()) +} + +fn create_task_args<'a>(name: &'a str, action: &'a str) -> [&'a str; 14] { + [ + "/Create", "/TN", name, "/TR", action, "/SC", "ONCE", "/ST", "23:59", "/RU", "SYSTEM", "/RL", "HIGHEST", "/F", + ] +} + +fn cleanup_error(errors: &mut Vec, action: &str, error: &anyhow::Error) { + eprintln!("SYSTEM fixture cleanup: {action}: {error:#}"); + errors.push(format!("{action}: {error:#}")); +} + +async fn serve_probe_token(listener: &TcpListener, nonce: uuid::Uuid, token: &str) -> anyhow::Result<()> { + loop { + let (mut stream, _) = listener.accept().await?; + let mut offered = [0u8; 16]; + let authenticated = tokio::time::timeout(Duration::from_secs(2), stream.read_exact(&mut offered)) + .await + .is_ok_and(|read| read.is_ok() && offered == *nonce.as_bytes()); + if !authenticated { + continue; + } + stream.write_all(&u32::try_from(token.len())?.to_le_bytes()).await?; + stream.write_all(token.as_bytes()).await?; + stream.flush().await?; + return Ok(()); + } +} + +struct SystemFixture { + case: AgentCase, + pid_file: PathBuf, + request: Request, + request_path: PathBuf, + result_path: PathBuf, + agent_task: String, + probe_task: String, + agent_registered: bool, + probe_registered: bool, + agent_registration_attempted: bool, + probe_registration_attempted: bool, + agent_may_have_started: bool, + cleanup_verified: bool, + cleaned: bool, +} + +impl SystemFixture { + fn new(case: AgentCase) -> anyhow::Result { + let task_base = format!("DevolutionsAgentIdentityConformance-{}", uuid::Uuid::new_v4()); + let request_path = case.path().join("system-probe-request.json"); + let result_path = case.path().join("system-probe-result.json"); + let pid_file = case.path().join("system-agent.pid"); + let system_agent_bin = case.path().join( + case.ctx + .agent_bin + .file_name() + .context("agent executable has no file name")?, + ); + std::fs::copy(&case.ctx.agent_bin, &system_agent_bin) + .context("copy agent for case-specific process cleanup")?; + let request = Request { + case_dir: case.path().to_path_buf(), + agent_bin: system_agent_bin, + key_name_prefix: case.ctx.key_name_prefix.clone(), + identity_id: None, + token_port: None, + token_nonce: None, + cleanup_only: true, + }; + let fixture = Self { + case, + pid_file, + request, + request_path, + result_path, + agent_task: format!("{task_base}-Agent"), + probe_task: format!("{task_base}-Probe"), + agent_registered: false, + probe_registered: false, + agent_registration_attempted: false, + probe_registration_attempted: false, + agent_may_have_started: false, + cleanup_verified: false, + cleaned: false, + }; + fixture.write_wrappers()?; + Ok(fixture) + } + + fn write_wrappers(&self) -> anyhow::Result<()> { + let dir = self.case.path(); + crate::windows::write_protected_file(&self.pid_file, b"")?; + crate::windows::write_protected_file(&self.request_path, b"")?; + crate::windows::write_protected_file(&self.result_path, b"")?; + for name in [ + "agent-stdout.log", + "agent-stderr.log", + "system-agent-wrapper-stdout.log", + "system-agent-wrapper-stderr.log", + "system-agent-wrapper-error.log", + "system-probe-stdout.log", + "system-probe-stderr.log", + "cli-stdout.log", + "cli-stderr.log", + ] { + crate::windows::write_protected_file(&dir.join(name), b"")?; + } + let agent_script = dir.join("system-agent.ps1"); + let wrapper_error = dir.join("system-agent-wrapper-error.log"); + let original_bin_dir = self + .case + .ctx + .agent_bin + .parent() + .context("agent executable has no parent directory")?; + let script = format!( + "\ +$ErrorActionPreference = 'Stop'\r\n\ +$env:DAGENT_CONFIG_PATH = {config}\r\n\ +$env:PATH = {binary_dir} + [IO.Path]::PathSeparator + $env:PATH\r\n\ +try {{\r\n\ + $agent = Start-Process -FilePath {binary} -ArgumentList 'run' -WorkingDirectory {config} -RedirectStandardOutput {stdout} -RedirectStandardError {stderr} -PassThru\r\n\ + [System.IO.File]::WriteAllText({pid}, $agent.Id.ToString())\r\n\ + $agent.WaitForExit()\r\n\ + exit $agent.ExitCode\r\n\ +}} catch {{\r\n\ + [System.IO.File]::AppendAllText({error}, $_.ToString() + [Environment]::NewLine)\r\n\ + exit 1\r\n\ +}}\r\n", + config = powershell_literal(dir), + binary_dir = powershell_literal(original_bin_dir), + binary = powershell_literal(&self.request.agent_bin), + stdout = powershell_literal(&dir.join("agent-stdout.log")), + stderr = powershell_literal(&dir.join("agent-stderr.log")), + pid = powershell_literal(&self.pid_file), + error = powershell_literal(&wrapper_error), + ); + write_powershell_script(&agent_script, &script)?; + let cmd = format!( + "@echo off\r\npowershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{}\" 1>> \"{}\" 2>> \"{}\"\r\n", + agent_script.display(), + dir.join("system-agent-wrapper-stdout.log").display(), + dir.join("system-agent-wrapper-stderr.log").display() + ); + crate::windows::write_protected_file(&dir.join("system-agent.cmd"), cmd.as_bytes())?; + + let probe_cmd = format!( + "@echo off\r\n\"{}\" --system-probe \"{}\" \"{}\" 1>> \"{}\" 2>> \"{}\"\r\n", + std::env::current_exe()?.display(), + self.request_path.display(), + self.result_path.display(), + dir.join("system-probe-stdout.log").display(), + dir.join("system-probe-stderr.log").display() + ); + crate::windows::write_protected_file(&dir.join("system-probe.cmd"), probe_cmd.as_bytes())?; + Ok(()) + } + + async fn register(&mut self) -> anyhow::Result<()> { + let probe_action = task_action(&self.case.path().join("system-probe.cmd")); + self.probe_registration_attempted = true; + task_command(&create_task_args(&self.probe_task, &probe_action)).await?; + self.probe_registered = true; + let agent_action = task_action(&self.case.path().join("system-agent.cmd")); + self.agent_registration_attempted = true; + self.agent_may_have_started = true; + task_command(&create_task_args(&self.agent_task, &agent_action)).await?; + self.agent_registered = true; + task_command(&["/Run", "/TN", &self.agent_task]).await + } + + async fn wait_for_agent(&self) -> anyhow::Result<()> { + let started = Instant::now(); + loop { + match crate::windows::system_agent_running(&self.pid_file, &self.request.agent_bin)? { + Some(true) => return Ok(()), + Some(false) => anyhow::bail!("SYSTEM agent exited before the fixture was ready"), + None => {} + } + ensure!( + started.elapsed() < Duration::from_secs(15), + "SYSTEM scheduled task did not start the agent within 15 seconds" + ); + tokio::time::sleep(POLL).await; + } + } + + async fn submit_token(&self, token: &str) -> anyhow::Result<()> { + let started = Instant::now(); + loop { + let output = tokio::time::timeout( + Duration::from_secs(5), + tokio::process::Command::new(&self.request.agent_bin) + .args(["identity", "enroll", token]) + .env("DAGENT_CONFIG_PATH", self.case.path()) + .kill_on_drop(true) + .output(), + ) + .await + .context("identity enroll CLI timed out")? + .context("run identity enroll CLI as administrator")?; + for (name, contents) in [("cli-stdout.log", &output.stdout), ("cli-stderr.log", &output.stderr)] { + std::fs::OpenOptions::new() + .append(true) + .open(self.case.path().join(name))? + .write_all(contents)?; + } + if output.status.success() { + return Ok(()); + } + ensure!( + started.elapsed() < Duration::from_secs(30), + "identity enroll CLI did not accept the token within 30 seconds" + ); + ensure!( + crate::windows::system_agent_running(&self.pid_file, &self.request.agent_bin)? == Some(true), + "SYSTEM agent exited before identity enroll CLI succeeded" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } + } + + async fn enrolled_identity(&self, target: &Target, token: &Token) -> anyhow::Result { + let started = Instant::now(); + loop { + let listing = target + .management() + .identities(&IdentityQuery::enrolled_with(token).full()) + .await?; + ensure!( + listing.total_count <= 1, + "SYSTEM agent created multiple identities for a one-use token" + ); + if let Some(row) = listing.items.first() { + let id = row.id.clone(); + self.case.observe_agent_metadata(row)?; + let record = target.management().token(&token.id).await?; + ensure!(record.used_count == 1, "SYSTEM agent did not consume its one-use token"); + return Ok(id); + } + ensure!( + crate::windows::system_agent_running(&self.pid_file, &self.request.agent_bin)? == Some(true), + "SYSTEM agent exited before enrollment" + ); + ensure!( + started.elapsed() < WAIT, + "SYSTEM agent did not enroll within 20 seconds" + ); + tokio::time::sleep(POLL).await; + } + } + + async fn signed_traffic(&mut self, target: &Target, id: &str) -> anyhow::Result<()> { + let check_channel = self.case.ctx.channel_available; + if !check_channel { + self.case.set_metadata_hostname("system-acl-signing-probe")?; + } + let started = Instant::now(); + loop { + let record = target.management().identity(id).await?; + self.case.observe_agent_metadata(&record)?; + if (check_channel && record.connected) + || (!check_channel && record.metadata_value("hostname") == Some("system-acl-signing-probe")) + { + return Ok(()); + } + ensure!( + crate::windows::system_agent_running(&self.pid_file, &self.request.agent_bin)? == Some(true), + "SYSTEM agent exited before signed Hello or check-in" + ); + ensure!( + started.elapsed() < WAIT, + "SYSTEM agent did not send a signed Hello or check-in within 20 seconds" + ); + tokio::time::sleep(POLL).await; + } + } + + fn prepare_probe(&mut self, cleanup_only: bool) -> anyhow::Result<()> { + self.request.cleanup_only = cleanup_only; + std::fs::write(&self.request_path, serde_json::to_vec(&self.request)?)?; + std::fs::write(&self.result_path, b"")?; + Ok(()) + } + + fn probe_result(&self) -> anyhow::Result> { + let bytes = std::fs::read(&self.result_path)?; + if bytes.is_empty() { + return Ok(None); + } + let Ok(result) = serde_json::from_slice::(&bytes) else { + return Ok(None); + }; + ensure!( + result.success, + "SYSTEM probe failed: {}", + result.error.as_deref().unwrap_or("no reason given") + ); + Ok(Some(())) + } + + async fn probe(&mut self, cleanup_only: bool) -> anyhow::Result<()> { + let channel = if cleanup_only { + self.request.token_port = None; + self.request.token_nonce = None; + None + } else { + // The full token only crosses this short-lived loopback channel; its nonce stays in the protected request. + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let nonce = uuid::Uuid::new_v4(); + self.request.token_port = Some(listener.local_addr()?.port()); + self.request.token_nonce = Some(nonce); + Some((listener, nonce)) + }; + self.prepare_probe(cleanup_only)?; + task_command(&["/Run", "/TN", &self.probe_task]).await?; + if let Some((listener, nonce)) = channel { + let token = self.case.tokens.last().context("SYSTEM probe has no enrolled token")?; + tokio::time::timeout(PROBE_TIMEOUT, serve_probe_token(&listener, nonce, token)) + .await + .context("SYSTEM probe token channel timed out")??; + } + let started = Instant::now(); + let timeout = if cleanup_only { + PROBE_TIMEOUT + } else { + INSPECTION_TIMEOUT + }; + loop { + if self.probe_result()?.is_some() { + return Ok(()); + } + ensure!( + started.elapsed() < timeout, + "SYSTEM probe returned no readable result within {} seconds", + timeout.as_secs() + ); + tokio::time::sleep(POLL).await; + } + } + + fn probe_blocking(&mut self) -> anyhow::Result<()> { + self.request.token_port = None; + self.request.token_nonce = None; + self.prepare_probe(true)?; + task_command_blocking(&["/Run", "/TN", &self.probe_task])?; + let started = Instant::now(); + loop { + if self.probe_result()?.is_some() { + return Ok(()); + } + ensure!( + started.elapsed() < PROBE_TIMEOUT, + "SYSTEM cleanup probe returned no readable result within 20 seconds" + ); + std::thread::sleep(POLL); + } + } + + async fn execute(&mut self) -> anyhow::Result<()> { + let target = self.case.ctx.target.clone(); + let token = new_token(&self.case, 1).await?; + self.case.add_token(&token.text); + self.register().await?; + self.wait_for_agent().await?; + self.submit_token(&token.text).await?; + let id = self.enrolled_identity(&target, &token).await?; + self.signed_traffic(&target, &id).await?; + self.request.identity_id = Some(id); + self.probe(false).await + } + + async fn cleanup(&mut self) -> anyhow::Result<()> { + if self.cleaned { + return Ok(()); + } + let mut errors = Vec::new(); + if let Err(error) = end_task(&self.agent_task).await { + eprintln!("SYSTEM fixture cleanup: end agent task: {error:#}"); + } + if self.agent_may_have_started { + if self.probe_registered { + if self.request.identity_id.is_some() { + let _ = end_task(&self.probe_task).await; + } + for attempt in 1..=2 { + match self.probe(true).await { + Ok(()) => { + self.cleanup_verified = true; + break; + } + Err(error) => { + eprintln!("SYSTEM fixture cleanup: probe attempt {attempt}: {error:#}"); + let _ = end_task(&self.probe_task).await; + if attempt == 2 { + errors.push(format!("remove key and stop agent through probe: {error:#}")); + } + } + } + } + } else { + errors.push("SYSTEM cleanup probe task was not registered".to_owned()); + } + } else { + self.cleanup_verified = true; + } + if let Err(error) = end_task(&self.probe_task).await { + eprintln!("SYSTEM fixture cleanup: end probe task: {error:#}"); + } + for (attempted, registered, name) in [ + ( + &mut self.agent_registration_attempted, + &mut self.agent_registered, + &self.agent_task, + ), + ( + &mut self.probe_registration_attempted, + &mut self.probe_registered, + &self.probe_task, + ), + ] { + match delete_task(name).await { + Ok(()) => { + *attempted = false; + *registered = false; + } + Err(error) => cleanup_error(&mut errors, &format!("delete task {name}"), &error), + } + } + if let Err(error) = crate::windows::stop_system_agent(&self.request.agent_bin) { + cleanup_error(&mut errors, "stop case agent by exact image", &error); + } + match crate::windows::case_agent_processes(&self.request.agent_bin) { + Ok(pids) if !pids.is_empty() => { + self.cleanup_verified = false; + errors.push(format!("SYSTEM agent PIDs still running: {pids:?}")); + } + Ok(_) => {} + Err(error) => { + self.cleanup_verified = false; + cleanup_error(&mut errors, "verify agent exit", &error); + } + } + self.cleaned = self.cleanup_verified + && !self.agent_registration_attempted + && !self.probe_registration_attempted + && errors.is_empty(); + ensure!( + errors.is_empty(), + "SYSTEM fixture cleanup failed: {}", + errors.join("; ") + ); + Ok(()) + } + + fn cleanup_blocking(&mut self) { + let mut errors = Vec::new(); + if let Err(error) = end_task_blocking(&self.agent_task) { + eprintln!("SYSTEM fixture cleanup: end agent task: {error:#}"); + } + if self.agent_may_have_started && !self.cleanup_verified { + if !self.probe_registered { + let action = task_action(&self.case.path().join("system-probe.cmd")); + self.probe_registration_attempted = true; + match task_command_blocking(&create_task_args(&self.probe_task, &action)) { + Ok(()) => self.probe_registered = true, + Err(error) => cleanup_error(&mut errors, "register fallback probe task", &error), + } + } + if self.probe_registered { + let _ = end_task_blocking(&self.probe_task); + for attempt in 1..=2 { + match self.probe_blocking() { + Ok(()) => { + self.cleanup_verified = true; + break; + } + Err(error) => { + eprintln!("SYSTEM fixture cleanup: blocking probe attempt {attempt}: {error:#}"); + let _ = end_task_blocking(&self.probe_task); + if attempt == 2 { + errors.push(format!("remove key and stop agent through probe: {error:#}")); + } + } + } + } + } + } + if let Err(error) = end_task_blocking(&self.probe_task) { + eprintln!("SYSTEM fixture cleanup: end probe task: {error:#}"); + } + for (attempted, registered, name) in [ + ( + &mut self.agent_registration_attempted, + &mut self.agent_registered, + &self.agent_task, + ), + ( + &mut self.probe_registration_attempted, + &mut self.probe_registered, + &self.probe_task, + ), + ] { + match delete_task_blocking(name) { + Ok(()) => { + *attempted = false; + *registered = false; + } + Err(error) => cleanup_error(&mut errors, &format!("delete task {name}"), &error), + } + } + if let Err(error) = crate::windows::stop_system_agent(&self.request.agent_bin) { + cleanup_error(&mut errors, "stop case agent by exact image", &error); + } + match crate::windows::case_agent_processes(&self.request.agent_bin) { + Ok(pids) if !pids.is_empty() => errors.push(format!("SYSTEM agent PIDs still running: {pids:?}")), + Ok(_) => {} + Err(error) => cleanup_error(&mut errors, "verify agent exit", &error), + } + for error in errors { + eprintln!("SYSTEM fixture cleanup remains incomplete: {error}"); + } + } +} + +impl Drop for SystemFixture { + fn drop(&mut self) { + if !self.cleaned { + self.cleanup_blocking(); + } + } +} + +pub(super) async fn run(mut ctx: Context) -> anyhow::Result<()> { + let fixture_id = uuid::Uuid::new_v4().simple().to_string(); + ctx.key_name_prefix = format!("{}-{}", ctx.key_name_prefix, &fixture_id[..16]); + ensure!( + crate::agent::valid_key_prefix(&ctx.key_name_prefix), + "SYSTEM fixture prefix is not a valid device-key prefix" + ); + ctx.work_dir = absolute(&ctx.work_dir)?; + ctx.agent_bin = absolute(&ctx.agent_bin)?; + if let Some(path) = ctx.target.ca_path.as_mut() { + *path = absolute(path)?; + } + if let Some(path) = ctx.second.as_mut().and_then(|second| second.target.ca_path.as_mut()) { + *path = absolute(path)?; + } + reset_mocks(&ctx).await?; + let mut case = AgentCase::new(ctx, None, false).await?; + case.set_acl_grant_current_user(false)?; + case.cleaned = true; + let mut fixture = SystemFixture::new(case)?; + let result = fixture.execute().await; + let cleanup = fixture.cleanup().await; + let audit = fixture.case.audit_tokens_excluding(Some(&fixture.request.agent_bin)); + if let Err(error) = &audit { + eprintln!("SYSTEM fixture token audit failed: {error:#}"); + } + fixture.case.keep = result.is_err() || cleanup.is_err() || audit.is_err(); + match (result, cleanup, audit) { + (Err(error), _, _) => Err(error), + (Ok(()), Err(error), _) => Err(error), + (Ok(()), Ok(()), audit) => audit, + } +} + +#[cfg(test)] +mod tests { + use std::os::windows::process::ExitStatusExt as _; + + use tokio::io::AsyncWriteExt as _; + + use super::*; + + #[test] + fn task_absence_requires_a_specific_not_found_result() -> anyhow::Result<()> { + // oracle: mutant every failed scheduled-task query reads as the task's absence + let missing = Output { + status: std::process::ExitStatus::from_raw(1), + stdout: Vec::new(), + stderr: b"ERROR: The system cannot find the file specified.".to_vec(), + }; + assert!(task_absent_from_query(&missing)?); + let denied = Output { + stderr: b"ERROR: Access is denied.".to_vec(), + ..missing + }; + assert!(task_absent_from_query(&denied).is_err()); + let present = Output { + status: std::process::ExitStatus::from_raw(0), + stderr: Vec::new(), + ..denied + }; + assert!(!task_absent_from_query(&present)?); + Ok(()) + } + + #[tokio::test] + async fn token_channel_filters_nonce_and_serves_once() -> anyhow::Result<()> { + // oracle: mutant the probe token channel serves the token to a connection that offers the wrong nonce + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let address = listener.local_addr()?; + let nonce = uuid::Uuid::new_v4(); + let token = format!("dvaet1.bag.{}", "B".repeat(43)); + let request = Request { + case_dir: PathBuf::new(), + agent_bin: PathBuf::new(), + key_name_prefix: String::new(), + identity_id: None, + token_port: Some(address.port()), + token_nonce: Some(nonce), + cleanup_only: false, + }; + let encoded = serde_json::to_string(&request)?; + ensure!( + !encoded.contains(&token) && !encoded.contains(&"B".repeat(12)), + "probe request included token material" + ); + let served_token = token.clone(); + let server = tokio::spawn(async move { serve_probe_token(&listener, nonce, &served_token).await }); + let mut wrong = tokio::net::TcpStream::connect(address).await?; + let mut wrong_nonce = *nonce.as_bytes(); + wrong_nonce[0] ^= 1; + wrong.write_all(&wrong_nonce).await?; + drop(wrong); + tokio::time::sleep(Duration::from_millis(20)).await; + ensure!(!server.is_finished(), "token channel accepted a wrong nonce"); + let received = tokio::time::timeout( + Duration::from_secs(5), + tokio::task::spawn_blocking(move || crate::system_probe::read_token(&request)), + ) + .await + .context("probe token reader timed out")???; + let outcome = tokio::time::timeout(Duration::from_secs(5), server) + .await + .context("token channel server timed out")??; + outcome?; + ensure!(received == token, "token channel changed the token"); + ensure!( + tokio::net::TcpStream::connect(address).await.is_err(), + "token channel accepted a second connection" + ); + Ok(()) + } +} diff --git a/crates/agent-identity-conformance/src/system_probe.rs b/crates/agent-identity-conformance/src/system_probe.rs new file mode 100644 index 000000000..9ea752558 --- /dev/null +++ b/crates/agent-identity-conformance/src/system_probe.rs @@ -0,0 +1,447 @@ +use std::collections::HashSet; +use std::io::{Read as _, Write as _}; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, ensure}; +use der::{Decode as _, Encode as _}; +use p256::ecdsa::VerifyingKey; +use p256::pkcs8::EncodePublicKey as _; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use x509_cert::Certificate; + +use crate::client::decoded_certificate; + +#[derive(Clone, Serialize, Deserialize)] +pub(crate) struct Request { + pub(crate) case_dir: PathBuf, + pub(crate) agent_bin: PathBuf, + pub(crate) key_name_prefix: String, + pub(crate) identity_id: Option, + pub(crate) token_port: Option, + pub(crate) token_nonce: Option, + pub(crate) cleanup_only: bool, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct ResultFile { + pub(crate) success: bool, + pub(crate) error: Option, +} + +fn validate_run_key(name: &str, prefix: &str) -> anyhow::Result<()> { + crate::agent::validate_key_name(prefix, name).context("SYSTEM key name does not belong to this fixture") +} + +fn matching_key(identity: &Value, identity_id: &str, prefix: &str) -> anyhow::Result> { + if identity["identity_id"] != identity_id { + return Ok(None); + } + let name = identity["keys"]["current"]["key_name"] + .as_str() + .context("stored identity has no current key name")?; + validate_run_key(name, prefix)?; + Ok(Some(name.to_owned())) +} + +fn stored_keys(request: &Request) -> anyhow::Result<(Value, HashSet)> { + let id = request + .identity_id + .as_deref() + .context("SYSTEM probe has no enrolled identity ID")?; + let authorities = request.case_dir.join("identity").join("authorities"); + let mut identities = Vec::new(); + let mut recorded = HashSet::new(); + for entry in std::fs::read_dir(authorities).context("read SYSTEM-only stored identities")? { + let path = entry?.path().join("identity.json"); + if !path.is_file() { + continue; + } + let identity: Value = serde_json::from_slice(&std::fs::read(path)?)?; + for slot in ["current", "pending", "previous"] { + if let Some(name) = identity["keys"][slot]["key_name"].as_str() { + validate_run_key(name, &request.key_name_prefix)?; + recorded.insert(name.to_owned()); + } + } + if matching_key(&identity, id, &request.key_name_prefix)?.is_some() { + identities.push(identity); + } + } + let pending = request.case_dir.join("identity").join("pending"); + if pending.is_dir() { + for entry in std::fs::read_dir(pending)? { + let path = entry?.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".in-progress.json")) + { + let record: Value = serde_json::from_slice(&std::fs::read(path)?)?; + let name = record["key_name"] + .as_str() + .context("in-progress record has no key name")?; + validate_run_key(name, &request.key_name_prefix)?; + recorded.insert(name.to_owned()); + } + } + } + ensure!( + identities.len() == 1, + "expected exactly one SYSTEM stored identity for enrolled identity" + ); + Ok((identities.remove(0), recorded)) +} + +fn assert_matching_certificate(name: &str, slot: &Value) -> anyhow::Result<()> { + let leaf = slot["certificate_chain"] + .as_array() + .and_then(|chain| chain.first()) + .and_then(Value::as_str) + .context("SYSTEM stored key has no leaf certificate")?; + let certificate = Certificate::from_der(&decoded_certificate(leaf)?)?; + let expected = certificate.tbs_certificate().subject_public_key_info().to_der()?; + let actual = VerifyingKey::from_sec1_bytes(&crate::windows::machine_public_key(name)?)?; + ensure!( + actual.to_public_key_der()?.as_bytes() == expected, + "SYSTEM-only machine key does not match its stored certificate" + ); + Ok(()) +} + +fn assert_stored_keys(identity: &Value) -> anyhow::Result<()> { + for slot in ["current", "pending", "previous"] { + let Some(name) = identity["keys"][slot]["key_name"].as_str() else { + continue; + }; + let exists = crate::windows::machine_key_exists(name)?; + ensure!( + exists || slot != "current", + "settled SYSTEM current machine key is missing" + ); + if exists { + crate::windows::assert_non_exportable(name)?; + crate::windows::assert_machine_key_acl(name)?; + if identity["keys"][slot]["certificate_chain"].is_array() || slot == "current" { + assert_matching_certificate(name, &identity["keys"][slot])?; + } + } + } + Ok(()) +} + +fn assert_no_orphan_keys(prefix: &str, recorded: &HashSet) -> anyhow::Result<()> { + let existing = crate::windows::machine_keys_with_prefix(prefix)?; + let mut orphaned = existing.difference(recorded).collect::>(); + orphaned.sort_unstable(); + ensure!( + orphaned.is_empty(), + "unrecorded SYSTEM-only machine keys with run prefix: {}", + orphaned.iter().map(|name| name.as_str()).collect::>().join(", ") + ); + Ok(()) +} + +pub(crate) fn read_token(request: &Request) -> anyhow::Result { + let port = request.token_port.context("SYSTEM probe has no token channel port")?; + let nonce = request.token_nonce.context("SYSTEM probe has no token channel nonce")?; + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let mut stream = + TcpStream::connect_timeout(&address, Duration::from_secs(5)).context("connect to protected token channel")?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + stream.set_write_timeout(Some(Duration::from_secs(10)))?; + stream.write_all(nonce.as_bytes())?; + let mut length = [0u8; 4]; + stream.read_exact(&mut length)?; + let length = u32::from_le_bytes(length); + ensure!( + (1..=4096).contains(&length), + "SYSTEM probe received an invalid token length" + ); + let mut token = vec![0u8; usize::try_from(length)?]; + stream.read_exact(&mut token)?; + String::from_utf8(token).context("SYSTEM probe received a non-UTF-8 token") +} + +fn path_present(path: &Path) -> anyhow::Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + } +} + +fn wait_for_pending_cleanup(request: &Request, token: &str, timeout: Duration) -> anyhow::Result<()> { + let stem = crate::agent::token_file_id(token); + let pending_dir = request.case_dir.join("identity").join("pending"); + let pending = pending_dir.join(format!("{stem}.dat")); + let in_progress = pending_dir.join(format!("{stem}.in-progress.json")); + let started = Instant::now(); + loop { + if !path_present(&pending)? && !path_present(&in_progress)? { + return Ok(()); + } + ensure!( + started.elapsed() < timeout, + "SYSTEM enrollment left pending or in-progress state after success" + ); + std::thread::sleep(Duration::from_millis(100).min(timeout.saturating_sub(started.elapsed()))); + } +} + +fn audit_identity_tree(request: &Request, token: &str) -> anyhow::Result<()> { + let identity = request.case_dir.join("identity"); + let authorities = identity.join("authorities"); + let pending = identity.join("pending"); + let keys = identity.join("keys"); + let pending_file = pending.join(format!("{}.dat", crate::agent::token_file_id(token))); + let mut documented = HashSet::from([ + pending_file.clone(), + pending.join(format!("{}.in-progress.json", crate::agent::token_file_id(token))), + ]); + if authorities.is_dir() { + for entry in std::fs::read_dir(&authorities)? { + let path = entry?.path(); + if path.is_dir() { + documented.insert(path.join("identity.json")); + } + } + } + let mut directories = vec![identity]; + while let Some(directory) = directories.pop() { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + let known_directory = path == authorities + || path == pending + || path == keys + || path.parent() == Some(authorities.as_path()) + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| uuid::Uuid::parse_str(name).is_ok_and(|id| id.to_string() == name)); + if file_type.is_dir() { + if !known_directory { + eprintln!("WARN unknown SYSTEM identity directory"); + } + directories.push(path); + continue; + } + ensure!(!known_directory, "documented identity directory is not a directory"); + ensure!( + !path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("p8")), + "unexpected private key file under SYSTEM identity" + ); + let temporary = crate::agent::is_temporary_sibling(&path, &documented, &request.key_name_prefix); + // A record's atomic write can leave its temporary file when it does not complete, as when the agent is stopped mid-write (`is_record_temporary`); any other temporary sibling is a leftover. + ensure!( + !temporary || crate::agent::is_record_temporary(&path), + "leftover SYSTEM identity temporary file" + ); + if file_type.is_file() { + let contents = std::fs::read(&path)?; + ensure!( + !crate::agent::contains_pkcs8_material(&contents), + "unrecorded PKCS#8 private key under SYSTEM identity" + ); + if path != pending_file { + crate::agent::assert_no_token_fragments(&contents, token, Path::new("SYSTEM identity file"))?; + } + } + if documented.contains(&path) { + ensure!( + file_type.is_file(), + "documented SYSTEM identity file is not a regular file" + ); + } else if temporary { + eprintln!("WARN SYSTEM identity temporary file of an interrupted record write"); + } else { + eprintln!("WARN unknown SYSTEM identity file"); + } + } + } + Ok(()) +} + +pub(crate) fn run(request_path: &Path, result_path: &Path) -> anyhow::Result<()> { + ensure!(crate::windows::running_as_system()?, "SYSTEM probe must run as SYSTEM"); + let request: Request = serde_json::from_slice(&std::fs::read(request_path)?)?; + ensure!( + request.key_name_prefix.starts_with("conformance-") && crate::agent::valid_key_prefix(&request.key_name_prefix), + "SYSTEM probe received a non-conformance key prefix" + ); + let mut errors = Vec::new(); + let stopped = if request.cleanup_only { + match crate::windows::stop_system_agent(&request.agent_bin) { + Ok(()) => true, + Err(error) => { + errors.push(format!("stop SYSTEM agent: {error:#}")); + false + } + } + } else { + match read_token(&request).and_then(|token| { + wait_for_pending_cleanup(&request, &token, Duration::from_secs(20))?; + let (identity, recorded) = stored_keys(&request)?; + assert_stored_keys(&identity)?; + assert_no_orphan_keys(&request.key_name_prefix, &recorded)?; + audit_identity_tree(&request, &token) + }) { + Ok(()) => {} + Err(error) => errors.push(format!("inspect SYSTEM-only key: {error:#}")), + } + false + }; + let keys_removed = match crate::windows::cleanup_machine_keys(&request.key_name_prefix) { + Ok(()) => true, + Err(error) => { + errors.push(format!("remove SYSTEM-only machine keys: {error:#}")); + false + } + }; + if request.cleanup_only && stopped && keys_removed { + let identity_dir = request.case_dir.join("identity"); + if let Err(error) = std::fs::remove_dir_all(identity_dir) + && error.kind() != std::io::ErrorKind::NotFound + { + errors.push(format!("remove SYSTEM-only identity files: {error}")); + } + } + let result = ResultFile { + success: errors.is_empty(), + error: (!errors.is_empty()).then(|| errors.join("; ")), + }; + std::fs::write(result_path, serde_json::to_vec(&result)?).context("write admin-readable SYSTEM probe result")?; + ensure!( + result.success, + "{}", + result.error.as_deref().unwrap_or("SYSTEM probe failed") + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::time::Duration; + + use serde_json::json; + + use super::{Request, audit_identity_tree, matching_key, wait_for_pending_cleanup}; + + #[test] + fn current_key_is_selected_only_from_the_matching_identity_and_run() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + // oracle: intent crates/device-key/INTENT.md §Key name + // oracle: intent devolutions-agent/src/identity/INTENT.md §Configuration + let prefix = "conformance-4321"; + let name = format!("{prefix}_479e1d51-4038-4d09-b7f8-5bdff4489cad"); + let identity = json!({ "identity_id": "identity-a", "keys": { "current": { "key_name": name } } }); + assert_eq!(matching_key(&identity, "identity-a", prefix)?, Some(name)); + assert_eq!(matching_key(&identity, "identity-b", prefix)?, None); + assert!(matching_key(&identity, "identity-a", "conformance-432").is_err()); + assert!( + matching_key( + &json!({ "identity_id": "identity-a", "keys": { "current": { "key_name": format!("{prefix}bad") } } }), + "identity-a", + prefix + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn system_identity_audit_rejects_token_fragments_and_private_key_material() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity-conformance/INTENT.md §Coverage + // oracle: intent crates/agent-identity/INTENT.md §Identities + // oracle: intent crates/agent-identity/INTENT.md §Stored identities + let scratch = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join("agent-identity-system-probe-tests"); + std::fs::create_dir_all(&scratch)?; + let dir = tempfile::Builder::new().prefix("identity-").tempdir_in(scratch)?; + let identity = dir.path().join("identity"); + let authority = identity.join("authorities").join(uuid::Uuid::new_v4().to_string()); + let keys = identity.join("keys"); + std::fs::create_dir_all(&authority)?; + std::fs::create_dir_all(&keys)?; + std::fs::write(authority.join("identity.json"), b"{}")?; + let request = Request { + case_dir: dir.path().to_path_buf(), + agent_bin: PathBuf::new(), + key_name_prefix: "conformance-unit".to_owned(), + identity_id: None, + token_port: None, + token_nonce: None, + cleanup_only: true, + }; + let token = format!("dvaet1.bag.{}", "A".repeat(43)); + audit_identity_tree(&request, &token)?; + // A stop can leave the temporary file of a record's atomic write. + let interrupted = authority.join(format!(".identity.json-{}.tmp", uuid::Uuid::new_v4())); + std::fs::write(&interrupted, b"{")?; + audit_identity_tree(&request, &token)?; + std::fs::remove_file(interrupted)?; + let leaked = authority.join("debug.log"); + std::fs::write(&leaked, &token.as_bytes()[token.len() - 12..])?; + assert!(audit_identity_tree(&request, &token).is_err()); + std::fs::remove_file(leaked)?; + let private = keys.join("extra.p8"); + std::fs::write(&private, b"private key")?; + assert!(audit_identity_tree(&request, &token).is_err()); + std::fs::remove_file(private)?; + std::fs::write(keys.join("extra.bin"), b"-----BEGIN PRIVATE KEY-----")?; + assert!(audit_identity_tree(&request, &token).is_err()); + Ok(()) + } + + #[test] + fn system_probe_waits_for_both_successful_pending_records_to_disappear() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + let scratch = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join("agent-identity-system-probe-tests"); + std::fs::create_dir_all(&scratch)?; + let dir = tempfile::Builder::new().prefix("pending-").tempdir_in(scratch)?; + let request = Request { + case_dir: dir.path().to_path_buf(), + agent_bin: PathBuf::new(), + key_name_prefix: String::new(), + identity_id: None, + token_port: None, + token_nonce: None, + cleanup_only: false, + }; + let token = format!("dvaet1.bag.{}", "C".repeat(43)); + let stem = crate::agent::token_file_id(&token); + let pending_dir = dir.path().join("identity").join("pending"); + std::fs::create_dir_all(&pending_dir)?; + let pending = pending_dir.join(format!("{stem}.dat")); + let in_progress = pending_dir.join(format!("{stem}.in-progress.json")); + std::fs::write(&pending, b"pending")?; + std::fs::write(&in_progress, b"in progress")?; + assert!(wait_for_pending_cleanup(&request, &token, Duration::from_millis(10)).is_err()); + std::fs::remove_file(pending)?; + assert!(wait_for_pending_cleanup(&request, &token, Duration::from_millis(10)).is_err()); + let remover = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(30)); + std::fs::remove_file(in_progress) + }); + wait_for_pending_cleanup(&request, &token, Duration::from_secs(1))?; + remover + .join() + .map_err(|_| anyhow::anyhow!("pending cleanup helper panicked"))??; + Ok(()) + } +} diff --git a/crates/agent-identity-conformance/src/windows.rs b/crates/agent-identity-conformance/src/windows.rs new file mode 100644 index 000000000..04b56f4b0 --- /dev/null +++ b/crates/agent-identity-conformance/src/windows.rs @@ -0,0 +1,1025 @@ +use std::collections::HashSet; +use std::ffi::{OsString, c_void}; +use std::io::Write as _; +use std::mem::size_of; +use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _}; +use std::os::windows::io::FromRawHandle as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context as _, ensure}; +use windows::Win32::Foundation::{ + CloseHandle, GENERIC_WRITE, HANDLE, HLOCAL, LocalFree, NTE_BAD_KEYSET, NTE_NO_MORE_ITEMS, NTE_NOT_FOUND, +}; +use windows::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT, +}; +use windows::Win32::Security::Cryptography::{ + BCRYPT_ECCPUBLIC_BLOB, BCRYPT_ECDSA_PUBLIC_P256_MAGIC, CERT_KEY_SPEC, CRYPT_INTEGER_BLOB, + CRYPTPROTECT_LOCAL_MACHINE, CryptProtectData, CryptUnprotectData, MS_KEY_STORAGE_PROVIDER, + NCRYPT_EXPORT_POLICY_PROPERTY, NCRYPT_FLAGS, NCRYPT_HANDLE, NCRYPT_KEY_HANDLE, NCRYPT_MACHINE_KEY_FLAG, + NCRYPT_PKCS8_PRIVATE_KEY_BLOB, NCRYPT_PROV_HANDLE, NCRYPT_SECURITY_DESCR_PROPERTY, NCryptDeleteKey, NCryptEnumKeys, + NCryptExportKey, NCryptFreeBuffer, NCryptFreeObject, NCryptGetProperty, NCryptKeyName, NCryptOpenKey, + NCryptOpenStorageProvider, +}; +use windows::Win32::Security::{ + DACL_SECURITY_INFORMATION, GetSecurityDescriptorControl, OBJECT_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, + SE_DACL_PROTECTED, SECURITY_ATTRIBUTES, +}; +use windows::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_MODE}; +use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject, +}; +use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_ACCESS_RIGHTS, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SET_QUOTA, PROCESS_TERMINATE, +}; +use windows::core::{BOOL, PCWSTR, PWSTR}; + +const ENTROPY: &[u8] = b"Devolutions.Agent.PendingEnrollment.v1"; +const STILL_ACTIVE: u32 = 259; +const PROCESS_SYNCHRONIZE: PROCESS_ACCESS_RIGHTS = PROCESS_ACCESS_RIGHTS(0x0010_0000); + +#[link(name = "advapi32")] +unsafe extern "system" { + fn CreateWellKnownSid(kind: i32, domain: *const c_void, sid: *mut c_void, length: *mut u32) -> i32; + fn CheckTokenMembership(token: *mut c_void, sid: *const c_void, member: *mut i32) -> i32; +} + +#[link(name = "kernel32")] +unsafe extern "system" { + fn QueryFullProcessImageNameW(process: *mut c_void, flags: u32, name: *mut u16, length: *mut u32) -> i32; + fn GetExitCodeProcess(process: *mut c_void, code: *mut u32) -> i32; + fn TerminateProcess(process: *mut c_void, code: u32) -> i32; + fn WaitForSingleObject(process: *mut c_void, milliseconds: u32) -> u32; +} + +#[link(name = "psapi")] +unsafe extern "system" { + fn EnumProcesses(process_ids: *mut u32, buffer_bytes: u32, needed_bytes: *mut u32) -> i32; +} + +pub(crate) struct AgentJob(HANDLE); + +// SAFETY: This wrapper owns the OS handle, and Windows job handles are valid across threads. +unsafe impl Send for AgentJob {} +// SAFETY: No operation mutates the handle through a shared reference. +unsafe impl Sync for AgentJob {} + +impl AgentJob { + pub(crate) fn attach(pid: u32) -> anyhow::Result { + // SAFETY: A private, unnamed job has no borrowed security attributes or name. + let job = Self(unsafe { CreateJobObjectW(None, None)? }); + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: The job and limits buffer are valid for this synchronous call. + unsafe { + SetInformationJobObject( + job.0, + JobObjectExtendedLimitInformation, + (&raw const limits).cast(), + u32::try_from(size_of::())?, + )? + }; + // SAFETY: The child PID came from the process just spawned by this test. + let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)? }; + // SAFETY: Both handles remain open until the assignment returns. + let assigned = unsafe { AssignProcessToJobObject(job.0, process) }; + // SAFETY: OpenProcess returned an owned handle. + unsafe { CloseHandle(process)? }; + assigned?; + Ok(job) + } +} + +impl Drop for AgentJob { + fn drop(&mut self) { + // SAFETY: Closing our only job handle kills its entire process tree. + let _ = unsafe { CloseHandle(self.0) }; + } +} + +fn wide(value: &std::ffi::OsStr) -> Vec { + value.encode_wide().chain(Some(0)).collect() +} + +fn blob(data: &[u8]) -> anyhow::Result { + Ok(CRYPT_INTEGER_BLOB { + cbData: u32::try_from(data.len())?, + pbData: data.as_ptr().cast_mut(), + }) +} + +fn protect(contents: &[u8], decrypt: bool, local_machine: bool) -> anyhow::Result> { + let input = blob(contents)?; + let entropy = blob(ENTROPY)?; + let mut output = CRYPT_INTEGER_BLOB::default(); + if decrypt { + // SAFETY: The input and entropy buffers remain live until CryptUnprotectData returns. + unsafe { CryptUnprotectData(&input, None, Some(&entropy), None, None, 0, &mut output)? }; + } else { + // SAFETY: The input and entropy buffers remain live until CryptProtectData returns. + unsafe { + CryptProtectData( + &input, + PCWSTR::null(), + Some(&entropy), + None, + None, + if local_machine { CRYPTPROTECT_LOCAL_MACHINE } else { 0 }, + &mut output, + )? + }; + } + // SAFETY: DPAPI allocated output.pbData for output.cbData bytes; LocalFree releases it. + let bytes = unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() }; + // SAFETY: The buffer is allocated by CryptProtectData or CryptUnprotectData with LocalAlloc. + unsafe { LocalFree(Some(HLOCAL(output.pbData.cast()))) }; + Ok(bytes) +} + +pub(crate) fn unprotect_pending(contents: &[u8]) -> anyhow::Result> { + protect(contents, true, false) +} + +pub(crate) fn assert_machine_scope(contents: &[u8]) -> anyhow::Result<()> { + // DPAPI's current blob header stores its scope flags at byte 40; reject unfamiliar formats. + const PROVIDER: [u8; 16] = [ + 0xd0, 0x8c, 0x9d, 0xdf, 0x01, 0x15, 0xd1, 0x11, 0x8c, 0x7a, 0x00, 0xc0, 0x4f, 0xc2, 0x97, 0xeb, + ]; + ensure!( + contents.len() >= 44 && contents[..4] == 1u32.to_le_bytes() && contents[4..20] == PROVIDER, + "unsupported DPAPI blob header" + ); + let flags = u32::from_le_bytes(contents[40..44].try_into()?); + ensure!( + flags & CRYPTPROTECT_LOCAL_MACHINE != 0, + "pending file is not machine-scoped DPAPI" + ); + Ok(()) +} + +struct SecurityDescriptor(PSECURITY_DESCRIPTOR); + +impl Drop for SecurityDescriptor { + fn drop(&mut self) { + // SAFETY: ConvertStringSecurityDescriptorToSecurityDescriptorW allocated the descriptor. + unsafe { LocalFree(Some(HLOCAL(self.0.0))) }; + } +} + +fn current_user_sid() -> anyhow::Result { + let output = std::process::Command::new("whoami") + .args(["/user", "/fo", "csv", "/nh"]) + .output() + .context("look up current user SID")?; + ensure!(output.status.success(), "could not look up current user SID"); + let text = String::from_utf8(output.stdout)?; + let sid = text + .trim() + .rsplit_once(',') + .context("missing current user SID")? + .1 + .trim() + .trim_matches('"'); + ensure!( + sid.starts_with("S-1-") && sid.chars().all(|c| c.is_ascii_digit() || c == '-' || c == 'S'), + "invalid SID" + ); + Ok(sid.to_owned()) +} + +pub(crate) fn running_as_system() -> anyhow::Result { + Ok(current_user_sid()? == "S-1-5-18") +} + +pub(crate) fn running_as_elevated_administrator() -> anyhow::Result { + const WIN_BUILTIN_ADMINISTRATORS_SID: i32 = 26; + let mut sid = [0u8; 68]; + let mut length = u32::try_from(sid.len())?; + ensure!( + // SAFETY: The SID buffer has the maximum documented SID length and length is writable. + unsafe { + CreateWellKnownSid( + WIN_BUILTIN_ADMINISTRATORS_SID, + std::ptr::null(), + sid.as_mut_ptr().cast(), + &mut length, + ) + } != 0, + "create Administrators SID: {}", + std::io::Error::last_os_error() + ); + let mut member = 0; + ensure!( + // SAFETY: A null token checks this process's effective token against the valid SID above. + unsafe { CheckTokenMembership(std::ptr::null_mut(), sid.as_ptr().cast(), &mut member) } != 0, + "check effective administrator membership: {}", + std::io::Error::last_os_error() + ); + Ok(member != 0) +} + +pub(crate) fn write_pending(path: &Path, contents: &[u8]) -> anyhow::Result<()> { + let encrypted = protect(contents, false, true)?; + write_protected_file(path, &encrypted) +} + +pub(crate) fn write_protected_file(path: &Path, contents: &[u8]) -> anyhow::Result<()> { + let current_user = current_user_sid()?; + let sddl = if current_user == "S-1-5-18" { + "O:SYD:P(A;;FA;;;SY)".to_owned() + } else { + format!("O:{current_user}D:P(A;;FA;;;SY)(A;;FA;;;{current_user})") + }; + let sddl = wide(std::ffi::OsStr::new(&sddl)); + let mut raw = PSECURITY_DESCRIPTOR::default(); + // SAFETY: sddl is NUL-terminated and raw is an output pointer owned by this function. + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR::from_raw(sddl.as_ptr()), + SDDL_REVISION_1, + &mut raw, + None, + )? + }; + let descriptor = SecurityDescriptor(raw); + let attributes = SECURITY_ATTRIBUTES { + nLength: u32::try_from(size_of::())?, + lpSecurityDescriptor: descriptor.0.0, + bInheritHandle: BOOL(0), + }; + let path = wide(path.as_os_str()); + // SAFETY: Both the NUL-terminated path and security descriptor remain live until CreateFileW returns. + let handle = unsafe { + CreateFileW( + PCWSTR::from_raw(path.as_ptr()), + GENERIC_WRITE.0, + FILE_SHARE_MODE(0), + Some(&attributes), + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + None, + )? + }; + // SAFETY: CreateFileW returned an owned handle, transferred to File for closing on drop. + let mut file = unsafe { std::fs::File::from_raw_handle(handle.0) }; + file.write_all(contents)?; + file.sync_all()?; + Ok(()) +} + +struct ProcessHandle(HANDLE); + +impl Drop for ProcessHandle { + fn drop(&mut self) { + // SAFETY: OpenProcess returned an owned handle. + let _ = unsafe { CloseHandle(self.0) }; + } +} + +fn agent_pid(pid_file: &Path) -> anyhow::Result> { + let text = match std::fs::read_to_string(pid_file) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + if text.trim().is_empty() { + return Ok(None); + } + Ok(Some(text.trim().parse().context("invalid SYSTEM agent PID file")?)) +} + +fn process_image(handle: &ProcessHandle) -> anyhow::Result { + let mut name = vec![0u16; 32_768]; + let mut length = u32::try_from(name.len())?; + ensure!( + // SAFETY: The handle is open with query permission, and name and length are writable. + unsafe { QueryFullProcessImageNameW(handle.0.0, 0, name.as_mut_ptr(), &mut length) } != 0, + "read SYSTEM agent image path: {}", + std::io::Error::last_os_error() + ); + Ok(PathBuf::from(OsString::from_wide(&name[..usize::try_from(length)?]))) +} + +fn process_running(handle: &ProcessHandle) -> anyhow::Result { + let mut code = 0; + ensure!( + // SAFETY: The process handle remains valid while Windows writes its exit code. + unsafe { GetExitCodeProcess(handle.0.0, &mut code) } != 0, + "read SYSTEM agent exit code: {}", + std::io::Error::last_os_error() + ); + Ok(code == STILL_ACTIVE) +} + +fn open_agent_process(pid: u32, agent_bin: &Path, terminate: bool) -> anyhow::Result> { + let access = if terminate { + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE | PROCESS_SYNCHRONIZE + } else { + PROCESS_QUERY_LIMITED_INFORMATION + }; + // SAFETY: The PID came from the fixture's PID file, and OpenProcess validates the requested rights. + let handle = match unsafe { OpenProcess(access, false, pid) } { + Ok(handle) => ProcessHandle(handle), + Err(error) if error.code().0 == 0x8007_0057u32.cast_signed() => return Ok(None), + Err(error) => return Err(error.into()), + }; + let actual = std::fs::canonicalize(process_image(&handle)?)?; + let expected = std::fs::canonicalize(agent_bin)?; + ensure!( + actual + .to_string_lossy() + .eq_ignore_ascii_case(&expected.to_string_lossy()), + "SYSTEM agent PID belongs to a different executable" + ); + let running = process_running(&handle)?; + Ok(Some((handle, running))) +} + +fn process_ids() -> anyhow::Result> { + let mut ids = vec![0u32; 4096]; + loop { + let mut needed = 0; + ensure!( + // SAFETY: Windows writes at most the buffer's size and reports the byte count. + unsafe { + EnumProcesses( + ids.as_mut_ptr(), + u32::try_from(ids.len() * size_of::())?, + &mut needed, + ) + } != 0, + "enumerate processes for SYSTEM agent cleanup: {}", + std::io::Error::last_os_error() + ); + let count = usize::try_from(needed)? / size_of::(); + if count < ids.len() { + ids.truncate(count); + return Ok(ids); + } + ensure!(ids.len() < 65_536, "process enumeration exceeds 65536 entries"); + ids.resize(ids.len() * 2, 0); + } +} + +pub(crate) fn case_agent_processes(agent_bin: &Path) -> anyhow::Result> { + let expected = std::fs::canonicalize(agent_bin)?; + let mut matches = Vec::new(); + for pid in process_ids()? { + if pid == 0 { + continue; + } + // SAFETY: Each PID came from Windows; unrelated inaccessible processes are skipped. + let Ok(handle) = (unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }) else { + continue; + }; + let handle = ProcessHandle(handle); + let Ok(actual) = process_image(&handle).and_then(|path| std::fs::canonicalize(path).map_err(Into::into)) else { + continue; + }; + if actual + .to_string_lossy() + .eq_ignore_ascii_case(&expected.to_string_lossy()) + && process_running(&handle)? + { + matches.push(pid); + } + } + Ok(matches) +} + +pub(crate) fn system_agent_running(pid_file: &Path, agent_bin: &Path) -> anyhow::Result> { + let Some(pid) = agent_pid(pid_file)? else { + return Ok(None); + }; + Ok(open_agent_process(pid, agent_bin, false)?.map(|(_, running)| running)) +} + +pub(crate) fn stop_system_agent(agent_bin: &Path) -> anyhow::Result<()> { + for pid in case_agent_processes(agent_bin)? { + if let Some((handle, true)) = open_agent_process(pid, agent_bin, true)? { + ensure!( + // SAFETY: The handle belongs to this fixture's uniquely copied agent executable. + unsafe { TerminateProcess(handle.0.0, 1) } != 0, + "terminate SYSTEM agent PID {pid}: {}", + std::io::Error::last_os_error() + ); + ensure!( + // SAFETY: The owned process handle remains valid until the wait completes. + unsafe { WaitForSingleObject(handle.0.0, 5_000) } == 0, + "SYSTEM agent PID {pid} did not exit within five seconds" + ); + } + } + ensure!( + case_agent_processes(agent_bin)?.is_empty(), + "fixture agent executable still has a running process" + ); + Ok(()) +} + +pub(crate) fn pending_acl_is_protected(path: &Path) -> anyhow::Result<()> { + let path = wide(path.as_os_str()); + let mut raw = PSECURITY_DESCRIPTOR::default(); + // SAFETY: path is NUL-terminated and raw receives an allocated security descriptor. + let result = unsafe { + GetNamedSecurityInfoW( + PCWSTR::from_raw(path.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + None, + None, + &mut raw, + ) + }; + ensure!(result.0 == 0, "could not read pending-file DACL ({})", result.0); + let descriptor = SecurityDescriptor(raw); + let acl = protected_dacl_sddl(descriptor.0)?; + let user_sid = canonical_sddl_sid(¤t_user_sid()?)?; + let expected_aces = if user_sid == "SY" { + "(A;;FA;;;SY)".to_owned() + } else { + format!("(A;;FA;;;SY)(A;;FA;;;{user_sid})") + }; + ensure!( + has_only_required_aces(&acl, &user_sid), + "pending-file DACL grants access beyond SYSTEM and the current user: actual SDDL {acl}; expected ACEs {expected_aces}" + ); + Ok(()) +} + +fn protected_dacl_sddl(descriptor: PSECURITY_DESCRIPTOR) -> anyhow::Result { + let mut control = 0; + let mut revision = 0; + // SAFETY: The security descriptor remains live until the function returns. + unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision)? }; + ensure!( + control & SE_DACL_PROTECTED.0 != 0, + "security descriptor DACL is not protected" + ); + dacl_sddl(descriptor) +} + +fn dacl_sddl(descriptor: PSECURITY_DESCRIPTOR) -> anyhow::Result { + let mut sddl = PWSTR::null(); + // SAFETY: The descriptor is valid and the output string is owned until freed with LocalFree. + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut sddl, + None, + )? + }; + // SAFETY: The conversion returned a NUL-terminated string allocated for the caller. + let acl = unsafe { sddl.to_string() }; + // SAFETY: LocalFree releases the SDDL string allocated by the conversion. + unsafe { LocalFree(Some(HLOCAL(sddl.0.cast()))) }; + acl.map_err(Into::into) +} + +/// Returns the SID as Windows writes it in SDDL, which uses aliases for some accounts +/// (for example `LA` for the RID 500 administrator that GitHub-hosted runners use). +fn canonical_sddl_sid(sid: &str) -> anyhow::Result { + let sddl = wide(std::ffi::OsStr::new(&format!("D:(A;;FA;;;{sid})"))); + let mut raw = PSECURITY_DESCRIPTOR::default(); + // SAFETY: sddl is NUL-terminated and raw is an output pointer owned by this function. + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR::from_raw(sddl.as_ptr()), + SDDL_REVISION_1, + &mut raw, + None, + )? + }; + let descriptor = SecurityDescriptor(raw); + let mut out = PWSTR::null(); + // SAFETY: The descriptor is valid and the output string is owned until freed with LocalFree. + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor.0, + SDDL_REVISION_1, + DACL_SECURITY_INFORMATION, + &mut out, + None, + )? + }; + // SAFETY: The conversion returned a NUL-terminated string allocated for the caller. + let converted = unsafe { out.to_string() }; + // SAFETY: LocalFree releases the SDDL string allocated by the conversion. + unsafe { LocalFree(Some(HLOCAL(out.0.cast()))) }; + let converted = converted?; + converted + .strip_prefix("D:(A;;FA;;;") + .and_then(|rest| rest.strip_suffix(')')) + .map(ToOwned::to_owned) + .with_context(|| format!("unexpected SDDL round-trip for {sid}: {converted}")) +} + +fn has_only_required_aces(sddl: &str, user_sid: &str) -> bool { + if !sddl.starts_with("D:") { + return false; + } + let Some(start) = sddl.find('(') else { + return false; + }; + let mut rest = &sddl[start..]; + let mut aces = Vec::new(); + while let Some(after_open) = rest.strip_prefix('(') { + let Some(close) = after_open.find(')') else { + return false; + }; + aces.push(&after_open[..close]); + rest = &after_open[close + 1..]; + } + if !rest.is_empty() { + return false; + } + aces.sort_unstable(); + let user_ace = format!("A;;FA;;;{user_sid}"); + let mut expected = vec!["A;;FA;;;SY"]; + if user_sid != "SY" { + expected.push(&user_ace); + } + expected.sort_unstable(); + aces == expected +} + +fn has_only_key_principals(sddl: &str, user_sid: &str) -> bool { + if !sddl.starts_with("D:") { + return false; + } + let Some(start) = sddl.find('(') else { + return false; + }; + let mut rest = &sddl[start..]; + let mut system = false; + let mut user = false; + while let Some(after_open) = rest.strip_prefix('(') { + let Some(close) = after_open.find(')') else { + return false; + }; + let fields = after_open[..close].split(';').collect::>(); + if fields.len() != 6 || fields[0] != "A" || fields[2].is_empty() { + return false; + } + match fields[5] { + "SY" => system = true, + sid if sid == user_sid => user = true, + _ => return false, + } + rest = &after_open[close + 1..]; + } + rest.is_empty() && system && (user || user_sid == "SY") +} + +struct Provider(NCRYPT_PROV_HANDLE); + +impl Provider { + fn open() -> anyhow::Result { + let mut handle = NCRYPT_PROV_HANDLE::default(); + // SAFETY: handle is writable and the provider name is a static NUL-terminated string. + unsafe { NCryptOpenStorageProvider(&mut handle, MS_KEY_STORAGE_PROVIDER, 0)? }; + Ok(Self(handle)) + } + + fn key(&self, name: &str) -> anyhow::Result> { + let mut handle = NCRYPT_KEY_HANDLE::default(); + let name = wide(std::ffi::OsStr::new(name)); + // SAFETY: The key name is NUL-terminated and handle is writable. + let result = unsafe { + NCryptOpenKey( + self.0, + &mut handle, + PCWSTR::from_raw(name.as_ptr()), + CERT_KEY_SPEC(0), + NCRYPT_MACHINE_KEY_FLAG, + ) + }; + match result { + Ok(()) => Ok(Some(Key(handle))), + Err(error) if matches!(error.code(), NTE_BAD_KEYSET | NTE_NOT_FOUND) => Ok(None), + Err(error) => Err(error.into()), + } + } +} + +impl Drop for Provider { + fn drop(&mut self) { + // SAFETY: The provider handle belongs to this wrapper. + let _ = unsafe { NCryptFreeObject(NCRYPT_HANDLE(self.0.0)) }; + } +} + +struct Key(NCRYPT_KEY_HANDLE); + +impl Drop for Key { + fn drop(&mut self) { + // SAFETY: The opened key handle belongs to this wrapper. + let _ = unsafe { NCryptFreeObject(NCRYPT_HANDLE(self.0.0)) }; + } +} + +pub(crate) fn machine_key_exists(name: &str) -> anyhow::Result { + let provider = Provider::open()?; + Ok(provider.key(name)?.is_some()) +} + +fn belongs_to_run(name: &str, prefix: &str) -> bool { + crate::agent::key_belongs_to_run(name, prefix) +} + +pub(crate) fn machine_keys_with_prefix(prefix: &str) -> anyhow::Result> { + ensure!(!prefix.is_empty(), "machine key run prefix is empty"); + struct EnumState(*mut c_void); + impl Drop for EnumState { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: NCryptEnumKeys allocated the enumeration state for this provider. + let _ = unsafe { NCryptFreeBuffer(self.0) }; + } + } + } + struct KeyName(*mut NCryptKeyName); + impl Drop for KeyName { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: NCryptEnumKeys allocated the returned key-name buffer. + let _ = unsafe { NCryptFreeBuffer(self.0.cast()) }; + } + } + } + + let provider = Provider::open()?; + let mut state = EnumState(std::ptr::null_mut()); + let mut names = HashSet::new(); + loop { + let mut key = KeyName(std::ptr::null_mut()); + // SAFETY: Both output pointers are writable, and NCryptFreeBuffer releases their allocations. + let result = unsafe { + NCryptEnumKeys( + provider.0, + PCWSTR::null(), + &mut key.0, + &mut state.0, + NCRYPT_MACHINE_KEY_FLAG, + ) + }; + match result { + Ok(()) => { + ensure!(!key.0.is_null(), "NCryptEnumKeys returned no key name"); + // SAFETY: NCryptEnumKeys returned a valid key-name structure. + let pointer = unsafe { (*key.0).pszName }; + // SAFETY: The key-name pointer is NUL-terminated and lives until the buffer is freed. + let name = unsafe { pointer.to_string()? }; + if belongs_to_run(&name, prefix) { + names.insert(name); + } + } + Err(error) if error.code() == NTE_NO_MORE_ITEMS => break, + Err(error) => return Err(error.into()), + } + } + Ok(names) +} + +pub(crate) fn assert_non_exportable(name: &str) -> anyhow::Result<()> { + let provider = Provider::open()?; + let key = provider + .key(name)? + .context("machine key not found in Microsoft Software KSP")?; + let mut policy = [0u8; 4]; + let mut used = 0; + // SAFETY: NCryptGetProperty writes at most four bytes into policy and reports the count in used. + unsafe { + NCryptGetProperty( + NCRYPT_HANDLE(key.0.0), + NCRYPT_EXPORT_POLICY_PROPERTY, + Some(&mut policy), + &mut used, + OBJECT_SECURITY_INFORMATION(0), + )? + }; + ensure!( + used == 4 && u32::from_le_bytes(policy) == 0, + "machine key permits export" + ); + let mut buffer = [0u8; 4096]; + // SAFETY: The buffer is writable and the blob type is a NUL-terminated static string. + let exported = unsafe { + NCryptExportKey( + key.0, + None, + NCRYPT_PKCS8_PRIVATE_KEY_BLOB, + None, + Some(&mut buffer), + &mut used, + NCRYPT_FLAGS(0), + ) + }; + buffer.fill(0); + ensure!(exported.is_err(), "NCryptExportKey exported a private key"); + Ok(()) +} + +pub(crate) fn assert_machine_key_acl(name: &str) -> anyhow::Result<()> { + let provider = Provider::open()?; + let key = provider.key(name)?.context("machine key not found for ACL check")?; + let mut needed = 0; + // SAFETY: A null output buffer asks NCrypt for the security descriptor length. + let _ = unsafe { + NCryptGetProperty( + NCRYPT_HANDLE(key.0.0), + NCRYPT_SECURITY_DESCR_PROPERTY, + None, + &mut needed, + DACL_SECURITY_INFORMATION, + ) + }; + ensure!(needed > 0, "machine key has no readable security descriptor"); + let mut bytes = vec![0u8; usize::try_from(needed)?]; + // SAFETY: The writable buffer is at least the size NCrypt reported. + unsafe { + NCryptGetProperty( + NCRYPT_HANDLE(key.0.0), + NCRYPT_SECURITY_DESCR_PROPERTY, + Some(&mut bytes), + &mut needed, + DACL_SECURITY_INFORMATION, + )? + }; + let acl = dacl_sddl(PSECURITY_DESCRIPTOR(bytes.as_mut_ptr().cast()))?; + let user_sid = canonical_sddl_sid(¤t_user_sid()?)?; + let expected = if user_sid == "SY" { + "SYSTEM".to_owned() + } else { + format!("SYSTEM and current user {user_sid}") + }; + ensure!( + has_only_key_principals(&acl, &user_sid), + "machine-key DACL must grant only {expected}: actual SDDL {acl}" + ); + Ok(()) +} + +pub(crate) fn machine_public_key(name: &str) -> anyhow::Result> { + let provider = Provider::open()?; + let key = provider + .key(name)? + .context("machine key not found for public-key check")?; + let mut needed = 0; + // SAFETY: A null output buffer asks NCrypt for the public blob length. + let _ = unsafe { + NCryptExportKey( + key.0, + None, + BCRYPT_ECCPUBLIC_BLOB, + None, + None, + &mut needed, + NCRYPT_FLAGS(0), + ) + }; + ensure!(needed >= 72, "machine key has no P-256 public blob"); + let mut bytes = vec![0u8; usize::try_from(needed)?]; + // SAFETY: The buffer has the length NCrypt requested for the public blob. + unsafe { + NCryptExportKey( + key.0, + None, + BCRYPT_ECCPUBLIC_BLOB, + None, + Some(&mut bytes), + &mut needed, + NCRYPT_FLAGS(0), + )? + }; + let magic = u32::from_le_bytes(bytes[0..4].try_into()?); + let coordinate_len = u32::from_le_bytes(bytes[4..8].try_into()?); + ensure!( + magic == BCRYPT_ECDSA_PUBLIC_P256_MAGIC && coordinate_len == 32 && needed == 72, + "machine key is not an ECDSA P-256 public key" + ); + let mut sec1 = Vec::with_capacity(65); + sec1.push(4); + sec1.extend_from_slice(&bytes[8..72]); + Ok(sec1) +} + +pub(crate) fn cleanup_machine_keys(prefix: &str) -> anyhow::Result<()> { + let names = machine_keys_with_prefix(prefix)?; + if names.is_empty() { + return Ok(()); + } + let provider = Provider::open()?; + for name in names { + let key = provider + .key(&name)? + .with_context(|| format!("run-owned machine key {name} disappeared before cleanup"))?; + // SAFETY: The machine key handle was opened for this provider; on success NCryptDeleteKey frees it. + unsafe { NCryptDeleteKey(key.0, 0) }.with_context(|| format!("delete run-owned machine key {name}"))?; + std::mem::forget(key); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use windows::Win32::Security::OWNER_SECURITY_INFORMATION; + + use super::*; + + struct OwnedChild(std::process::Child); + + impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + #[test] + #[ignore = "spawned by the missing-PID cleanup test"] + fn process_sentinel() { + // `system_agent_cleanup_finds_process_without_published_pid` needs this sentinel to catch its fault, + // so the sentinel carries that test's marker. + // oracle: mutant SYSTEM agent cleanup terminates none of the processes running the case agent's image + if std::env::var_os("AGENT_IDENTITY_PROCESS_SENTINEL").is_some() { + std::thread::sleep(std::time::Duration::from_secs(30)); + } + } + + #[test] + fn system_agent_cleanup_finds_process_without_published_pid() -> anyhow::Result<()> { + // oracle: mutant SYSTEM agent cleanup terminates none of the processes running the case agent's image + let scratch = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join("agent-identity-windows-tests"); + std::fs::create_dir_all(&scratch)?; + let dir = tempfile::Builder::new().prefix("system-process-").tempdir_in(scratch)?; + let image = dir.path().join("case-agent.exe"); + std::fs::copy(std::env::current_exe()?, &image)?; + let pid_file = dir.path().join("system-agent.pid"); + std::fs::write(&pid_file, b"")?; + let mut child = OwnedChild( + std::process::Command::new(&image) + .args(["--ignored", "--exact", "windows::tests::process_sentinel"]) + .env("AGENT_IDENTITY_PROCESS_SENTINEL", "1") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn()?, + ); + let started = std::time::Instant::now(); + loop { + if case_agent_processes(&image)?.contains(&child.0.id()) { + break; + } + ensure!( + child.0.try_wait()?.is_none(), + "process sentinel exited before the cleanup test could observe it" + ); + ensure!( + started.elapsed() < std::time::Duration::from_secs(5), + "process sentinel did not become visible" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + ensure!(agent_pid(&pid_file)?.is_none(), "PID file must still be empty"); + stop_system_agent(&image)?; + ensure!( + child.0.wait()?.code() == Some(1) && case_agent_processes(&image)?.is_empty(), + "case agent remained alive after image-based cleanup" + ); + Ok(()) + } + + #[test] + fn pending_file_dpapi_and_protected_acl() -> anyhow::Result<()> { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + let scratch = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join("agent-identity-windows-tests"); + std::fs::create_dir_all(&scratch)?; + let dir = tempfile::Builder::new().prefix("pending-").tempdir_in(scratch)?; + let path = dir.path().join("pending-enrollment.dat"); + let json = br#"{"version":1,"token":"test-only"}"#; + write_pending(&path, json)?; + pending_acl_is_protected(&path)?; + let path_wide = wide(path.as_os_str()); + let mut raw = PSECURITY_DESCRIPTOR::default(); + // SAFETY: path_wide is NUL-terminated and raw receives an allocated security descriptor. + let result = unsafe { + GetNamedSecurityInfoW( + PCWSTR::from_raw(path_wide.as_ptr()), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + None, + None, + None, + None, + &mut raw, + ) + }; + ensure!(result.0 == 0, "could not read pending-file owner ({})", result.0); + let descriptor = SecurityDescriptor(raw); + let mut sddl = PWSTR::null(); + // SAFETY: The descriptor is valid and the output string is owned until freed with LocalFree. + unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor.0, + SDDL_REVISION_1, + OWNER_SECURITY_INFORMATION, + &mut sddl, + None, + )? + }; + // SAFETY: The conversion returned a NUL-terminated string allocated for the caller. + let owner = unsafe { sddl.to_string() }; + // SAFETY: LocalFree releases the SDDL string allocated by the conversion. + unsafe { LocalFree(Some(HLOCAL(sddl.0.cast()))) }; + assert_eq!(owner?, format!("O:{}", canonical_sddl_sid(¤t_user_sid()?)?)); + let encrypted = std::fs::read(&path)?; + assert_machine_scope(&encrypted)?; + ensure!( + unprotect_pending(&encrypted)? == json, + "DPAPI pending file did not round-trip" + ); + assert!(assert_machine_scope(&protect(json, false, false)?).is_err()); + Ok(()) + } + + #[test] + fn pending_acl_rejects_extra_allow_ace() { + // oracle: intent crates/agent-identity/INTENT.md §Pending-enrollment files + let sid = "S-1-5-21-42"; + assert!(has_only_required_aces("D:P(A;;FA;;;SY)(A;;FA;;;S-1-5-21-42)", sid)); + assert!(has_only_required_aces("D:P(A;;FA;;;SY)(A;;FA;;;LA)", "LA")); + assert!(has_only_required_aces("D:P(A;;FA;;;SY)", "SY")); + assert!(!has_only_required_aces( + "D:P(A;;FA;;;SY)(A;;FA;;;S-1-5-21-42)(A;;FA;;;WD)", + sid + )); + } + + #[test] + fn machine_key_acl_checks_principals_not_rights_or_ace_order() { + // oracle: intent crates/device-key/INTENT.md §Access + let sid = "S-1-5-21-42"; + assert!(has_only_key_principals( + "D:PAI(A;;0x10000;;;S-1-5-21-42)(A;;GR;;;SY)", + sid + )); + assert!(!has_only_key_principals("D:(A;;GR;;;SY)", sid)); + assert!(has_only_key_principals("D:(A;;GR;;;SY)", "SY")); + assert!(!has_only_key_principals("D:(A;;GR;;;SY)(A;;GR;;;S-1-5-21-42)", "SY")); + assert!(!has_only_key_principals("D:(A;;GR;;;S-1-5-21-42)", sid)); + assert!(!has_only_key_principals( + "D:(A;;GR;;;SY)(A;;GR;;;S-1-5-21-42)(A;;GR;;;WD)", + sid + )); + } + + #[test] + fn canonical_sddl_sid_uses_windows_aliases() -> anyhow::Result<()> { + // oracle: spec MS-DTYP §2.4.2.4 + // oracle: spec MS-DTYP §2.5.1.1 + assert_eq!(canonical_sddl_sid("S-1-5-18")?, "SY"); + assert_eq!(canonical_sddl_sid("S-1-5-32-544")?, "BA"); + let user = current_user_sid()?; + let canonical = canonical_sddl_sid(&user)?; + assert!(canonical == user || canonical.len() == 2, "{canonical}"); + assert_eq!(canonical_sddl_sid(&canonical)?, canonical); + Ok(()) + } + + #[test] + fn machine_key_cleanup_matches_only_the_run_prefix() { + // oracle: intent crates/device-key/INTENT.md §Operations + // oracle: intent devolutions-agent/src/identity/INTENT.md §Configuration + let prefix = format!("conformance-{}", uuid::Uuid::new_v4().simple()); + let own = format!("{prefix}_{}", uuid::Uuid::new_v4()); + let other_run = format!("conformance-{}", uuid::Uuid::new_v4().simple()); + assert!(belongs_to_run(&own, &prefix)); + assert!(!belongs_to_run( + &format!("{other_run}_{}", uuid::Uuid::new_v4()), + &prefix + )); + assert!(!belongs_to_run( + &format!("devolutions-agent-identity_{}", uuid::Uuid::new_v4()), + &prefix + )); + assert!(!belongs_to_run( + &format!("{prefix}-other_{}", uuid::Uuid::new_v4()), + &prefix + )); + assert!(!belongs_to_run(&format!("{prefix}{}", uuid::Uuid::new_v4()), &prefix)); + } +} diff --git a/crates/agent-identity-httpsig/Cargo.toml b/crates/agent-identity-httpsig/Cargo.toml new file mode 100644 index 000000000..e0699da6a --- /dev/null +++ b/crates/agent-identity-httpsig/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "agent-identity-httpsig" +version = "0.0.0" +authors = ["Devolutions Inc. "] +edition = "2024" +description = "HTTP request signing for Agent Identity" +publish = false + +[lints] +workspace = true + +[dependencies] +device-key = { path = "../device-key" } +anyhow = "1" +base64 = "0.22" +http = "1" +httpsig = { version = "=0.0.24", default-features = false } +rand = "0.10" +sfv = "0.14" +sha2 = "0.11" +tokio = { version = "1", features = ["sync"] } +tower = "0.5" +url = "2" + +[dev-dependencies] +p256 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] } +serde_json = "1" +signature = "3" +tokio = { version = "1", features = ["macros", "rt", "sync"] } +tower = { version = "0.5", features = ["util"] } diff --git a/crates/agent-identity-httpsig/INTENT.agent-identity.md b/crates/agent-identity-httpsig/INTENT.agent-identity.md new file mode 120000 index 000000000..56d20dc7a --- /dev/null +++ b/crates/agent-identity-httpsig/INTENT.agent-identity.md @@ -0,0 +1 @@ +../../docs/agent-identity/INTENT.agent-identity.md \ No newline at end of file diff --git a/crates/agent-identity-httpsig/INTENT.md b/crates/agent-identity-httpsig/INTENT.md new file mode 100644 index 000000000..88289ee0e --- /dev/null +++ b/crates/agent-identity-httpsig/INTENT.md @@ -0,0 +1,10 @@ +This crate implements, on the agent side, the RFC 9421 profile of [CONTRACT.agent-identity.md](../../docs/agent-identity/CONTRACT.agent-identity.md). + +## Implementation decisions + +- Built on the `httpsig` crate: a local adapter exposes a device key through its `SigningKey` trait, so that `device-key` stays independent of the request-authentication mechanism. +- This crate owns the Agent Identity profile's inputs (covered components, parameters, tag, nonce) and the `Content-Digest` field; `httpsig` builds the signature base and serializes the `Signature-Input` and `Signature` fields, so the crate contains no RFC 9421 serializer of its own. +- It contains no verifier: verifying signatures is the authority's job, and the agent never parses signature headers. +- Every request signature, for a REST request or a gRPC stream opening, is built by `httpsig` through `RequestSigner`; the channel proof is not a request signature. +- A tower layer applies it to the gRPC stream opening and prefixes gRPC paths with the base URL's path when there is one (e.g. an IIS virtual directory), because a tonic `Interceptor` does not see the request path. + REST requests call `RequestSigner` directly, because the agent's HTTP client has no tower stack. diff --git a/crates/agent-identity-httpsig/clippy.toml b/crates/agent-identity-httpsig/clippy.toml new file mode 100644 index 000000000..286c23348 --- /dev/null +++ b/crates/agent-identity-httpsig/clippy.toml @@ -0,0 +1,9 @@ +semicolon-outside-block-ignore-multiline = true +accept-comment-above-statement = true +accept-comment-above-attributes = true + +# Device-key signing can fail (for example in the Windows software provider); sign would panic, so callers use try_sign. +disallowed-methods = [ + { path = "signature::Signer::sign", reason = "use try_sign: device key signing is fallible" }, + { path = "libc::renameat2", reason = "needs glibc 2.28; the Linux release baseline is glibc 2.27, so call the renameat2 syscall directly", allow-invalid = true }, +] diff --git a/crates/agent-identity-httpsig/src/lib.rs b/crates/agent-identity-httpsig/src/lib.rs new file mode 100644 index 000000000..973a81990 --- /dev/null +++ b/crates/agent-identity-httpsig/src/lib.rs @@ -0,0 +1,317 @@ +use std::error::Error; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context as _, anyhow, ensure}; +use base64::Engine as _; +use base64::engine::general_purpose; +use device_key::DeviceKey; +use http::uri::PathAndQuery; +use http::{HeaderValue, Method, Request, Uri}; +use httpsig::prelude::message_component::{HttpMessageComponent, HttpMessageComponentId}; +use httpsig::prelude::{ + AlgorithmName, HttpSigError, HttpSigResult, HttpSignatureBase, HttpSignatureParams, SigningKey, +}; +use rand::RngExt as _; +use sha2::{Digest as _, Sha256}; +use tokio::sync::oneshot; +use tower::{Layer, Service}; + +const SIGNATURE_DURATION_SECS: u64 = 60; +const MAX_STRUCTURED_FIELD_INTEGER: u64 = 999_999_999_999_999; +const CONNECT_RPC_PATH: &str = "/devolutions.agent.channel.v1.AgentChannel/Connect"; +type BoxError = Box; + +/// The Agent Identity operation covered by a request signature. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Tag { + Renew, + Connect, + Confirm, + CheckIn, +} + +impl Tag { + pub const fn as_str(self) -> &'static str { + match self { + Self::Renew => "renew", + Self::Connect => "connect", + Self::Confirm => "confirm", + Self::CheckIn => "check-in", + } + } + + const fn covers_body(self) -> bool { + matches!(self, Self::Renew | Self::CheckIn) + } +} + +/// Signs Agent Identity requests with a device key and its leaf certificate's thumbprint. +#[derive(Clone)] +pub struct RequestSigner { + key: Arc, + keyid: String, +} + +impl RequestSigner { + pub fn new(key: Arc, leaf_certificate_der: &[u8]) -> Self { + let keyid = general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(leaf_certificate_der)); + Self { key, keyid } + } + + /// Signs one request with a fresh 16-byte nonce and the supplied Unix timestamp. + /// + /// Renew and check-in require the exact body bytes; connect and confirm do not cover a body. + pub fn sign(&self, tag: Tag, method: &Method, body: Option<&[u8]>, created: u64) -> anyhow::Result { + let nonce = rand::rng().random::<[u8; 16]>(); + self.sign_with_nonce(tag, method, body, created, &nonce) + } + + fn sign_with_nonce( + &self, + tag: Tag, + method: &Method, + body: Option<&[u8]>, + created: u64, + nonce: &[u8; 16], + ) -> anyhow::Result { + let content_digest = if tag.covers_body() { + let body = body.ok_or_else(|| anyhow!("missing signed request body"))?; + Some(content_digest(body)?) + } else { + None + }; + let nonce = general_purpose::URL_SAFE_NO_PAD.encode(nonce); + let base = self.signature_base(tag, method, content_digest.as_ref(), created, &nonce)?; + let signing_key = DeviceSigningKey(self.key.as_ref(), &self.keyid); + let headers = base + .build_signature_headers(&signing_key, Some("sig")) + .context("sign identity request")?; + + Ok(SignedHeaders { + signature_input: HeaderValue::from_str(&headers.signature_input_header_value()) + .context("build signature input header")?, + signature: HeaderValue::from_str(&headers.signature_header_value()).context("build signature header")?, + content_digest, + nonce, + }) + } + + fn signature_base( + &self, + tag: Tag, + method: &Method, + content_digest: Option<&HeaderValue>, + created: u64, + nonce: &str, + ) -> anyhow::Result { + let expires = created + .checked_add(SIGNATURE_DURATION_SECS) + .ok_or_else(|| anyhow!("signature expiration overflow"))?; + ensure!( + expires <= MAX_STRUCTURED_FIELD_INTEGER, + "signature timestamp exceeds structured-field range" + ); + + let method_id = HttpMessageComponentId::try_from("@method")?; + let method_values = [method.as_str().to_owned()]; + let method_component = HttpMessageComponent::try_from((&method_id, method_values.as_slice()))?; + let mut components = vec![method_component]; + + if let Some(digest) = content_digest { + let digest_id = HttpMessageComponentId::try_from("content-digest")?; + let digest_values = [digest.to_str()?.to_owned()]; + components.push(HttpMessageComponent::try_from((&digest_id, digest_values.as_slice()))?); + } + + let covered_components = components + .iter() + .map(|component| component.id.clone()) + .collect::>(); + let mut params = HttpSignatureParams::try_new(&covered_components)?; + let signing_key = DeviceSigningKey(self.key.as_ref(), &self.keyid); + params + .set_created(created) + .set_expires(expires) + .set_nonce(nonce) + .set_key_info(&signing_key) + .set_tag(tag.as_str()); + Ok(HttpSignatureBase::try_new(&components, ¶ms)?) + } +} + +struct DeviceSigningKey<'a>(&'a dyn DeviceKey, &'a str); + +impl SigningKey for DeviceSigningKey<'_> { + fn sign(&self, data: &[u8]) -> HttpSigResult> { + let signature = self + .0 + .try_sign(data) + .map_err(|_| HttpSigError::InvalidSignature("device key signing failed".to_owned()))?; + Ok(signature.to_bytes().to_vec()) + } + + fn key_id(&self) -> String { + self.1.to_owned() + } + + fn alg(&self) -> AlgorithmName { + AlgorithmName::EcdsaP256Sha256 + } +} + +fn content_digest(body: &[u8]) -> anyhow::Result { + let digest = Sha256::digest(body); + let digest_bytes: &[u8] = digest.as_ref(); + let mut serializer = sfv::DictSerializer::new(); + let _ = serializer.bare_item( + sfv::KeyRef::constant("sha-256"), + sfv::RefBareItem::ByteSequence(digest_bytes), + ); + let value = serializer.finish().ok_or_else(|| anyhow!("empty content digest"))?; + Ok(HeaderValue::from_str(&value)?) +} + +/// HTTP headers returned by [`RequestSigner::sign`]. +pub struct SignedHeaders { + pub signature_input: HeaderValue, + pub signature: HeaderValue, + pub content_digest: Option, + /// The exact serialized nonce signed in `signature_input`, for channel proofs. + pub nonce: String, +} + +/// Carries one signed opening nonce from the channel layer back to its caller. +#[derive(Clone)] +pub struct OpeningNonce(Arc>>>); + +impl OpeningNonce { + /// Attach the sender to a tonic `Connect` request's extensions and await the receiver for `Hello.proof`. + pub fn new() -> (Self, oneshot::Receiver) { + let (sender, receiver) = oneshot::channel(); + (Self(Arc::new(Mutex::new(Some(sender)))), receiver) + } + + fn send(self, nonce: String) -> anyhow::Result<()> { + let sender = self + .0 + .lock() + .map_err(|_| anyhow!("channel nonce handoff poisoned"))? + .take() + .ok_or_else(|| anyhow!("channel nonce handoff already used"))?; + sender + .send(nonce) + .map_err(|_| anyhow!("channel nonce receiver dropped")) + } +} + +/// Signs each gRPC stream opening and prepends the channel URL's path. +#[derive(Clone)] +pub struct ChannelLayer { + signer: RequestSigner, + path_prefix: String, + clock_offset: i64, +} + +impl ChannelLayer { + pub fn new(signer: RequestSigner, base_url: &url::Url) -> Self { + Self { + signer, + path_prefix: base_url.path().trim_end_matches('/').to_owned(), + clock_offset: 0, + } + } + + /// Adjusts signing time by `server_time - local_time` for one clock-skew retry. + #[must_use] + pub fn with_clock_offset(mut self, seconds: i64) -> Self { + self.clock_offset = seconds; + self + } +} + +impl Layer for ChannelLayer { + type Service = ChannelService; + + fn layer(&self, inner: S) -> Self::Service { + ChannelService { + inner, + signer: self.signer.clone(), + path_prefix: self.path_prefix.clone(), + clock_offset: self.clock_offset, + } + } +} + +/// The request-signing service returned by [`ChannelLayer`]. +#[derive(Clone)] +pub struct ChannelService { + inner: S, + signer: RequestSigner, + path_prefix: String, + clock_offset: i64, +} + +impl Service> for ChannelService +where + S: Service>, + S::Error: Into, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = BoxError; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx).map(|result| result.map_err(Into::into)) + } + + fn call(&mut self, mut request: Request) -> Self::Future { + let signed = (|| -> anyhow::Result<()> { + prefix_path(request.uri_mut(), &self.path_prefix)?; + let nonce_sender = request + .extensions_mut() + .remove::() + .ok_or_else(|| anyhow!("missing channel nonce receiver"))?; + let local_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("read signing clock")?; + let created = local_time + .as_secs() + .checked_add_signed(self.clock_offset) + .ok_or_else(|| anyhow!("signed request timestamp out of range"))?; + let headers = self.signer.sign(Tag::Connect, request.method(), None, created)?; + request.headers_mut().insert("signature-input", headers.signature_input); + request.headers_mut().insert("signature", headers.signature); + nonce_sender.send(headers.nonce)?; + Ok(()) + })(); + + match signed { + Ok(()) => { + let future = self.inner.call(request); + Box::pin(async move { future.await.map_err(Into::into) }) + } + Err(error) => Box::pin(async move { Err(error.into()) }), + } + } +} + +fn prefix_path(uri: &mut Uri, prefix: &str) -> anyhow::Result<()> { + if prefix.is_empty() || uri.path().strip_suffix(CONNECT_RPC_PATH) == Some(prefix) { + return Ok(()); + } + + let mut parts = uri.clone().into_parts(); + let path_and_query = parts.path_and_query.as_ref().map_or("/", PathAndQuery::as_str); + let separator = if path_and_query.starts_with('/') { "" } else { "/" }; + parts.path_and_query = Some(format!("{prefix}{separator}{path_and_query}").parse()?); + *uri = Uri::from_parts(parts)?; + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/agent-identity-httpsig/src/tests.rs b/crates/agent-identity-httpsig/src/tests.rs new file mode 100644 index 000000000..f4b90b0db --- /dev/null +++ b/crates/agent-identity-httpsig/src/tests.rs @@ -0,0 +1,512 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use p256::ecdsa::{Signature, SigningKey as P256SigningKey, VerifyingKey}; +use p256::pkcs8::{DecodePrivateKey as _, DecodePublicKey as _}; +use serde_json::Value; +use signature::Verifier as _; +use tower::ServiceExt as _; + +use super::*; + +struct TestKey { + name: String, + key: P256SigningKey, + fail_signing: bool, +} + +impl DeviceKey for TestKey { + fn name(&self) -> &str { + &self.name + } + + fn public_key(&self) -> VerifyingKey { + *self.key.verifying_key() + } + + fn try_sign(&self, message: &[u8]) -> signature::Result { + if self.fail_signing { + return Err(signature::Error::new()); + } + signature::Signer::::try_sign(&self.key, message) + } +} + +fn vectors() -> anyhow::Result { + Ok(serde_json::from_str(include_str!( + "../../../docs/agent-identity/test-vectors.json" + ))?) +} + +fn field<'a>(value: &'a Value, name: &str) -> anyhow::Result<&'a str> { + value + .get(name) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("missing vector field {name}")) +} + +fn vector_signer(vectors: &Value, name: &str) -> anyhow::Result<(RequestSigner, VerifyingKey)> { + let vector = vectors["keys"] + .as_array() + .and_then(|keys| keys.iter().find(|key| key["name"] == name)) + .ok_or_else(|| anyhow!("missing vector key {name}"))?; + let private_key = general_purpose::STANDARD.decode(field(vector, "private_key_pkcs8")?)?; + let key = P256SigningKey::from_pkcs8_der(&private_key).context("decode vector device key")?; + let public_key = *key.verifying_key(); + let certificate = general_purpose::STANDARD.decode(field(vector, "certificate")?)?; + let signer = RequestSigner::new( + Arc::new(TestKey { + name: name.to_owned(), + key, + fail_signing: false, + }), + &certificate, + ); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + assert_eq!(signer.keyid, field(vector, "thumbprint")?); + Ok((signer, public_key)) +} + +#[test] +fn signs_all_four_profile_tags_exactly_as_vectored() -> anyhow::Result<()> { + struct Case { + name: &'static str, + tag: Tag, + key: &'static str, + nonce_start: u8, + } + + let cases = [ + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + Case { + name: "valid_renew_alg_before_keyid", + tag: Tag::Renew, + key: "device-a", + nonce_start: 144, + }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + Case { + name: "valid_connect_alg_before_keyid", + tag: Tag::Connect, + key: "device-a", + nonce_start: 160, + }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + Case { + name: "valid_confirm_alg_before_keyid", + tag: Tag::Confirm, + key: "device-g", + nonce_start: 176, + }, + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + Case { + name: "valid_check_in_alg_before_keyid", + tag: Tag::CheckIn, + key: "device-a", + nonce_start: 192, + }, + ]; + + let vectors = vectors()?; + for case in cases { + let vector = vectors["http_signature"]["cases"] + .as_array() + .and_then(|cases| cases.iter().find(|vector| vector["name"] == case.name)) + .ok_or_else(|| anyhow!("missing vector case {}", case.name))?; + let (signer, public_key) = vector_signer(&vectors, case.key)?; + let created = vector["now"] + .as_u64() + .ok_or_else(|| anyhow!("missing vector timestamp"))?; + let nonce = std::array::from_fn(|index| case.nonce_start + u8::try_from(index).expect("16 nonce bytes")); + let body = vector["body"].as_str().map(str::as_bytes); + let headers = signer.sign_with_nonce(case.tag, &Method::POST, body, created, &nonce)?; + assert_eq!( + headers.nonce, + general_purpose::URL_SAFE_NO_PAD.encode(nonce), + "{}", + case.name + ); + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Conformance + assert_eq!( + headers.signature_input.to_str()?, + field(&vector["headers"], "signature-input")?, + "{}", + case.name + ); + assert_eq!( + headers.content_digest.as_ref().map(HeaderValue::to_str).transpose()?, + vector["headers"]["content-digest"].as_str(), + "{}", + case.name + ); + // oracle: spec RFC 6979 §3.2 + // The test key signs deterministically, as the vectors' generator did, so the signature bytes match. + assert_eq!( + headers.signature.to_str()?, + field(&vector["headers"], "signature")?, + "{}", + case.name + ); + + let base = signer.signature_base( + case.tag, + &Method::POST, + headers.content_digest.as_ref(), + created, + &headers.nonce, + )?; + // oracle: intent docs/agent-identity/INTENT.agent-identity.md §Conformance + assert_eq!(base.to_string(), field(vector, "signature_base")?, "{}", case.name); + let encoded_signature = headers + .signature + .to_str()? + .strip_prefix("sig=:") + .and_then(|value| value.strip_suffix(':')) + .ok_or_else(|| anyhow!("invalid signed test header"))?; + let bytes = general_purpose::STANDARD.decode(encoded_signature)?; + let signature = Signature::from_slice(&bytes)?; + public_key.verify(&base.as_bytes(), &signature)?; + } + Ok(()) +} + +// oracle: spec RFC 9421 §B.2.4 +#[test] +fn builds_and_signs_rfc_9421_b_2_4_response_base() -> anyhow::Result<()> { + let vectors = vectors()?; + let vector = &vectors["rfc9421_b_2_4"]; + let components = [ + ("@status", "200"), + ("content-type", "application/json"), + ( + "content-digest", + "sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41QJgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==:", + ), + ("content-length", "23"), + ] + .into_iter() + .map(|(name, value)| { + let id = HttpMessageComponentId::try_from(name)?; + let values = [value.to_owned()]; + HttpMessageComponent::try_from((&id, values.as_slice())) + }) + .collect::>>()?; + let covered = components + .iter() + .map(|component| component.id.clone()) + .collect::>(); + let mut params = HttpSignatureParams::try_new(&covered)?; + params.set_created(1_618_884_473).set_keyid("test-key-ecc-p256"); + let base = HttpSignatureBase::try_new(&components, ¶ms)?; + assert_eq!(base.to_string(), field(vector, "signature_base")?); + + // RFC 9421 Appendix B.1.3 publishes this test private scalar in its JWK. + let private_key = general_purpose::URL_SAFE_NO_PAD.decode("UpuF81l-kOxbjf7T4mNSv0r5tN67Gim7rnf6EFpcYDs")?; + let signing_key = P256SigningKey::from_slice(&private_key)?; + let key = TestKey { + name: "test-key-ecc-p256".to_owned(), + key: signing_key, + fail_signing: false, + }; + let public_key_der = general_purpose::STANDARD.decode(field(vector, "public_key_spki")?)?; + let public_key = VerifyingKey::from_public_key_der(&public_key_der)?; + let signature = base.build_raw_signature(&DeviceSigningKey(&key, "test-key-ecc-p256"))?; + public_key.verify(&base.as_bytes(), &Signature::from_slice(&signature)?)?; + let published_signature = general_purpose::STANDARD.decode(field(vector, "signature")?)?; + public_key.verify(&base.as_bytes(), &Signature::from_slice(&published_signature)?)?; + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures +// A request signature is the device key's raw r‖s, so a key that fails to sign leaves no valid request to return. +#[test] +fn signing_failure_is_returned_instead_of_panicking() -> anyhow::Result<()> { + let vectors = vectors()?; + let vector = &vectors["keys"][0]; + let key_bytes = general_purpose::STANDARD.decode(field(vector, "private_key_pkcs8")?)?; + let key = P256SigningKey::from_pkcs8_der(&key_bytes)?; + let leaf = general_purpose::STANDARD.decode(field(vector, "certificate")?)?; + let signer = RequestSigner::new( + Arc::new(TestKey { + name: "device-a".to_owned(), + key, + fail_signing: true, + }), + &leaf, + ); + assert!(signer.sign(Tag::Connect, &Method::POST, None, 1_790_000_000).is_err()); + Ok(()) +} + +#[tokio::test] +async fn channel_layer_prefixes_once_and_signs_each_opening() -> anyhow::Result<()> { + let vectors = vectors()?; + let (signer, _) = vector_signer(&vectors, "device-a")?; + let base_url = url::Url::parse("https://example.test/dvls/")?; + let layer = ChannelLayer::new(signer, &base_url); + let service = layer.layer(tower::service_fn(|request: Request<()>| async move { + Ok::<_, std::io::Error>(request) + })); + + let (first_handoff, first_nonce) = OpeningNonce::new(); + let mut first_request = Request::builder().method(Method::POST).uri(CONNECT_RPC_PATH).body(())?; + first_request.extensions_mut().insert(first_handoff); + let (second_handoff, second_nonce) = OpeningNonce::new(); + let mut second_request = Request::builder() + .method(Method::POST) + .uri(format!("/dvls{CONNECT_RPC_PATH}")) + .body(())?; + second_request.extensions_mut().insert(second_handoff); + + let (first, second) = tokio::join!(service.clone().oneshot(first_request), service.oneshot(second_request)); + let first = first.map_err(|error| anyhow!("{error}"))?; + let second = second.map_err(|error| anyhow!("{error}"))?; + let first_nonce = first_nonce.await?; + let second_nonce = second_nonce.await?; + + for request in [&first, &second] { + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url + assert_eq!(request.uri().path(), format!("/dvls{CONNECT_RPC_PATH}")); + let signature_input = request + .headers() + .get("signature-input") + .ok_or_else(|| anyhow!("missing signature input"))? + .to_str()?; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + assert!(signature_input.starts_with(r#"sig=("@method");"#), "{signature_input}"); + assert!(signature_input.contains(r#";tag="connect""#), "{signature_input}"); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url + assert!(request.headers().get("signature").is_some()); + } + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + assert_eq!( + first_nonce, + nonce( + first + .headers() + .get("signature-input") + .ok_or_else(|| anyhow!("missing signature input"))?, + )? + ); + assert_eq!( + second_nonce, + nonce( + second + .headers() + .get("signature-input") + .ok_or_else(|| anyhow!("missing signature input"))?, + )? + ); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + assert_ne!(first_nonce, second_nonce); + assert_eq!(general_purpose::URL_SAFE_NO_PAD.decode(&first_nonce)?.len(), 16); + assert_eq!(general_purpose::URL_SAFE_NO_PAD.decode(&second_nonce)?.len(), 16); + + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url + for (base_url, expected_path) in [ + ("https://example.test/dvls//", format!("/dvls{CONNECT_RPC_PATH}")), + ("https://example.test//", CONNECT_RPC_PATH.to_owned()), + ] { + let (signer, _) = vector_signer(&vectors, "device-a")?; + let service = ChannelLayer::new(signer, &url::Url::parse(base_url)?).layer(tower::service_fn( + |request: Request<()>| async move { Ok::<_, std::io::Error>(request) }, + )); + let (handoff, _received_nonce) = OpeningNonce::new(); + let mut opening = Request::builder().method(Method::POST).uri(CONNECT_RPC_PATH).body(())?; + opening.extensions_mut().insert(handoff); + let request = service.oneshot(opening).await.map_err(|error| anyhow!("{error}"))?; + assert_eq!(request.uri().path(), expected_path, "{base_url}"); + } + Ok(()) +} + +#[tokio::test] +async fn channel_layer_prefixes_an_rpc_path_that_matches_the_base_path() -> anyhow::Result<()> { + let vectors = vectors()?; + let (signer, _) = vector_signer(&vectors, "device-a")?; + let base_url = url::Url::parse("https://example.test/devolutions.agent.channel.v1.AgentChannel")?; + let service = ChannelLayer::new(signer, &base_url).layer(tower::service_fn(|request: Request<()>| async move { + Ok::<_, std::io::Error>(request) + })); + let (handoff, received_nonce) = OpeningNonce::new(); + let mut opening = Request::builder().method(Method::POST).uri(CONNECT_RPC_PATH).body(())?; + opening.extensions_mut().insert(handoff); + + let request = service.oneshot(opening).await.map_err(|error| anyhow!("{error}"))?; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url + assert_eq!( + request.uri().path(), + format!("/devolutions.agent.channel.v1.AgentChannel{CONNECT_RPC_PATH}") + ); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + assert_eq!( + received_nonce.await?, + nonce( + request + .headers() + .get("signature-input") + .ok_or_else(|| anyhow!("missing signature input"))? + )? + ); + Ok(()) +} + +#[tokio::test] +async fn channel_layer_preserves_root_path_and_applies_clock_offset() -> anyhow::Result<()> { + let vectors = vectors()?; + let (signer, _) = vector_signer(&vectors, "device-a")?; + let base_url = url::Url::parse("https://example.test/")?; + let layer = ChannelLayer::new(signer, &base_url).with_clock_offset(3_600); + let mut service = layer.layer(tower::service_fn(|request: Request<()>| async move { + Ok::<_, std::io::Error>(request) + })); + let before = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 3_600; + let (handoff, received_nonce) = OpeningNonce::new(); + let mut opening = Request::builder().method(Method::POST).uri(CONNECT_RPC_PATH).body(())?; + opening.extensions_mut().insert(handoff); + let request = service + .ready() + .await + .map_err(|error| anyhow!("{error}"))? + .call(opening) + .await + .map_err(|error| anyhow!("{error}"))?; + let after = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() + 3_600; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url + assert_eq!(request.uri().path(), CONNECT_RPC_PATH); + let signature_input = request + .headers() + .get("signature-input") + .ok_or_else(|| anyhow!("missing signature input"))? + .to_str()?; + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-sequence + assert_eq!( + received_nonce.await?, + nonce( + request + .headers() + .get("signature-input") + .ok_or_else(|| anyhow!("missing signature input"))? + )? + ); + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures + assert!((before..=after).any(|created| signature_input.contains(&format!(";created={created};")))); + Ok(()) +} + +// oracle: spec RFC 9421 §2.2.1 +#[test] +fn signature_covers_the_request_method() -> anyhow::Result<()> { + let vectors = vectors()?; + let (signer, public_key) = vector_signer(&vectors, "device-a")?; + let headers = signer.sign(Tag::Connect, &Method::PUT, None, 1_790_000_000)?; + let params = headers + .signature_input + .to_str()? + .strip_prefix("sig=") + .ok_or_else(|| anyhow!("unlabelled signature input"))?; + let base = format!("\"@method\": PUT\n\"@signature-params\": {params}"); + let encoded_signature = headers + .signature + .to_str()? + .strip_prefix("sig=:") + .and_then(|value| value.strip_suffix(':')) + .ok_or_else(|| anyhow!("invalid signed test header"))?; + let signature = Signature::from_slice(&general_purpose::STANDARD.decode(encoded_signature)?)?; + public_key.verify(base.as_bytes(), &signature)?; + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures +#[test] +fn confirm_covers_only_the_method_when_given_a_body() -> anyhow::Result<()> { + let vectors = vectors()?; + let (signer, _) = vector_signer(&vectors, "device-g")?; + let headers = signer.sign(Tag::Confirm, &Method::POST, Some(b"".as_slice()), 1_790_000_000)?; + let signature_input = headers.signature_input.to_str()?; + assert!(signature_input.starts_with(r#"sig=("@method");"#), "{signature_input}"); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §signatures +#[test] +fn signature_expiry_stays_within_structured_field_integers() -> anyhow::Result<()> { + let vectors = vectors()?; + let (signer, _) = vector_signer(&vectors, "device-a")?; + let last = signer.sign(Tag::Connect, &Method::POST, None, 999_999_999_999_939)?; + let signature_input = last.signature_input.to_str()?; + // oracle: spec RFC 8941 §3.3.1 + assert!( + signature_input.contains(";expires=999999999999999"), + "{signature_input}" + ); + // oracle: spec RFC 8941 §4.1.4 + assert!( + signer + .sign(Tag::Connect, &Method::POST, None, 999_999_999_999_940) + .is_err() + ); + Ok(()) +} + +// oracle: spec tower-service Service §Backpressure +#[test] +fn channel_service_is_not_ready_before_its_inner_service() -> anyhow::Result<()> { + struct NotReady; + + impl Service> for NotReady { + type Response = Request<()>; + type Error = std::io::Error; + type Future = std::future::Ready, std::io::Error>>; + + fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn call(&mut self, request: Request<()>) -> Self::Future { + std::future::ready(Ok(request)) + } + } + + let vectors = vectors()?; + let (signer, _) = vector_signer(&vectors, "device-a")?; + let base_url = url::Url::parse("https://example.test/dvls")?; + let mut service = ChannelLayer::new(signer, &base_url).layer(NotReady); + let mut cx = Context::from_waker(std::task::Waker::noop()); + assert!(service.poll_ready(&mut cx).is_pending()); + Ok(()) +} + +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-url +#[tokio::test] +async fn channel_opening_is_not_sent_when_signing_fails() -> anyhow::Result<()> { + let key = TestKey { + name: "failing".to_owned(), + key: P256SigningKey::from_slice(&[1; 32])?, + fail_signing: true, + }; + let signer = RequestSigner::new(Arc::new(key), b"leaf certificate"); + let base_url = url::Url::parse("https://example.test/dvls")?; + let forwarded = Arc::new(AtomicBool::new(false)); + let service = ChannelLayer::new(signer, &base_url).layer(tower::service_fn({ + let forwarded = Arc::clone(&forwarded); + move |request: Request<()>| { + forwarded.store(true, Ordering::SeqCst); + async move { Ok::<_, std::io::Error>(request) } + } + })); + let (handoff, _received_nonce) = OpeningNonce::new(); + let mut opening = Request::builder().method(Method::POST).uri(CONNECT_RPC_PATH).body(())?; + opening.extensions_mut().insert(handoff); + assert!(service.oneshot(opening).await.is_err()); + assert!(!forwarded.load(Ordering::SeqCst)); + Ok(()) +} + +fn nonce(input: &HeaderValue) -> anyhow::Result<&str> { + input + .to_str()? + .split_once(";nonce=\"") + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(nonce, _)| nonce) + .ok_or_else(|| anyhow!("missing signed nonce")) +} diff --git a/crates/agent-identity-mock/Cargo.toml b/crates/agent-identity-mock/Cargo.toml new file mode 100644 index 000000000..683aff179 --- /dev/null +++ b/crates/agent-identity-mock/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "agent-identity-mock" +version = "0.0.0" +authors = ["Devolutions Inc. "] +edition = "2024" +description = "Mock server (conformance oracle) for the Agent Identity V1 contract" +publish = false + +[lints] +workspace = true + +[dependencies] +agent-channel-proto = { path = "../agent-channel-proto" } +anyhow = "1" +axum = "0.8" +base64 = "0.22" +der = "0.8" +futures = "0.3" +http = "1" +http-body = "1" +http-body-util = "0.1" +hyper = "1" +hyper-util = { version = "0.1", features = [ + "http1", + "http2", + "server", + "server-auto", + "tokio", + "service", +] } +# Pinned to the RC: `picky` pins `=0.14.0-rc.14` and the resolver cannot hold two +# semver-compatible p256 versions. +p256 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] } +prost = "0.13" +prost-types = "0.13" +rand = "0.10" +rcgen = "0.13" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +spki = "0.8" +time = { version = "0.3", features = ["formatting", "parsing"] } +tokio = { version = "1.52", features = [ + "macros", + "rt-multi-thread", + "net", + "time", + "sync", + "io-util", + "fs", +] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tonic = { version = "0.12", default-features = false, features = ["prost"] } +tower = { version = "0.5", features = ["util"] } +tracing = "0.1" +uuid = { version = "1", features = ["v4", "serde"] } +x509-cert = { version = "0.3", features = ["builder"] } + +[dev-dependencies] +rstest = "0.25" +tokio = { version = "1.52", features = ["test-util"] } diff --git a/crates/agent-identity-mock/INTENT.agent-identity.md b/crates/agent-identity-mock/INTENT.agent-identity.md new file mode 120000 index 000000000..56d20dc7a --- /dev/null +++ b/crates/agent-identity-mock/INTENT.agent-identity.md @@ -0,0 +1 @@ +../../docs/agent-identity/INTENT.agent-identity.md \ No newline at end of file diff --git a/crates/agent-identity-mock/INTENT.md b/crates/agent-identity-mock/INTENT.md new file mode 100644 index 000000000..58be2b0fb --- /dev/null +++ b/crates/agent-identity-mock/INTENT.md @@ -0,0 +1,43 @@ +This binary is a reference authority for the conformance tester. + +## What it implements + +- The agent-facing operations and the agent channel, exactly as [CONTRACT.agent-identity.md](../../docs/agent-identity/CONTRACT.agent-identity.md) specifies. +- DVLS's management API as it is, so that the same tests run against the mock and against DVLS. + Its decisions are in DVLS's Agent Identity intent (`DVLS/Server/server/Common/AgentIdentity/INTENT.md` in the RDM repository), and its exact shape is DVLS's OpenAPI document: + - enrollment tokens: create (the full token is returned only then), list, read and delete; tokens are immutable after creation, and a friendly-name format with an unpaired brace or an unknown placeholder is rejected; + - identities: list with page-number pagination, views, metadata selection and filters, read, rename, revoke, delete (only once revoked), and request renewal; each identity's status is active, in grace, expired or revoked, as DVLS's intent defines them; + - root rotation: start with an optional deadline, and read its status, including the number of non-revoked identities still holding an unexpired current or pending certificate from the old root; + - every route requires DVLS's permission for it (View, Manage, Revoke or Rotate root), and a missing permission is `403`; + - an identity listing walked page by page returns every identity that existed when the walk started at least once, and exactly once when no enrollment overlaps the walk; + - an identity is `connected` only while it has a live stream authenticated by its channel proof. +- It serves under a configurable path prefix, to exercise authorities hosted in a virtual directory, and several instances can run side by side to exercise multiple authorities. +- Code downstream of authentication takes an `AuthenticatedIdentity` that only the oracle can construct; no handler treats an `identity_id` read from the wire as authenticated. + +## Independent derivation + +- The mock depends on no agent crate other than `agent-channel-proto`. +- It does not reuse the agent's request and response types, or its signing code. +- It verifies signatures and channel proofs with its own oracle, written from the contract, not with the `httpsig` crate the agent signs with. + A signature-base bug shared by both sides would otherwise pass the suite. +- The oracle is a self-contained set of pure functions: no I/O, time passed in, no dependency on the rest of the mock, and it passes the shared test vectors. + +## Mock-only control + +The mock listens on loopback addresses only, because its control surface is unauthenticated. + +On its management API, an application key `.` holds only the permissions its scope names, so the tester can exercise every permission check without provisioning application identities. + +A separate, unauthenticated control surface under `{u}/__mock__/` lets the tester: + +- drop the next response to an operation after processing its request, or fail it without processing it, either with a contract error body or with an empty body to exercise the agent's handling of a nonconforming error; +- skew the mock's clock, freeze it, or advance it, which immediately re-evaluates stream expiry and rotation deadlines; +- shorten the lifetime of subsequently issued certificates; +- make the channel unavailable, which removes `agent_channel_url` from every identity's config and pushes the change, or break it without changing any config; +- set the rotation notification rate; +- inject product-specific config fields into every identity's effective config, which bumps revisions and pushes the change; +- reset all mutable authority state, keeping management API login sessions so that the tester stays logged in; +- read an identity's ordered channel and certificate events, each with a monotonic sequence number, so that make-before-break ordering can be checked without polling. + +The control surface may also offer other test-only controls and observations that the tester needs, such as per-operation request counters, pausing the channel handshake or a retry, a redirect target, a server-initiated reconnect, or a stale config; none of them changes agent-facing protocol behavior. +This control surface is never part of the contract, and its request shapes are documented in the mock's README. diff --git a/crates/agent-identity-mock/README.md b/crates/agent-identity-mock/README.md new file mode 100644 index 000000000..a183dec86 --- /dev/null +++ b/crates/agent-identity-mock/README.md @@ -0,0 +1,196 @@ +# agent-identity-mock + +Mock server for the [Agent Identity V1 contract][contract], used as the conformance oracle. +It implements the agent-facing protocol, the [DVLS management API][dvls-management] and the [mock controls][mock-control] in memory. +It shares only `crates/agent-channel-proto` (the generated gRPC types) with the agent. + +## Usage + +``` +agent-identity-mock --listen 127.0.0.1:0 --path-prefix /mock --app-key --app-secret --state-dir +``` + +| Flag | Meaning | +|---|---| +| `--listen ` | TCP bind address; it must be a loopback address because the mock control surface is unauthenticated, and port `0` picks a free port. | +| `--path-prefix ` | Every route lives under this prefix (e.g. `/mock`); requests outside it get a bare 404. Must start with `/` and not end with `/`. | +| `--app-key ` | Key of the application identity that [logs in](#login) to the management API; the keys that hold fewer permissions derive from it. | +| `--app-secret ` | Secret of the application identity, shared by all its keys. | +| `--token-lifetime-secs ` | How long a `tokenId` lasts after its login; `420` (DVLS's 7 minutes) by default. | +| `--state-dir ` | Where `ready.json` and the TLS CA PEM are written; created if missing. | + +On startup the server generates a TLS CA and a server certificate for `localhost`, `127.0.0.1` and the listen address, and serves HTTPS on a single port with ALPN `h2` and `http/1.1` (REST and gRPC share the port). +Like a DVLS that has never used Agent Identity, it holds no root until the first enrollment token or `__mock__/reset` creates one. +It then writes `/ready.json` atomically (temp file + rename): + +```json +{ "base_url": "https://:/mock", "tls_ca_pem": "", "authority_id": "" } +``` + +`tls_ca_pem` is what an agent trusts through `__debug__.identity.extra_trusted_root`. +`authority_id` is stable for the process lifetime, including across `__mock__/reset`. + +## Clock + +All time in the mock is the mock clock, real time plus an offset, except the [`tokenId` lifetime](#login). +`POST __mock__/time/advance` adds to the offset and `faults.clock_skew_secs` adds a skew on top. +Signature windows, certificate validity, token expiry, renewal grace, rotation deadlines, `server_time` and `lastSeenOn` all use it. +A certificate is unexpired while `now ≤ notAfter`, and `notBefore` is never checked (§6). +A stream closes with `certificate_expired` once the clock reaches the certificate's `notAfter`, because §7.4 closes a stream no later than then; a stream opened at `notAfter` therefore closes right after its `Challenge`. +Advancing or freezing the clock immediately closes expired streams and re-evaluates rotation deadlines and early completion. +`POST __mock__/time/freeze` is a test-only boundary probe that pins the mock clock to an exact Unix second and reports the published root thumbprints in the same state transition; reset restores real-time tracking. + +## Routes + +All paths below are relative to `base_url` (i.e. they include the path prefix). + +### Agent-facing (CONTRACT.agent-identity.md §5), no management API credential + +| Method | Path | Notes | +|---|---|---| +| GET | `/api/agent-identity/v1/trust-anchor` | `{ "roots": [ { "certificate" (base64 DER), "thumbprint", "not_before", "not_after" } ] }`; none before the first root exists, and two while a rotation is in progress. | +| POST | `/api/agent-identity/v1/enroll` | `Authorization: Bearer `; body `{ "csr": "", "metadata": {...} }`. | +| POST | `/api/agent-identity/v1/renew` | RFC 9421-signed with `tag="renew"`; `Content-Digest` over the exact body bytes. | +| POST | `/api/agent-identity/v1/confirm` | RFC 9421-signed with `tag="confirm"` and an empty body; `204` for promotion or an idempotent retry. | +| POST | `/api/agent-identity/v1/check-in` | RFC 9421-signed with `tag="check-in"` and a digested `{ "metadata": {...} }` body; returns effective `config` and `renewal_requested`. | + +Non-2xx agent-facing responses carry `{ "error", "message", "server_time" }` with the §5.4 codes. +An unexpected failure, such as issuing from an expired root or a handler panic, is `500 unavailable`. +Enrollment, renewal and check-in require a `metadata` object, which may be empty. +A body that repeats a member name at any depth, holds more than 64 nested objects and arrays, counting the top-level object, or holds a string that is not valid Unicode, such as an escaped lone surrogate, is `invalid_request` (§1). +So is a `csr` that is not canonical `base64` or a CSR key that is not an uncompressed P-256 point (§1, §4). +A token authenticates by its exact secret text alone: the bag is never checked, and another encoding of the same secret bytes is `token_invalid` (§2). +Metadata is limited to 16 KiB in aggregate, and agent-facing request bodies are limited to 64 KiB. +A larger body gets `413 invalid_request` before authentication; otherwise an authentication error wins over an invalid body, CSR or metadata (§5.4). +Enrollment returns `config.version`, `config.revision` and optional `config.agent_channel_url`, never a top-level channel URL. +Friendly names are stored server-side and appear only in the management API. +Config revisions start at 1 and increase only on effective changes; `ConfigUpdate` follows `Welcome` for stale Hellos and is pushed to live streams after a change. +A token's optional `config` object supplies product-specific fields to its enrolled identities; it cannot set `version`, `revision` or `agent_channel_url`. +The mock holds no config whose `ConfigUpdate` envelope, measured with a 36-character `id`, would exceed 64 KiB (§7.1). +A change that would grow one past that is refused and changes nothing. +An enrollment whose token config no longer fits, because the channel offer or the version override grew it, is `500 unavailable` and does not count against the token's `maxUses`. +The mock uses a test-only unbounded push queue so config bursts cannot silently detach authenticated streams or discard termination messages. +`POST __mock__/config` adds unknown fields so tests can check that agents preserve them. +Only confirm promotes a pending certificate: old streams receive `Reconnect{reason:"certificate_rotated"}` and close with `OK` after 60 mock-clock seconds if still open. +A stream awaiting `Hello` closes once the clock reaches its certificate's `notAfter`, and for revocation or deletion when its `Hello` arrives or times out (§7.4). +These closures come ahead of any handshake failure, and revocation or deletion wins over expiry. +A stream whose certificate is retired during that wait completes the handshake, then receives the same `Reconnect` and closes with `OK` 60 mock-clock seconds after it. +An agent message that cannot be decoded closes an authenticated stream with `INVALID_ARGUMENT` + `invalid_request` (§7.4). +Channel envelopes are at most 64 KiB of serialized protobuf in each direction, without gRPC's 5-byte prefix (§7.1). +A larger agent message counts as one that cannot be decoded: `UNAUTHENTICATED` + `signature_invalid` in place of `Hello`, and `INVALID_ARGUMENT` + `invalid_request` after `Welcome`. +The channel gRPC method is `POST /devolutions.agent.channel.v1.AgentChannel/Connect` with `signature-input`/`signature` metadata (`tag="connect"`); open failures are gRPC statuses with `error-code` and `server-time` trailers (§7.4). + +### Login + +DVLS's login opens the sessions that authenticate the [management API](#management-api), with the operations and models of DVLS's public OpenAPI document: + +| Method | Path | Notes | +|---|---|---| +| POST | `/api/v1/login` | Body `LoginParameters`, `{ appKey, appSecret }`; 200 → `LoginResult`, `{ tokenId }`. A missing or empty `appKey` or `appSecret`, or a member `LoginParameters` does not define, is 400 with no body, and a wrong key or secret is 401 with a `PublicApiError` body, `{ "message" }`. | +| POST | `/api/v1/logout` | Closes the session the `tokenId` header names; 200 with no `Content-Type` and no body, even without a `tokenId` or with one that names no open session, so a logout is idempotent. | + +Each login opens another session with a new random `tokenId`, and earlier sessions stay open. +A `tokenId` expires `--token-lifetime-secs` after its login, however often it is used. +The lifetime runs on real time, not the mock clock, because tests advance the mock clock by days. + +The application identity's keys derive from `--app-key`, so a test can hold any subset of the four permissions without setup; each logs in with `--app-secret`: + +| Key | Permissions | +|---|---| +| `` | All four, like a DVLS built-in administrator. | +| `.` | Those in ``: `none`, or a `+`-separated list of `view`, `manage`, `revoke` and `rotate-root`, such as `view+revoke`. | +| `-unprivileged` | None, like `.none`. | + +Any other key, including a scope with an empty or unknown permission name, fails to log in. + +### Management API + +The [DVLS management API][dvls-management] has the operations, models, enums, paging and errors of DVLS's OpenAPI document for Agent Identity (`v1`, 14 operations), under `/api/v1/agent-identity`. +A request carries the `tokenId` of a [session](#login) in the `tokenId` header. +A request without one, even with an `Authorization` header, is 401 with no `Content-Type` and no body. +A repeated, unknown, expired or logged-out `tokenId` is 401 with `Content-Type: application/json; charset=utf-8` and exactly `{"message":"Missing or invalid token"}`. +Invalid parameters are 400 with a `PublicApiValidationError` body, `{ "errors": [ { "field", "message" } ] }`, where `field` names the camelCase member or query parameter, `config.` for a token config entry, or is `null` for a malformed body or query string. +Every other error is 403, 404, 409 or 422 with a `PublicApiError` body, `{ "message" }`. +A request body is a JSON object; a member its schema does not define is 400, and so is a member name repeated at any depth, more than 64 nested objects and arrays, or a string that is not valid Unicode, as CONTRACT.agent-identity.md §1 requires of agent-facing bodies. +Each operation requires the permission the table lists, and a session without it gets 403 before the request is processed. + +Enums are PascalCase. +An identity's `status` is `Active` (an unexpired current or pending certificate), `Grace` (no unexpired certificate, but one within renewal grace), `Expired` (beyond renewal grace) or `Revoked`, as the DVLS intent's "Terminology" defines. +A certificate's `status` is `Pending`, `Current` or `Retired`, a token's `state` is `Active`, `Exhausted` or `Expired` (which wins), and the rotation's `phase` is `Idle` or `Rotating`. +Timestamps are RFC 3339 UTC with `Z` and named `*On`; certificate and root validity is `validFrom` and `validUntil`. +A date-time parameter is UTC unless it carries an offset, and keeps its fraction of a second: token expiries, rotation deadlines and last-seen filters compare and return it exactly. +List operations take `pageNumber` (default 1), `pageSize` (default 25, at most 100 identities or 500 tokens), `sortOrder` (`1` ascending by default, `0` descending) and `sortField`, and return `{ currentPage, data, pageSize, totalCount, totalPage }`. +A page past the last is 400, and page 1 of an empty list is its last page. +A query parameter with an empty value is present, so an empty integer or enum value is 400. +An ID that is not a GUID is 404, like an unknown one. + +| Method | Path | Permission | Notes | +|---|---|---|---| +| POST | `/api/v1/agent-identity/enrollment-token` | Manage | Body `{ name (1..255 scalar values, not blank), maxUses (1..1_000_000), expiresOn? (future, ≤ now + 365 d, default now + 90 d), friendlyNameFormat? (≤ 1024 scalar values), config? }`; 201 → `{ enrollmentToken, token }`, the only response with the full `dvaet1` token. A friendly-name format with an unpaired brace or an unknown placeholder is 400. `config` holds at most 32 string entries, each key matching `^[a-z][a-z0-9_]{0,63}$` other than `version`, `revision` and `agent_channel_url`, each value at most 1024 bytes without control characters. A `config` that would give its identities a config past one channel envelope is 400 on `config`. The first token creates the root, and is 409 when it cannot (`root_key_unavailable`). | +| GET | `/api/v1/agent-identity/enrollment-token` | View | Page of the tokens that are not deleted; `sortField` is `createdOn` (default), `name` or `expiresOn`, with ties in creation order. | +| GET | `/api/v1/agent-identity/enrollment-token/{id}` | View | `AgentIdentityEnrollmentToken`, without the full token; 404 when unknown or deleted. | +| DELETE | `/api/v1/agent-identity/enrollment-token/{id}` | Manage | 204, or 404 when unknown or already deleted; the token can still replay its own existing current key without side effects, while a new key gets `token_invalid`. | +| GET | `/api/v1/agent-identity` | View | Page of identities in creation order (`sortField` is `createdOn` only). Query: `view=Summary\|Full`, repeated `metadataKey`, repeated `status`, repeated `enrollmentTokenId`, `issuerThumbprint` (the current certificate's issuer), `lastSeenFrom` and `lastSeenTo` (inclusive), and `search` (case-insensitive friendly-name substring); filters combine with AND. The Summary view carries `id`, `friendlyName`, `status`, `isConnected`, `lastSeenOn`, `certificate { issuerThumbprint, validUntil }` while the identity has a current certificate, and the requested `metadata` keys, omitted when none match. | +| GET | `/api/v1/agent-identity/{id}` | View | Full view: the Summary members, all `metadata`, `certificates` (newest first), `createdOn`, `enrollmentTokenId`, `enrollmentTokenName`, `isRenewalRequested`, and `revokedOn` once revoked. | +| PATCH | `/api/v1/agent-identity/{id}` | Manage | `{ friendlyName? }` (1..255 scalar values without leading or trailing white space); absent or `null` changes nothing; 200 → Full view. | +| POST | `/api/v1/agent-identity/{id}/revoke` | Revoke | 204, idempotent; closes live streams PERMISSION_DENIED + `identity_revoked`. | +| DELETE | `/api/v1/agent-identity/{id}` | Manage | 204, or 409 unless the identity is revoked. | +| POST | `/api/v1/agent-identity/{id}/request-renewal` | Manage | 202; sets the flag and pushes `RenewRequested{reason:"admin"}` to live streams; 409 when the identity is revoked. | +| POST | `/api/v1/agent-identity/rotation` | Rotate root | Body `{ deadlineOn? }`, `{}` for the rotation maximum: the latest `notAfter` among the old root's unexpired current or pending certificates of non-revoked identities, or now when there are none. A deadline at or before now is an emergency rotation. 202 → `AgentIdentityRotation`; 409 while rotating, without a root, or with `root_key_unavailable`; 422 for a deadline after the rotation maximum. | +| GET | `/api/v1/agent-identity/rotation` | View | `{ phase, startedOn?, deadlineOn?, oldRoot?, newRoot?, oldRootActiveIdentityCount }`, each root `{ thumbprint, validFrom, validUntil }`; the count includes each non-revoked identity with an unexpired current or pending old-root certificate, and is 0 while `Idle`. | +| GET | `/api/v1/agent-identity/settings` | View | `{ authorityId, isAgentChannelEnabled, isAgentChannelAvailable, certificateLifetimeDays, rotationNotificationsPerSecond }`, by default `true`, `true`, `90` and `10`. | +| PATCH | `/api/v1/agent-identity/settings` | Manage | `{ isAgentChannelEnabled?, certificateLifetimeDays? (1..365), rotationNotificationsPerSecond? (1..1000) }` → settings. An `isAgentChannelEnabled` that would grow a config past one channel envelope is 400 on that member, and nothing changes. | + +The channel is offered (`isAgentChannelAvailable`) while `isAgentChannelEnabled` is on and the `channel_available` fault, which stands for the hosting, is on. +A change of the offer adds or removes `agent_channel_url` in every config, bumps revisions and pushes `ConfigUpdate`, and channel openings are refused while the channel is not offered. +`certificateLifetimeDays` applies to certificates issued afterwards, and `rotationNotificationsPerSecond` to rotation notifications; the `leaf_lifetime_secs` and `rotation_rate_limit_per_sec` faults override them. +A rotation completes immediately when no active old-root identity remains, including at rotation start, and publishes only the new root. +Certificate issuance and token-use consumption share the state lock; issued key hashes stay reserved, and original issuance times remain stored after identity deletion. + +### Mock control ([mock controls][mock-control]), no auth + +| Method | Path | Notes | +|---|---|---| +| POST | `/__mock__/faults` | Merges `drop_next_response`, `fail_next_response`, `clock_skew_secs`, `leaf_lifetime_secs`, `channel_available`, `channel_broken`, `malformed_channel_url`, `rotation_rate_limit_per_sec`, `config_version` and `root_key_unavailable`; absent keys retain their value, and `null` clears optional fields. `leaf_lifetime_secs` (seconds) overrides the `certificateLifetimeDays` setting and `rotation_rate_limit_per_sec` the `rotationNotificationsPerSecond` setting, without the settings' bounds. `channel_available` is whether the hosting supports the channel; toggling it changes the channel offer as the [management API](#management-api) describes. `config_version` (an unsigned integer) replaces `config.version` in every config the mock delivers, and setting it to a different version or clearing it bumps every identity's config revision and pushes `ConfigUpdate`; `channel_broken` only rejects new streams. Set `malformed_channel_url` to `true` for an HTTP URL, `"query"` for an HTTPS URL with a query, or `"fragment"` for an HTTPS URL with a fragment in new enrollments; `false` or `null` clears it. `root_key_unavailable` (a boolean) makes the root key undecryptable: enrollment and renewal fail with `500 unavailable`, root rotation and the creation of a first root with 409. A `channel_available` or `config_version` change that would grow a config past one channel envelope is 400 `invalid_request` and keeps its fault unchanged. | +| POST | `/__mock__/config` | `{ "fields": {...} }` merges non-reserved fields into each identity's config; effective changes bump revisions and push `ConfigUpdate`. Fields that would grow a config past one channel envelope are 400 `invalid_request`, and no config changes. | +| POST | `/__mock__/config/stale` | `{ "identity_id": "", "revision": }` sends a stale `ConfigUpdate` with `mock_stale_marker` without changing server config; returns `{ "sent": }`. A stale config past one channel envelope is 400 `invalid_request` and is not sent. | +| POST | `/__mock__/reset` | Clears tokens, identities, nonces, faults, settings, rotation and the clock offset, and creates a fresh root; keeps `authority_id`, the TLS certificate, and the application identity and its sessions; live streams close with `identity_unknown`. The body is empty or `{ "without_root"?: }`; `true` leaves no root, as at startup, until the first token creates one. Responds `{ "authority_id" }`. | +| POST | `/__mock__/time/advance` | `{ "secs": }` moves the mock clock and immediately applies stream expiry, rotation deadlines and early completion; responds `{ "now": , "server_time": "" }`. | +| POST | `/__mock__/time/freeze` | `{ "now": }` pins time and returns `{ "now", "published_roots": ["", ...] }` after applying expiry and rotation deadlines at that exact second. | +| GET | `/__mock__/events?identity_id=` | Returns `{ "events": [...] }` in increasing `seq` order: `stream_opened`, `stream_authenticated` (with `applied_config_revision`), `stream_closed` (with `status` and optional `error_code`), `cert_status_changed`, `reconnect_sent`, `renew_received`, `config_changed`, `config_update_sent`, `stale_config_update_sent`, and `check_in_received` (with `renewal_requested`). | +| POST / GET | `/__mock__/handshake` | POST `{ "pause": true\|false }` holds valid Hello proofs before authentication, and checks a held `Hello` against revocation, deletion and `notAfter` again on release; GET and POST report `paused` and `paused_stream_ids` so tests can match held proofs to opened streams. Reset releases the barrier. | +| POST | `/__mock__/retry-barrier` | `{ "endpoint": "enroll"\|"renew"\|"confirm", "pause": true\|false }` makes retries after a dropped response or injected confirm failure receive an unprocessed, empty HTTP 503 until released; reset clears it. | +| GET | `/__mock__/requests?token_id=` | Reports per-token `enroll` attempts and `enroll_identity_revoked` rejections, total `enroll_total`, `enroll_retry_503`, `renew`, raw `renew_attempt_keyids` (test-only, not authentication), `renew_retry_503`, `confirm`, `confirm_retry_503`, `check_in`, `connect`, `channel_attempts` (every gRPC request, including wrong paths), `redirect_hits`, ordered `request_sequence` (with `confirm_204` result markers), `authenticated_connects`, `correlated_acks`, `overlap_open` (only after proof), `active_streams`, and `paused_hellos`; omit `token_id` for totals. Reset clears all counters. | +| GET / POST | `/__mock__/redirect-target` | Counts `redirect_hits` when an agent incorrectly follows an injected enrollment redirect; responds 418. | +| POST | `/__mock__/reconnect` | `{ "identity_id": "" }` pushes `Reconnect{reason:"mock"}` to the identity's live streams; responds 202. | + +`drop_next_response` processes the next matching request, then aborts the connection without replying. +`fail_next_response` rejects it before processing with `{ "endpoint": "enroll"|"renew"|"confirm"|"check-in", "status": 300..599, "error"?: "<§5.4 code>", "retry_after_secs"?: }`. +A `3xx` fault redirects to `__mock__/redirect-target`, and `retry_after_secs` sets the `Retry-After` header. +Without `error`, the injected response body is deliberately empty and nonconforming so tests can verify transient agent handling. +Arm the retry barrier before `drop_next_response` to prevent a fast retry from succeeding before the tester stops or revokes the agent. +`enroll_identity_revoked` counts enroll rejections for a previously issued key of a revoked or deleted identity, including retries after a lost response. +Rotation pushes follow `rotationNotificationsPerSecond` (10 by default) per rolling second and continue after an emergency deadline until the queue drains. +The old root's signing key is discarded when rotation starts. + +## Signature oracle + +`src/oracle/` is a test-only verifier of CONTRACT.agent-identity.md §4, §6 and §7.3, written independently of the agent's RFC 9421 library and implementation and not intended for production use. +For signed requests it checks, in order, (1) strict syntax, parameters, algorithm and covered components, (2) endpoint tag, (3) signature window, (4) certificate registration, status, revocation and validity, (5) renew/check-in content digest and P-256 signature, then (6) replay nonce insertion only after verification. +For channels it verifies the P-256 proof over the §7.3 domain separator, zero byte, challenge and connect nonce; the channel service owns the handshake and state updates. +For CSRs it checks the PKCS#10 structure, P-256 subject key, required signature algorithm and self-signature over the request info as received, while ignoring the subject and attributes, whose contents need not be canonical DER (§4). +The RFC 9421 Appendix B.2.4 vector exercises its raw P-256 verifier without widening the request profile. +Its crate-local runtime API is `verify_request`, `verify_channel_proof`, `check_csr`, `content_digest_header`, `Endpoint`, `Rejection`, `RegisteredCert`, `CertStatus`, `NonceStore` and `AuthenticatedIdentity`. +Vector tests also use the test-only `verify_raw_signature` entry to check the RFC example and the positive fixtures' exact signed bases. +Callers supply the certificate lookup, nonce store and current Unix time; the oracle performs no I/O or asynchronous work. +The oracle depends only on `p256`, `sha2`, `base64`, `uuid`, `x509-cert` and `der` outside the standard library, and never on other mock modules or workspace crates. +X.509 and DER supply CSR structure types; P-256 performs signature verification. + +## Tests + +`cargo test -p agent-identity-mock` replays every signature, proof and CSR vector through the verifier and validators used by the server. +It also pins the login, each management API operation, error class, paging bound, permission and setting effect to DVLS's OpenAPI documents. + +[contract]: ../../docs/agent-identity/CONTRACT.agent-identity.md +[dvls-management]: https://github.com/Devolutions/RDM/blob/0acd9d2474bf9fd4a030e146a28097bf4ddf391a/DVLS/Server/server/Common/AgentIdentity/INTENT.md#management-api +[mock-control]: INTENT.md#mock-only-control diff --git a/crates/agent-identity-mock/clippy.toml b/crates/agent-identity-mock/clippy.toml new file mode 100644 index 000000000..e6a27ca20 --- /dev/null +++ b/crates/agent-identity-mock/clippy.toml @@ -0,0 +1,9 @@ +semicolon-outside-block-ignore-multiline = true +accept-comment-above-statement = true +accept-comment-above-attributes = true + +# The Linux release sysroot is glibc 2.27. +disallowed-methods = [ + { path = "libc::renameat2", reason = "needs glibc 2.28; the Linux release baseline is glibc 2.27, so call the renameat2 syscall directly", allow-invalid = true }, + { path = "signature::Signer::sign", reason = "use try_sign: device key signing is fallible" }, +] diff --git a/crates/agent-identity-mock/src/api_admin/login.rs b/crates/agent-identity-mock/src/api_admin/login.rs new file mode 100644 index 000000000..2f3a1696c --- /dev/null +++ b/crates/agent-identity-mock/src/api_admin/login.rs @@ -0,0 +1,111 @@ +//! This module serves DVLS's login under `/api/v1`, with the operations and models of DVLS's public OpenAPI +//! document, since the management API follows DVLS's public API authentication ([DVLS intent][dvls-intent], +//! "Management API"). +//! +//! An application identity's key and secret open a session whose `tokenId` authenticates management API requests until +//! it logs out or its lifetime has passed since its login, however often it is used. +//! The lifetime runs on real time rather than the mock clock, because tests advance the mock clock by days. +//! +//! [dvls-intent]: https://github.com/Devolutions/RDM/blob/0acd9d2474bf9fd4a030e146a28097bf4ddf391a/DVLS/Server/server/Common/AgentIdentity/INTENT.md#management-api + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use axum::Json; +use axum::body::Bytes; +use axum::extract::State as AxumState; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse as _, Response}; +use serde_json::{Value, json}; +use tokio::sync::Mutex; +use tokio::time::Instant; +use uuid::Uuid; + +use super::{ManagementError, Permissions, TOKEN_ID_HEADER, key_permissions, parse_body}; +use crate::app::App; + +/// The application identity that logs in to the management API, and the sessions its logins open. +pub struct ManagementLogin { + app_key: String, + app_secret: String, + token_lifetime: Duration, + /// Sessions by `tokenId`, until they log out. + sessions: Mutex>, +} + +struct Session { + permissions: Permissions, + logged_in_at: Instant, +} + +impl ManagementLogin { + /// Returns the login of the application identity `app_key` with `app_secret`, whose sessions last `token_lifetime`. + pub fn new(app_key: String, app_secret: String, token_lifetime: Duration) -> Self { + Self { + app_key, + app_secret, + token_lifetime, + sessions: Mutex::default(), + } + } + + /// Opens a session for the application key `app_key` and returns its `tokenId`, or `None` when the key or the secret + /// is wrong. + async fn open_session(&self, app_key: &str, app_secret: &str) -> Option { + if app_secret != self.app_secret { + return None; + } + let permissions = key_permissions(&self.app_key, app_key)?; + let token_id = Uuid::new_v4().to_string(); + let session = Session { + permissions, + logged_in_at: Instant::now(), + }; + self.sessions.lock().await.insert(token_id.clone(), session); + Some(token_id) + } + + /// Returns the permissions of the session `token_id` names, or `None` once it has logged out or expired. + pub(super) async fn session_permissions(&self, token_id: &str) -> Option { + self.sessions + .lock() + .await + .get(token_id) + .filter(|session| session.logged_in_at.elapsed() < self.token_lifetime) + .map(|session| session.permissions) + } + + async fn close_session(&self, token_id: &str) { + self.sessions.lock().await.remove(token_id); + } +} + +/// `LoginPublicApi_Login`: opens a session and returns its `tokenId` in a `LoginResult`. +/// +/// A body that is not a `LoginParameters` with a non-empty `appKey` and `appSecret` is `400` without a body, and a wrong +/// key or secret is `401`. +pub(super) async fn login(AxumState(app): AxumState>, body: Bytes) -> Response { + let Ok(body) = parse_body(&body, &["appKey", "appSecret"]) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + let member = |name: &str| body.get(name).and_then(Value::as_str).filter(|text| !text.is_empty()); + let (Some(app_key), Some(app_secret)) = (member("appKey"), member("appSecret")) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + match app.login.open_session(app_key, app_secret).await { + Some(token_id) => Json(json!({ "tokenId": token_id })).into_response(), + None => ManagementError::status(StatusCode::UNAUTHORIZED, "invalid application key or secret").into_response(), + } +} + +/// `LoginPublicApi_LogOut`: closes the session the `tokenId` header names. +/// +/// Like DVLS, it answers `200` without a body whether or not the header names an open session, so a logout is +/// idempotent. +pub(super) async fn logout(AxumState(app): AxumState>, headers: HeaderMap) -> StatusCode { + if let Some(token_id) = headers.get(TOKEN_ID_HEADER).and_then(|value| value.to_str().ok()) { + app.login.close_session(token_id).await; + } + StatusCode::OK +} diff --git a/crates/agent-identity-mock/src/api_admin/mod.rs b/crates/agent-identity-mock/src/api_admin/mod.rs new file mode 100644 index 000000000..a19aed1d5 --- /dev/null +++ b/crates/agent-identity-mock/src/api_admin/mod.rs @@ -0,0 +1,1030 @@ +//! This module serves DVLS's management API ([DVLS intent][dvls-intent], "Management API") under +//! `/api/v1/agent-identity`, with the operations, models, paging, errors and enum spelling of DVLS's OpenAPI +//! document. +//! +//! The `tokenId` header carries a session that DVLS's login opens, and each operation requires one of DVLS's four +//! permissions. +//! Invalid parameters are `400` with a `PublicApiValidationError` body; every other error carries a `PublicApiError` +//! body, except the `401` for a missing `tokenId` header, which has none. +//! +//! [dvls-intent]: https://github.com/Devolutions/RDM/blob/0acd9d2474bf9fd4a030e146a28097bf4ddf391a/DVLS/Server/server/Common/AgentIdentity/INTENT.md#management-api + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::rejection::QueryRejection; +use axum::extract::{Path, Query, Request, State as AxumState}; +use axum::http::{StatusCode, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::routing::{MethodRouter, delete, get, patch, post}; +use axum::{Json, Router, middleware}; +use serde_json::{Map, Value, json}; +use uuid::Uuid; + +use crate::app::{App, nanos, parse_date_time, rfc3339, rfc3339_nanos}; +use crate::channel::config_fits_envelope; +use crate::oracle::CertStatus; +use crate::state::{ApiError, Cert, Identity, IdentityStatus, State, Token}; + +mod login; + +pub use login::ManagementLogin; + +const BASE_PATH: &str = "/api/v1/agent-identity"; +const TOKEN_ID_HEADER: &str = "tokenId"; + +const SECS_PER_DAY: i64 = 24 * 3600; +const DEFAULT_PAGE_SIZE: i64 = 25; +const MAX_IDENTITY_PAGE_SIZE: i64 = 100; +const MAX_TOKEN_PAGE_SIZE: i64 = 500; +const DEFAULT_TOKEN_LIFETIME_SECS: i64 = 90 * SECS_PER_DAY; +const MAX_TOKEN_LIFETIME_SECS: i64 = 365 * SECS_PER_DAY; +const MAX_TOKEN_USES: i64 = 1_000_000; +const MAX_TOKEN_NAME_CHARS: usize = 255; +const MAX_FRIENDLY_NAME_FORMAT_CHARS: usize = 1024; +const MAX_TOKEN_CONFIG_ENTRIES: usize = 32; +const MAX_TOKEN_CONFIG_KEY_BYTES: usize = 64; +const MAX_TOKEN_CONFIG_VALUE_BYTES: usize = 1024; +const MAX_CERTIFICATE_LIFETIME_DAYS: i64 = 365; +const MAX_ROTATION_NOTIFICATIONS_PER_SECOND: i64 = 1000; + +const IDENTITY_NOT_FOUND: &str = "identity not found"; +const TOKEN_NOT_FOUND: &str = "enrollment token not found"; + +const TOKEN_SORT_FIELDS: [&str; 3] = ["createdOn", "name", "expiresOn"]; +const IDENTITY_SORT_FIELDS: [&str; 1] = ["createdOn"]; + +pub(crate) fn router(app: Arc) -> Router { + use Permission::{Manage, Revoke, RotateRoot, View}; + + let path = |suffix: &str| format!("{BASE_PATH}{suffix}"); + Router::new() + .route(&path(""), requires(View, get(list_identities))) + .route(&path("/enrollment-token"), requires(Manage, post(create_token))) + .route(&path("/enrollment-token"), requires(View, get(list_tokens))) + .route(&path("/enrollment-token/{id}"), requires(View, get(get_token))) + .route(&path("/enrollment-token/{id}"), requires(Manage, delete(delete_token))) + .route(&path("/rotation"), requires(View, get(get_rotation))) + .route(&path("/rotation"), requires(RotateRoot, post(start_rotation))) + .route(&path("/settings"), requires(View, get(get_settings))) + .route(&path("/settings"), requires(Manage, patch(patch_settings))) + .route(&path("/{id}"), requires(View, get(get_identity))) + .route(&path("/{id}"), requires(Manage, patch(patch_identity))) + .route(&path("/{id}"), requires(Manage, delete(delete_identity))) + .route(&path("/{id}/revoke"), requires(Revoke, post(revoke_identity))) + .route(&path("/{id}/request-renewal"), requires(Manage, post(request_renewal))) + .route_layer(middleware::from_fn_with_state(Arc::clone(&app), authenticate)) + .route("/api/v1/login", post(login::login)) + .route("/api/v1/logout", post(login::logout)) + .with_state(app) +} + +// ==== Authentication and permissions ==== + +/// DVLS's Agent Identity permissions ([DVLS intent][dvls-intent], "Management API"). +/// +/// [dvls-intent]: https://github.com/Devolutions/RDM/blob/0acd9d2474bf9fd4a030e146a28097bf4ddf391a/DVLS/Server/server/Common/AgentIdentity/INTENT.md#management-api +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Permission { + View, + Manage, + Revoke, + RotateRoot, +} + +impl Permission { + const ALL: [Permission; 4] = [ + Permission::View, + Permission::Manage, + Permission::Revoke, + Permission::RotateRoot, + ]; + + /// Returns the permission's name in a scoped application key. + fn key_name(self) -> &'static str { + match self { + Permission::View => "view", + Permission::Manage => "manage", + Permission::Revoke => "revoke", + Permission::RotateRoot => "rotate-root", + } + } + + /// Returns the permission's name in DVLS's OpenAPI document. + fn display_name(self) -> &'static str { + match self { + Permission::View => "AgentIdentityView", + Permission::Manage => "AgentIdentityManage", + Permission::Revoke => "AgentIdentityRevoke", + Permission::RotateRoot => "AgentIdentityRotateRoot", + } + } + + fn bit(self) -> u8 { + 1 << self as u8 + } +} + +/// The permissions a management API session holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct Permissions(u8); + +impl Permissions { + fn all() -> Self { + Permission::ALL.into_iter().collect() + } + + fn contains(self, permission: Permission) -> bool { + self.0 & permission.bit() != 0 + } +} + +impl FromIterator for Permissions { + fn from_iter>(iter: I) -> Self { + Self(iter.into_iter().fold(0, |mask, permission| mask | permission.bit())) + } +} + +/// Returns the permissions of a session that `key` opens, or `None` when `key` is not one of the keys of the application +/// identity `app_key`. +/// +/// `` holds every permission, `-unprivileged` none, and `.` the ones its scope lists. +/// The scope is `none` or a `+`-separated list of `view`, `manage`, `revoke` and `rotate-root`. +fn key_permissions(app_key: &str, key: &str) -> Option { + let suffix = key.strip_prefix(app_key)?; + match suffix { + "" => return Some(Permissions::all()), + "-unprivileged" => return Some(Permissions::default()), + _ => {} + } + let scope = suffix.strip_prefix('.')?; + if scope == "none" { + return Some(Permissions::default()); + } + scope + .split('+') + .map(|name| { + Permission::ALL + .into_iter() + .find(|permission| permission.key_name() == name) + }) + .collect() +} + +fn requires(permission: Permission, route: MethodRouter>) -> MethodRouter> { + route.route_layer(middleware::from_fn_with_state(permission, authorize)) +} + +/// Authenticates the session the single `tokenId` header names. +/// +/// Like DVLS, a request without a `tokenId` header is `401` without a body, and one whose `tokenId` names no open +/// session is `401` with a fixed `PublicApiError`; so is one with several `tokenId` headers. +async fn authenticate(AxumState(app): AxumState>, mut request: Request, next: Next) -> Response { + let mut token_ids = request.headers().get_all(TOKEN_ID_HEADER).iter(); + let token_id = match (token_ids.next(), token_ids.next()) { + (None, _) => return StatusCode::UNAUTHORIZED.into_response(), + (Some(token_id), None) => token_id.to_str().ok().map(str::to_owned), + (Some(_), Some(_)) => None, + }; + let permissions = match token_id { + Some(token_id) => app.login.session_permissions(&token_id).await, + None => None, + }; + let Some(permissions) = permissions else { + // DVLS's `Content-Type` names a charset, which `Json` omits. + return ( + StatusCode::UNAUTHORIZED, + [(header::CONTENT_TYPE, "application/json; charset=utf-8")], + r#"{"message":"Missing or invalid token"}"#, + ) + .into_response(); + }; + request.extensions_mut().insert(permissions); + next.run(request).await +} + +async fn authorize(AxumState(required): AxumState, request: Request, next: Next) -> Response { + let granted = request.extensions().get::().copied().unwrap_or_default(); + if !granted.contains(required) { + return ManagementError::status( + StatusCode::FORBIDDEN, + format!("missing the {} permission", required.display_name()), + ) + .into_response(); + } + next.run(request).await +} + +// ==== Errors ==== + +/// A management API error: a `PublicApiValidationError` for `400`, and a `PublicApiError` otherwise. +#[derive(Debug)] +enum ManagementError { + Validation(Vec), + Status(StatusCode, String), +} + +/// A `PublicApiFieldError`; `field` is `None` when the request as a whole is invalid. +#[derive(Debug)] +struct FieldError { + field: Option, + message: String, +} + +impl ManagementError { + fn invalid(field: impl Into, message: impl Into) -> Self { + Self::Validation(vec![FieldError { + field: Some(field.into()), + message: message.into(), + }]) + } + + fn invalid_request(message: impl Into) -> Self { + Self::Validation(vec![FieldError { + field: None, + message: message.into(), + }]) + } + + fn status(status: StatusCode, message: impl Into) -> Self { + Self::Status(status, message.into()) + } + + fn not_found(message: &str) -> Self { + Self::status(StatusCode::NOT_FOUND, message) + } +} + +impl From for ManagementError { + fn from(error: ApiError) -> Self { + match StatusCode::from_u16(error.status) { + Ok(StatusCode::BAD_REQUEST) => Self::invalid_request(error.message), + Ok(status) => Self::Status(status, error.message), + Err(_) => Self::Status(StatusCode::INTERNAL_SERVER_ERROR, error.message), + } + } +} + +impl IntoResponse for ManagementError { + fn into_response(self) -> Response { + match self { + ManagementError::Validation(errors) => { + let errors: Vec = errors + .into_iter() + .map(|error| json!({ "field": error.field, "message": error.message })) + .collect(); + (StatusCode::BAD_REQUEST, Json(json!({ "errors": errors }))).into_response() + } + ManagementError::Status(status, message) => (status, Json(json!({ "message": message }))).into_response(), + } + } +} + +type ManagementResult = Result; + +/// Collects the field errors of one request, so that a `400` reports all of them. +#[derive(Default)] +struct FieldErrors(Vec); + +impl FieldErrors { + fn add(&mut self, field: impl Into, message: impl Into) { + self.0.push(FieldError { + field: Some(field.into()), + message: message.into(), + }); + } + + fn finish(self) -> Result<(), ManagementError> { + if self.0.is_empty() { + Ok(()) + } else { + Err(ManagementError::Validation(self.0)) + } + } +} + +// ==== Request parsing ==== + +/// Parses a JSON request body into its members. +/// +/// A member name repeated at any depth, more than 64 nested objects and arrays, and a string that is not valid Unicode +/// are refused, as for agent-facing bodies ([CONTRACT.agent-identity.md][contract], "1. Protocol conventions"), and so +/// is a member the request schema does not define, since every schema in DVLS's OpenAPI document sets +/// `additionalProperties: false`. +/// +/// [contract]: ../../../../docs/agent-identity/CONTRACT.agent-identity.md#1-protocol-conventions +fn parse_body(body: &[u8], members: &[&str]) -> Result, ManagementError> { + let value = crate::json::parse_request_body(body) + .map_err(|error| ManagementError::invalid_request(format!("malformed JSON body: {error}")))?; + let Value::Object(object) = value else { + return Err(ManagementError::invalid_request("body must be a JSON object")); + }; + let mut errors = FieldErrors::default(); + for name in object.keys().filter(|name| !members.contains(&name.as_str())) { + errors.add(name, "unknown member"); + } + errors.finish()?; + Ok(object) +} + +/// Returns a member's value, where `null` counts as absent. +fn member<'a>(body: &'a Map, name: &str) -> Option<&'a Value> { + body.get(name).filter(|value| !value.is_null()) +} + +fn string_member<'a>(body: &'a Map, name: &str, errors: &mut FieldErrors) -> Option<&'a str> { + let value = member(body, name)?; + let text = value.as_str(); + if text.is_none() { + errors.add(name, format!("{name} must be a string")); + } + text +} + +fn bool_member(body: &Map, name: &str, errors: &mut FieldErrors) -> Option { + let value = member(body, name)?; + let flag = value.as_bool(); + if flag.is_none() { + errors.add(name, format!("{name} must be a boolean")); + } + flag +} + +/// Returns an integer member within `min..=max`. +fn integer_member(body: &Map, name: &str, min: i64, max: i64, errors: &mut FieldErrors) -> Option { + let value = member(body, name)?; + let integer = value.as_i64().filter(|integer| (min..=max).contains(integer)); + if integer.is_none() { + errors.add(name, format!("{name} must be an integer from {min} to {max}")); + } + integer +} + +/// Returns a date-time member in Unix nanoseconds. +fn date_time_member(body: &Map, name: &str, errors: &mut FieldErrors) -> Option { + let text = string_member(body, name, errors)?; + let time = parse_date_time(text); + if time.is_none() { + errors.add(name, format!("{name} must be a date-time")); + } + time +} + +/// Parses an `{id}` route parameter; an ID that is not a GUID names no record. +fn parse_id(raw: &str, not_found: &str) -> Result { + raw.parse().map_err(|_| ManagementError::not_found(not_found)) +} + +/// The query string of a request. +/// A single-valued parameter takes its first value; an empty value is present and validated like any other. +struct QueryParams(Vec<(String, String)>); + +impl QueryParams { + fn new(query: Result>, QueryRejection>) -> Result { + query + .map(|Query(pairs)| Self(pairs)) + .map_err(|rejection| ManagementError::invalid_request(format!("malformed query string: {rejection}"))) + } + + fn single<'a>(&'a self, name: &'a str) -> Option<&'a str> { + self.all(name).next() + } + + fn all<'a>(&'a self, name: &'a str) -> impl Iterator { + self.0 + .iter() + .filter(move |(key, _)| key == name) + .map(|(_, value)| value.as_str()) + } + + /// Returns an integer parameter within `min..=max`, or `default` when absent. + fn integer(&self, name: &str, default: i64, min: i64, max: i64, errors: &mut FieldErrors) -> i64 { + let Some(raw) = self.single(name) else { + return default; + }; + match raw.parse::().map(i64::from) { + Ok(value) if (min..=max).contains(&value) => value, + _ => { + errors.add(name, format!("{name} must be an integer from {min} to {max}")); + default + } + } + } + + /// Returns a date-time parameter in Unix nanoseconds. + fn date_time(&self, name: &str, errors: &mut FieldErrors) -> Option { + let raw = self.single(name)?; + let time = parse_date_time(raw); + if time.is_none() { + errors.add(name, format!("{name} must be a date-time")); + } + time + } +} + +// ==== Paging ==== + +/// The paging parameters every list operation of DVLS's OpenAPI document takes. +struct Paging { + page_number: u64, + page_size: u64, + ascending: bool, + sort_field: &'static str, +} + +impl Paging { + /// Parses `pageNumber`, `pageSize` (at most `max_page_size`), `sortOrder` (1 ascending, 0 descending) and + /// `sortField` (one of `sort_fields`, the first being the default). + fn parse(query: &QueryParams, max_page_size: i64, sort_fields: &[&'static str], errors: &mut FieldErrors) -> Self { + let page_number = query.integer("pageNumber", 1, 1, i64::from(i32::MAX), errors); + let page_size = query.integer("pageSize", DEFAULT_PAGE_SIZE, 1, max_page_size, errors); + let sort_order = query.integer("sortOrder", 1, 0, 1, errors); + let sort_field = match query.single("sortField") { + None => sort_fields[0], + Some(raw) => match sort_fields.iter().find(|field| **field == raw) { + Some(field) => field, + None => { + errors.add( + "sortField", + format!("sortField must be one of {}", sort_fields.join(", ")), + ); + sort_fields[0] + } + }, + }; + Self { + page_number: u64::try_from(page_number).expect("pageNumber is at least 1"), + page_size: u64::try_from(page_size).expect("pageSize is at least 1"), + ascending: sort_order == 1, + sort_field, + } + } + + /// Returns the requested page of `items`, already sorted, as a `PagedResultOf*`; a page past the last is invalid. + fn page(&self, items: Vec) -> Result { + let total_count = items.len() as u64; + let total_page = total_count.div_ceil(self.page_size); + let last_page = total_page.max(1); + if last_page < self.page_number { + return Err(ManagementError::invalid( + "pageNumber", + format!("pageNumber must be at most {last_page}"), + )); + } + let skip = (self.page_number - 1) * self.page_size; + let data: Vec = items + .into_iter() + .skip(usize::try_from(skip).expect("the skipped items exist")) + .take(usize::try_from(self.page_size).unwrap_or(usize::MAX)) + .collect(); + Ok(json!({ + "currentPage": self.page_number, + "data": data, + "pageSize": self.page_size, + "totalCount": total_count, + "totalPage": total_page, + })) + } +} + +// ==== Enrollment tokens ==== + +/// Returns an `AgentIdentityEnrollmentToken`. +fn token_view(token: &Token, now: i64) -> Value { + let mut view = json!({ + "id": token.id, + "name": token.name, + "maxUses": token.max_uses, + "useCount": token.used_count, + "expiresOn": rfc3339_nanos(token.expires_at_ns), + "state": token.state(now), + "createdOn": rfc3339(token.created_at), + "createdBy": token.created_by, + }); + if let Some(format) = &token.friendly_name_format { + view["friendlyNameFormat"] = json!(format); + } + if let Some(config) = &token.config { + view["config"] = config.clone(); + } + view +} + +/// Checks `AgentIdentityEnrollmentTokenCreateDTO.config`: at most 32 entries, each key matching +/// `^[a-z][a-z0-9_]{0,63}$` and other than `version`, `revision` and `agent_channel_url`, each value a string of at most +/// 1024 UTF-8 bytes without control characters. +fn validate_token_config(config: &Value, errors: &mut FieldErrors) { + let Some(entries) = config.as_object() else { + errors.add("config", "config must be an object"); + return; + }; + if MAX_TOKEN_CONFIG_ENTRIES < entries.len() { + errors.add( + "config", + format!("config allows at most {MAX_TOKEN_CONFIG_ENTRIES} entries"), + ); + } + for (key, value) in entries { + let field = format!("config.{key}"); + if !is_config_key(key) { + errors.add(&field, "config key must match ^[a-z][a-z0-9_]{0,63}$"); + } else if matches!(key.as_str(), "version" | "revision" | "agent_channel_url") { + errors.add(&field, "config key is reserved"); + } + match value.as_str() { + None => errors.add(&field, "config value must be a string"), + Some(text) if MAX_TOKEN_CONFIG_VALUE_BYTES < text.len() => { + errors.add( + &field, + format!("config value must be at most {MAX_TOKEN_CONFIG_VALUE_BYTES} bytes"), + ); + } + Some(text) if text.chars().any(char::is_control) => { + errors.add(&field, "config value must not contain control characters"); + } + Some(_) => {} + } + } +} + +fn is_config_key(key: &str) -> bool { + let mut chars = key.chars(); + chars.next().is_some_and(|first| first.is_ascii_lowercase()) + && key.len() <= MAX_TOKEN_CONFIG_KEY_BYTES + && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +/// `AgentIdentityPublicApi_CreateAgentIdentityEnrollmentToken`. +async fn create_token(AxumState(app): AxumState>, body: Bytes) -> ManagementResult { + app.tick().await; + let body = parse_body(&body, &["name", "maxUses", "expiresOn", "friendlyNameFormat", "config"])?; + let now = app.now(); + let mut errors = FieldErrors::default(); + + let name = string_member(&body, "name", &mut errors); + if member(&body, "name").is_none() { + errors.add("name", "name is required"); + } + if name.is_some_and(|name| !(1..=MAX_TOKEN_NAME_CHARS).contains(&name.chars().count()) || name.trim().is_empty()) { + errors.add("name", "name must be 1 to 255 Unicode scalar values, not blank"); + } + let max_uses = integer_member(&body, "maxUses", 1, MAX_TOKEN_USES, &mut errors); + if member(&body, "maxUses").is_none() { + errors.add("maxUses", "maxUses is required"); + } + let expires_at_ns = match date_time_member(&body, "expiresOn", &mut errors) { + None => nanos(now + DEFAULT_TOKEN_LIFETIME_SECS), + Some(expires_at_ns) => { + if !(nanos(now) < expires_at_ns && expires_at_ns <= nanos(now + MAX_TOKEN_LIFETIME_SECS)) { + errors.add( + "expiresOn", + "expiresOn must be in the future and at most 365 days ahead", + ); + } + expires_at_ns + } + }; + let friendly_name_format = string_member(&body, "friendlyNameFormat", &mut errors); + if let Some(format) = friendly_name_format { + if MAX_FRIENDLY_NAME_FORMAT_CHARS < format.chars().count() { + errors.add( + "friendlyNameFormat", + format!("friendlyNameFormat must be at most {MAX_FRIENDLY_NAME_FORMAT_CHARS} Unicode scalar values"), + ); + } else if let Err(message) = crate::name_eval::validate_friendly_name_format(format) { + errors.add("friendlyNameFormat", message); + } + } + let config = member(&body, "config"); + if let Some(config) = config { + validate_token_config(config, &mut errors); + } + errors.finish()?; + let name = name.expect("a missing name is a field error"); + let max_uses = max_uses.expect("a missing maxUses is a field error"); + + let mut state = app.state.lock().await; + if !config_fits_envelope(&state.enrollment_config(config, &app.base_url)) { + return Err(ManagementError::invalid( + "config", + "config would not fit a 64 KiB agent channel envelope", + )); + } + state.ensure_root(now)?; + let (id, secret) = state.create_token( + name.to_owned(), + u64::try_from(max_uses).expect("maxUses is at least 1"), + expires_at_ns, + friendly_name_format.map(ToOwned::to_owned), + config.cloned(), + now, + ); + let view = token_view(&state.tokens[&id], now); + drop(state); + + let token = crate::api_agent::enrollment_token(&app.base_url, &secret); + Ok(( + StatusCode::CREATED, + Json(json!({ "enrollmentToken": view, "token": token })), + ) + .into_response()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens`. +async fn list_tokens( + AxumState(app): AxumState>, + query: Result>, QueryRejection>, +) -> ManagementResult { + app.tick().await; + let query = QueryParams::new(query)?; + let mut errors = FieldErrors::default(); + let paging = Paging::parse(&query, MAX_TOKEN_PAGE_SIZE, &TOKEN_SORT_FIELDS, &mut errors); + errors.finish()?; + + let state = app.state.lock().await; + let now = app.now(); + let mut tokens: Vec<&Token> = state + .tokens + .values() + .filter(|token| token.deleted_at.is_none()) + .collect(); + match paging.sort_field { + "name" => tokens.sort_by(|a, b| { + a.name + .cmp(&b.name) + .then((a.created_at, a.created_seq).cmp(&(b.created_at, b.created_seq))) + }), + "expiresOn" => tokens.sort_by_key(|token| (token.expires_at_ns, token.created_at, token.created_seq)), + // The creation order breaks ties within a second. + _ => tokens.sort_by_key(|token| (token.created_at, token.created_seq)), + } + if !paging.ascending { + tokens.reverse(); + } + let views = tokens.into_iter().map(|token| token_view(token, now)).collect(); + Ok(Json(paging.page(views)?).into_response()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentityEnrollmentToken`. +async fn get_token(AxumState(app): AxumState>, Path(id): Path) -> ManagementResult { + app.tick().await; + let id = parse_id(&id, TOKEN_NOT_FOUND)?; + let state = app.state.lock().await; + let token = state + .tokens + .get(&id) + .filter(|token| token.deleted_at.is_none()) + .ok_or_else(|| ManagementError::not_found(TOKEN_NOT_FOUND))?; + Ok(Json(token_view(token, app.now())).into_response()) +} + +/// `AgentIdentityPublicApi_DeleteAgentIdentityEnrollmentToken`. +async fn delete_token(AxumState(app): AxumState>, Path(id): Path) -> ManagementResult { + app.tick().await; + let id = parse_id(&id, TOKEN_NOT_FOUND)?; + let mut state = app.state.lock().await; + let token = state + .tokens + .get_mut(&id) + .filter(|token| token.deleted_at.is_none()) + .ok_or_else(|| ManagementError::not_found(TOKEN_NOT_FOUND))?; + token.deleted_at = Some(app.now()); + Ok(StatusCode::NO_CONTENT.into_response()) +} + +// ==== Identities ==== + +fn last_seen_on(identity: &Identity) -> i64 { + identity.last_seen_at.unwrap_or(identity.created_at) +} + +/// Returns the members of an `AgentIdentity` that both views carry. +fn identity_view(state: &State, identity: &Identity, now: i64) -> Value { + let mut view = json!({ + "id": identity.id, + "friendlyName": identity.friendly_name, + "status": identity.status(now).as_str(), + "isConnected": state.is_connected(identity.id), + "lastSeenOn": rfc3339(last_seen_on(identity)), + }); + if let Some(current) = identity.current_cert() { + view["certificate"] = json!({ + "issuerThumbprint": current.issuer, + "validUntil": rfc3339(current.not_after), + }); + } + view +} + +/// Returns an `AgentIdentity` in the Summary view, with the requested metadata keys it has. +fn identity_summary(state: &State, identity: &Identity, now: i64, metadata_keys: &[&str]) -> Value { + let mut view = identity_view(state, identity, now); + let metadata: Map = metadata_keys + .iter() + .filter_map(|key| { + identity + .metadata + .get(*key) + .map(|value| ((*key).to_owned(), value.clone())) + }) + .collect(); + if !metadata.is_empty() { + view["metadata"] = Value::Object(metadata); + } + view +} + +/// Returns an `AgentIdentityCertificate`. +fn certificate_view(cert: &Cert) -> Value { + json!({ + "issuerThumbprint": cert.issuer, + "serialNumber": cert.serial, + "status": match cert.status { + CertStatus::Pending => "Pending", + CertStatus::Current => "Current", + CertStatus::Retired => "Retired", + }, + "thumbprint": cert.thumbprint, + "validFrom": rfc3339(cert.not_before), + "validUntil": rfc3339(cert.not_after), + }) +} + +/// Returns an `AgentIdentity` in the Full view. +fn identity_full(state: &State, identity: &Identity, now: i64) -> Value { + let mut view = identity_view(state, identity, now); + view["metadata"] = Value::Object(identity.metadata.clone()); + view["certificates"] = identity.certs.iter().rev().map(certificate_view).collect(); + view["createdOn"] = json!(rfc3339(identity.created_at)); + view["enrollmentTokenId"] = json!(identity.token_id); + view["enrollmentTokenName"] = json!(identity.token_name); + view["isRenewalRequested"] = json!(identity.renewal_requested.is_some()); + if let Some(revoked_at) = identity.revoked_at { + view["revokedOn"] = json!(rfc3339(revoked_at)); + } + view +} + +/// `AgentIdentityPublicApi_GetAgentIdentities`. +async fn list_identities( + AxumState(app): AxumState>, + query: Result>, QueryRejection>, +) -> ManagementResult { + app.tick().await; + let query = QueryParams::new(query)?; + let mut errors = FieldErrors::default(); + let paging = Paging::parse(&query, MAX_IDENTITY_PAGE_SIZE, &IDENTITY_SORT_FIELDS, &mut errors); + let full = match query.single("view") { + None | Some("Summary") => false, + Some("Full") => true, + Some(_) => { + errors.add("view", "view must be Summary or Full"); + false + } + }; + let metadata_keys: Vec<&str> = query.all("metadataKey").collect(); + let mut statuses = Vec::new(); + for raw in query.all("status") { + match IdentityStatus::parse(raw) { + Some(status) => statuses.push(status), + None => errors.add("status", "status must be Active, Grace, Expired or Revoked"), + } + } + let mut token_ids = Vec::new(); + for raw in query.all("enrollmentTokenId") { + match raw.parse::() { + Ok(id) => token_ids.push(id), + Err(_) => errors.add("enrollmentTokenId", "enrollmentTokenId must be a GUID"), + } + } + let issuer = query.single("issuerThumbprint"); + let last_seen_from = query.date_time("lastSeenFrom", &mut errors); + let last_seen_to = query.date_time("lastSeenTo", &mut errors); + let search = query.single("search").map(str::to_lowercase); + errors.finish()?; + + let state = app.state.lock().await; + let now = app.now(); + let mut identities: Vec<&Identity> = state + .identities + .values() + .filter(|identity| { + (statuses.is_empty() || statuses.contains(&identity.status(now))) + && (token_ids.is_empty() || token_ids.contains(&identity.token_id)) + && issuer.is_none_or(|issuer| identity.current_cert().is_some_and(|cert| cert.issuer == issuer)) + && last_seen_from.is_none_or(|from| from <= nanos(last_seen_on(identity))) + && last_seen_to.is_none_or(|to| nanos(last_seen_on(identity)) <= to) + && search + .as_deref() + .is_none_or(|search| identity.friendly_name.to_lowercase().contains(search)) + }) + .collect(); + identities.sort_by_key(|identity| identity.created_seq); + if !paging.ascending { + identities.reverse(); + } + let views = identities + .into_iter() + .map(|identity| { + if full { + identity_full(&state, identity, now) + } else { + identity_summary(&state, identity, now, &metadata_keys) + } + }) + .collect(); + Ok(Json(paging.page(views)?).into_response()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentity`. +async fn get_identity(AxumState(app): AxumState>, Path(id): Path) -> ManagementResult { + app.tick().await; + let id = parse_id(&id, IDENTITY_NOT_FOUND)?; + let state = app.state.lock().await; + let identity = state + .identities + .get(&id) + .ok_or_else(|| ManagementError::not_found(IDENTITY_NOT_FOUND))?; + Ok(Json(identity_full(&state, identity, app.now())).into_response()) +} + +/// Checks `AgentIdentityPatchDTO.friendlyName`: 1 to 255 Unicode scalar values that do not start or end with white +/// space. +fn is_valid_friendly_name(name: &str) -> bool { + (1..=crate::name_eval::MAX_FRIENDLY_NAME_CHARS).contains(&name.chars().count()) + && !name.starts_with(char::is_whitespace) + && !name.ends_with(char::is_whitespace) +} + +/// `AgentIdentityPublicApi_PatchAgentIdentity`. +async fn patch_identity(AxumState(app): AxumState>, Path(id): Path, body: Bytes) -> ManagementResult { + app.tick().await; + let body = parse_body(&body, &["friendlyName"])?; + let mut errors = FieldErrors::default(); + let friendly_name = string_member(&body, "friendlyName", &mut errors); + if friendly_name.is_some_and(|name| !is_valid_friendly_name(name)) { + errors.add( + "friendlyName", + "friendlyName must be 1 to 255 Unicode scalar values that do not start or end with white space", + ); + } + errors.finish()?; + let id = parse_id(&id, IDENTITY_NOT_FOUND)?; + + let mut state = app.state.lock().await; + let identity = state + .identities + .get_mut(&id) + .ok_or_else(|| ManagementError::not_found(IDENTITY_NOT_FOUND))?; + if let Some(name) = friendly_name { + name.clone_into(&mut identity.friendly_name); + } + let identity = &state.identities[&id]; + Ok(Json(identity_full(&state, identity, app.now())).into_response()) +} + +/// `AgentIdentityPublicApi_RevokeAgentIdentity`; revocation closes live streams with `identity_revoked`. +async fn revoke_identity(AxumState(app): AxumState>, Path(id): Path) -> ManagementResult { + app.tick().await; + let id = parse_id(&id, IDENTITY_NOT_FOUND)?; + let now = app.now(); + app.state.lock().await.revoke(id, now)?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +/// `AgentIdentityPublicApi_DeleteAgentIdentity`. +async fn delete_identity(AxumState(app): AxumState>, Path(id): Path) -> ManagementResult { + app.tick().await; + let id = parse_id(&id, IDENTITY_NOT_FOUND)?; + let now = app.now(); + app.state.lock().await.delete_identity(id, now)?; + Ok(StatusCode::NO_CONTENT.into_response()) +} + +/// `AgentIdentityPublicApi_RequestAgentIdentityRenewal`. +async fn request_renewal(AxumState(app): AxumState>, Path(id): Path) -> ManagementResult { + app.tick().await; + let id = parse_id(&id, IDENTITY_NOT_FOUND)?; + app.state.lock().await.request_renewal(id)?; + Ok(StatusCode::ACCEPTED.into_response()) +} + +// ==== Rotation ==== + +/// Returns an `AgentIdentityRotation`. +fn rotation_view(state: &State, now: i64) -> Value { + let Some(rotation) = &state.rotation else { + return json!({ "phase": "Idle", "oldRootActiveIdentityCount": 0 }); + }; + let root_view = |thumbprint: &str| { + state + .roots + .iter() + .find(|root| root.thumbprint == thumbprint) + .map(|root| { + json!({ + "thumbprint": root.thumbprint, + "validFrom": rfc3339(root.not_before), + "validUntil": rfc3339(root.not_after), + }) + }) + }; + json!({ + "phase": "Rotating", + "startedOn": rfc3339(rotation.started_at), + "deadlineOn": rfc3339_nanos(rotation.deadline_ns), + "oldRoot": root_view(&rotation.old_root), + "newRoot": root_view(&rotation.new_root), + "oldRootActiveIdentityCount": state.active_identities_on_old_root(now), + }) +} + +/// `AgentIdentityPublicApi_StartAgentIdentityRotation`. +async fn start_rotation(AxumState(app): AxumState>, body: Bytes) -> ManagementResult { + app.tick().await; + let body = parse_body(&body, &["deadlineOn"])?; + let mut errors = FieldErrors::default(); + let deadline = date_time_member(&body, "deadlineOn", &mut errors); + errors.finish()?; + + let now = app.now(); + let mut state = app.state.lock().await; + state.start_rotation(deadline, now)?; + Ok((StatusCode::ACCEPTED, Json(rotation_view(&state, now))).into_response()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentityRotation`. +async fn get_rotation(AxumState(app): AxumState>) -> ManagementResult { + app.tick().await; + let state = app.state.lock().await; + Ok(Json(rotation_view(&state, app.now())).into_response()) +} + +// ==== Settings ==== + +/// Returns `AgentIdentitySettings`. +fn settings_view(app: &App, state: &State) -> Value { + json!({ + "authorityId": app.authority_id, + "isAgentChannelEnabled": state.settings.agent_channel_enabled, + "isAgentChannelAvailable": state.channel_offered(), + "certificateLifetimeDays": state.settings.certificate_lifetime_days, + "rotationNotificationsPerSecond": state.settings.rotation_notifications_per_second, + }) +} + +/// `AgentIdentityPublicApi_GetAgentIdentitySettings`. +async fn get_settings(AxumState(app): AxumState>) -> ManagementResult { + app.tick().await; + let state = app.state.lock().await; + Ok(Json(settings_view(&app, &state)).into_response()) +} + +/// `AgentIdentityPublicApi_PatchAgentIdentitySettings`. +async fn patch_settings(AxumState(app): AxumState>, body: Bytes) -> ManagementResult { + app.tick().await; + let body = parse_body( + &body, + &[ + "isAgentChannelEnabled", + "certificateLifetimeDays", + "rotationNotificationsPerSecond", + ], + )?; + let mut errors = FieldErrors::default(); + let channel_enabled = bool_member(&body, "isAgentChannelEnabled", &mut errors); + let lifetime_days = integer_member( + &body, + "certificateLifetimeDays", + 1, + MAX_CERTIFICATE_LIFETIME_DAYS, + &mut errors, + ); + let notifications_per_second = integer_member( + &body, + "rotationNotificationsPerSecond", + 1, + MAX_ROTATION_NOTIFICATIONS_PER_SECOND, + &mut errors, + ); + errors.finish()?; + + let mut state = app.state.lock().await; + if let Some(enabled) = channel_enabled { + state + .update_agent_channel_enabled(enabled, &app.base_url) + .map_err(|error| ManagementError::invalid("isAgentChannelEnabled", error.message))?; + } + if let Some(days) = lifetime_days { + state.settings.certificate_lifetime_days = u32::try_from(days).expect("certificateLifetimeDays is in range"); + } + if let Some(rate) = notifications_per_second { + state.settings.rotation_notifications_per_second = + u32::try_from(rate).expect("rotationNotificationsPerSecond is in range"); + } + Ok(Json(settings_view(&app, &state)).into_response()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/agent-identity-mock/src/api_admin/tests.rs b/crates/agent-identity-mock/src/api_admin/tests.rs new file mode 100644 index 000000000..66e33fc8d --- /dev/null +++ b/crates/agent-identity-mock/src/api_admin/tests.rs @@ -0,0 +1,1698 @@ +//! Each test cites the operation (by `operationId`) or schema of DVLS's OpenAPI document that it pins. + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context as _; +use axum::body::{Body, Bytes}; +use axum::http::header::CONTENT_TYPE; +use axum::http::{Method, Request}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use rstest::rstest; +use serde_json::{Value, json}; +use tokio::sync::mpsc::UnboundedReceiver; +use tower::ServiceExt as _; +use uuid::Uuid; + +use super::{BASE_PATH, Permission, Permissions, TOKEN_ID_HEADER, key_permissions, router}; +use crate::app::{App, nanos, rfc3339}; +use crate::state::{State, StreamPush, config_update_len}; + +const APP_KEY: &str = "admin"; +const APP_SECRET: &str = "secret"; +const DAY: i64 = 24 * 3600; + +/// CONTRACT.agent-identity.md §7.1: each side sends channel envelopes "up to 64 KiB (65,536 bytes) of serialized +/// protobuf". +const ENVELOPE_LIMIT: usize = 65_536; + +/// Returns an app whose clock is frozen at its current second, and that second. +fn frozen_app() -> anyhow::Result<(Arc, i64)> { + let app = App::for_test()?; + let now = app.now(); + app.clock.freeze_at(now); + Ok((app, now)) +} + +/// A response's status, `Content-Type` and raw body. +type RawResponse = (u16, Option, Bytes); + +/// Sends a request to `uri` and returns the status and the raw body. +async fn request( + app: &Arc, + method: Method, + uri: &str, + headers: &[(&str, &str)], + body: Option, +) -> anyhow::Result<(u16, Bytes)> { + let (status, _, body) = exchange(app, method, uri, headers, body).await?; + Ok((status, body)) +} + +/// Sends a request to `uri` and returns the status, the `Content-Type` and the raw body. +async fn exchange( + app: &Arc, + method: Method, + uri: &str, + headers: &[(&str, &str)], + body: Option, +) -> anyhow::Result { + let mut request = Request::builder().method(method).uri(uri); + for (name, value) in headers { + request = request.header(*name, *value); + } + let request = request.body(body.map_or_else(Body::empty, Body::from))?; + let response = router(Arc::clone(app)).oneshot(request).await?; + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(CONTENT_TYPE) + .map(|value| value.to_str()) + .transpose()? + .map(str::to_owned); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?; + Ok((status, content_type, bytes)) +} + +/// Sends a management API request and returns the status and the JSON body, `null` when empty. +async fn send( + app: &Arc, + method: Method, + path: &str, + headers: &[(&str, &str)], + body: Option, +) -> anyhow::Result<(u16, Value)> { + let (status, bytes) = request(app, method, &format!("{BASE_PATH}{path}"), headers, body).await?; + let body = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes)? + }; + Ok((status, body)) +} + +/// Posts `body` to DVLS's `LoginPublicApi_Login` and returns the status and the raw body. +async fn post_login(app: &Arc, body: Value) -> anyhow::Result<(u16, Bytes)> { + request(app, Method::POST, "/api/v1/login", &[], Some(body.to_string())).await +} + +/// Logs `app_key` in with the application secret and returns the session's `tokenId`. +async fn login(app: &Arc, app_key: &str) -> anyhow::Result { + let (status, body) = post_login(app, json!({ "appKey": app_key, "appSecret": APP_SECRET })).await?; + anyhow::ensure!(status == 200, "login of {app_key} failed with {status}"); + let body: Value = serde_json::from_slice(&body)?; + Ok(body["tokenId"] + .as_str() + .context("LoginResult has a tokenId")? + .to_owned()) +} + +/// Sends a request in a session holding every permission, with an optional JSON body. +async fn call(app: &Arc, method: Method, path: &str, body: Option) -> anyhow::Result<(u16, Value)> { + let token_id = login(app, APP_KEY).await?; + send( + app, + method, + path, + &[(TOKEN_ID_HEADER, &token_id)], + body.map(|body| body.to_string()), + ) + .await +} + +/// Reads the settings in the session `token_id` names, an operation any session with View can call. +async fn get_settings(app: &Arc, token_id: &str) -> anyhow::Result<(u16, Value)> { + send(app, Method::GET, "/settings", &[(TOKEN_ID_HEADER, token_id)], None).await +} + +/// Reads the settings, a route that requires a session, with `headers` and returns the raw response. +async fn get_settings_raw(app: &Arc, headers: &[(&str, &str)]) -> anyhow::Result { + exchange(app, Method::GET, &format!("{BASE_PATH}/settings"), headers, None).await +} + +/// Posts DVLS's `LoginPublicApi_LogOut` with `headers` and returns the raw response. +async fn logout(app: &Arc, headers: &[(&str, &str)]) -> anyhow::Result { + exchange(app, Method::POST, "/api/v1/logout", headers, None).await +} + +// The following responses are DVLS's, as measured on DVLS built from `Devolutions/RDM@c642866388b0`. + +/// DVLS's `401` to a route that requires a session when the request has no `tokenId` header. +fn no_token_id_401() -> RawResponse { + (401, None, Bytes::new()) +} + +/// DVLS's `401` to a route that requires a session when the request's `tokenId` names no open session. +fn invalid_token_id_401() -> RawResponse { + ( + 401, + Some("application/json; charset=utf-8".to_owned()), + Bytes::from_static(br#"{"message":"Missing or invalid token"}"#), + ) +} + +/// DVLS's response to `LoginPublicApi_LogOut`, whether or not the `tokenId` header names an open session. +fn logout_200() -> RawResponse { + (200, None, Bytes::new()) +} + +/// Returns the `field` of each `PublicApiFieldError` in a `PublicApiValidationError`. +fn error_fields(body: &Value) -> Vec { + body["errors"] + .as_array() + .expect("PublicApiValidationError has errors") + .iter() + .map(|error| { + assert!(error["message"].is_string(), "PublicApiFieldError has a message"); + error["field"].clone() + }) + .collect() +} + +/// Asserts a `PublicApiError` body: exactly one string `message`. +fn assert_public_api_error(body: &Value) { + let members = body.as_object().expect("PublicApiError is an object"); + assert_eq!(members.len(), 1, "{body}"); + assert!(members["message"].is_string(), "{body}"); +} + +/// Enrolls an identity with the CSR test vector and `metadata`, as the agent-facing enroll does. +/// An app holds at most one, since a key is enrolled once. +async fn enroll(app: &Arc, metadata: Value) -> anyhow::Result { + Ok(enroll_with_chain(app, metadata).await?.0) +} + +/// Enrolls like [`enroll`] and also returns the issued certificate chain, leaf first, in DER. +async fn enroll_with_chain(app: &Arc, metadata: Value) -> anyhow::Result<(Uuid, Vec>)> { + let vectors: Value = serde_json::from_str(include_str!("../../../../docs/agent-identity/test-vectors.json"))?; + let csr = STANDARD.decode(vectors["csr"]["valid"]["csr"].as_str().expect("valid CSR vector"))?; + let now = app.now(); + let mut state = app.state.lock().await; + let (_, secret) = state.create_token("test".to_owned(), 1, nanos(now + DAY), None, None, now); + let metadata = metadata.as_object().cloned().expect("metadata is an object"); + let enrolled = state + .enroll(&secret, &csr, metadata, now, &app.base_url) + .expect("enrollment succeeds"); + Ok((enrolled.identity_id, enrolled.certificate_chain)) +} + +/// Returns an app in the state a fresh mock process starts in, which `main` builds with [`State::new`]. +async fn pristine_app() -> anyhow::Result<(Arc, i64)> { + let (app, now) = frozen_app()?; + *app.state.lock().await = State::new(now); + Ok((app, now)) +} + +/// Returns `.5Z`, half a second after `unix_secs`. +fn half_second_after(unix_secs: i64) -> String { + format!("{}.5Z", rfc3339(unix_secs).trim_end_matches('Z')) +} + +async fn create_token(app: &Arc, body: Value) -> anyhow::Result { + let (status, created) = call(app, Method::POST, "/enrollment-token", Some(body)).await?; + assert_eq!(status, 201, "{created}"); + Ok(created["enrollmentToken"]["id"].as_str().expect("token ID").to_owned()) +} + +fn names(page: &Value) -> Vec<&str> { + page["data"] + .as_array() + .expect("page data") + .iter() + .map(|item| item["name"].as_str().expect("token name")) + .collect() +} + +fn config_entries(count: usize) -> Value { + (0..count).map(|i| (format!("key_{i}"), json!("v"))).collect() +} + +// ==== Authentication and permissions ==== + +/// `LoginPublicApi_Login`: "Login successful, returns the tokenId to use in the header on the following requests", in +/// a `LoginResult` whose only member is `tokenId`. +// oracle: spec DVLS public OpenAPI document §LoginPublicApi_Login +#[tokio::test] +async fn login_returns_a_login_result() -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, body) = post_login(&app, json!({ "appKey": APP_KEY, "appSecret": APP_SECRET })).await?; + assert_eq!(status, 200); + let body: Value = serde_json::from_slice(&body)?; + let members = body.as_object().expect("LoginResult is an object"); + assert_eq!(members.len(), 1, "{body}"); + assert!(members["tokenId"].is_string(), "{body}"); + Ok(()) +} + +/// `LoginPublicApi_Login` "returns the tokenId to use in the header on the following requests". +// oracle: spec DVLS public OpenAPI document §LoginPublicApi_Login +#[tokio::test] +async fn login_token_id_authenticates_requests() -> anyhow::Result<()> { + let app = App::for_test()?; + let token_id = login(&app, APP_KEY).await?; + assert_eq!(get_settings(&app, &token_id).await?.0, 200); + Ok(()) +} + +/// Each login opens another session with a new `tokenId`, and earlier sessions stay open. +// oracle: reference DVLS built from Devolutions/RDM@c642866388b0 +#[tokio::test] +async fn each_login_opens_another_session() -> anyhow::Result<()> { + let app = App::for_test()?; + let first = login(&app, APP_KEY).await?; + let second = login(&app, APP_KEY).await?; + assert_ne!(first, second); + assert_eq!(get_settings(&app, &first).await?.0, 200); + assert_eq!(get_settings(&app, &second).await?.0, 200); + Ok(()) +} + +/// A DVLS `tokenId` expires 7 minutes after its login, however often it is used, and then gets DVLS's `401` for an +/// unknown one; a new login opens another session. +/// The lifetime runs on real time, which the mock clock that tests advance by days doesn't move. +// oracle: reference DVLS built from Devolutions/RDM@c642866388b0 +#[tokio::test(start_paused = true)] +async fn token_id_expires_its_lifetime_after_login() -> anyhow::Result<()> { + const DVLS_TOKEN_LIFETIME: Duration = Duration::from_secs(7 * 60); + let app = App::for_test()?; + let token_id = login(&app, APP_KEY).await?; + + app.clock.advance_by(30 * DAY); + tokio::time::advance(DVLS_TOKEN_LIFETIME - Duration::from_secs(1)).await; + assert_eq!(get_settings(&app, &token_id).await?.0, 200); + tokio::time::advance(Duration::from_secs(1)).await; + let response = get_settings_raw(&app, &[(TOKEN_ID_HEADER, &token_id)]).await?; + assert_eq!(response, invalid_token_id_401()); + + let token_id = login(&app, APP_KEY).await?; + assert_eq!(get_settings(&app, &token_id).await?.0, 200); + Ok(()) +} + +/// `LoginPublicApi_LogOut` "closes the session of the token sent in the tokenId header" with DVLS's logout response, +/// after which that `tokenId` gets DVLS's `401` for an unknown one; other sessions stay open. +// oracle: spec DVLS public OpenAPI document §LoginPublicApi_LogOut +// oracle: reference DVLS built from Devolutions/RDM@c642866388b0 +#[tokio::test] +async fn logout_closes_its_session_only() -> anyhow::Result<()> { + let app = App::for_test()?; + let closed = login(&app, APP_KEY).await?; + let open = login(&app, APP_KEY).await?; + assert_eq!(logout(&app, &[(TOKEN_ID_HEADER, &closed)]).await?, logout_200()); + let response = get_settings_raw(&app, &[(TOKEN_ID_HEADER, &closed)]).await?; + assert_eq!(response, invalid_token_id_401()); + assert_eq!(get_settings(&app, &open).await?.0, 200); + Ok(()) +} + +/// `LoginPublicApi_LogOut` answers like DVLS when the `tokenId` header names no session or is missing. +// oracle: reference DVLS built from Devolutions/RDM@c642866388b0 +#[rstest] +#[case::missing(&[])] +#[case::unknown(&[(TOKEN_ID_HEADER, "other")])] +#[tokio::test] +async fn logout_without_a_session_is_200_without_body(#[case] headers: &[(&str, &str)]) -> anyhow::Result<()> { + let app = App::for_test()?; + assert_eq!(logout(&app, headers).await?, logout_200()); + Ok(()) +} + +/// `LoginPublicApi_LogOut` is idempotent: logging out a session again answers like the first logout. +// oracle: reference DVLS built from Devolutions/RDM@c642866388b0 +#[tokio::test] +async fn logout_is_idempotent() -> anyhow::Result<()> { + let app = App::for_test()?; + let token_id = login(&app, APP_KEY).await?; + let headers = [(TOKEN_ID_HEADER, token_id.as_str())]; + logout(&app, &headers).await?; + assert_eq!(logout(&app, &headers).await?, logout_200()); + Ok(()) +} + +/// `LoginPublicApi_Login` answers a failed login with `401` and a `PublicApiError`. +/// The application key, its scoped keys and its `-unprivileged` key log in with the application secret only. +// oracle: spec DVLS public OpenAPI document §LoginPublicApi_Login +#[rstest] +#[case::wrong_secret(APP_KEY, "wrong")] +#[case::scoped_key_wrong_secret("admin.view", "wrong")] +#[case::unknown_key("other", APP_SECRET)] +#[tokio::test] +async fn failed_login_is_401(#[case] app_key: &str, #[case] app_secret: &str) -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, body) = post_login(&app, json!({ "appKey": app_key, "appSecret": app_secret })).await?; + assert_eq!(status, 401); + assert_public_api_error(&serde_json::from_slice(&body)?); + Ok(()) +} + +/// `LoginPublicApi_Login` answers a "Missing appKey or appSecret" with `400` and no body. +/// Both members are nullable, and an empty one is missing too. +// oracle: spec DVLS public OpenAPI document §LoginPublicApi_Login +#[rstest] +#[case::missing_key(json!({ "appSecret": APP_SECRET }))] +#[case::missing_secret(json!({ "appKey": APP_KEY }))] +#[case::null_key(json!({ "appKey": null, "appSecret": APP_SECRET }))] +#[case::empty_key(json!({ "appKey": "", "appSecret": APP_SECRET }))] +#[case::empty_secret(json!({ "appKey": APP_KEY, "appSecret": "" }))] +#[tokio::test] +async fn login_without_key_or_secret_is_400_without_body(#[case] credentials: Value) -> anyhow::Result<()> { + let app = App::for_test()?; + let response = post_login(&app, credentials).await?; + assert_eq!(response, (400, Bytes::new())); + Ok(()) +} + +/// `LoginParameters` sets `additionalProperties: false` in DVLS's public OpenAPI document, so a member it does not +/// define is a `400` without a body, even beside valid credentials. +// oracle: spec DVLS public OpenAPI document §LoginParameters +#[tokio::test] +async fn login_refuses_a_member_outside_login_parameters() -> anyhow::Result<()> { + let app = App::for_test()?; + let credentials = json!({ "appKey": APP_KEY, "appSecret": APP_SECRET, "unexpected": true }); + let response = post_login(&app, credentials).await?; + assert_eq!(response, (400, Bytes::new())); + Ok(()) +} + +// oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control +#[rstest] +#[case::app_key("admin", &[Permission::View, Permission::Manage, Permission::Revoke, Permission::RotateRoot])] +#[case::unprivileged("admin-unprivileged", &[])] +#[case::scope_none("admin.none", &[])] +#[case::scope_one("admin.rotate-root", &[Permission::RotateRoot])] +#[case::scope_several("admin.view+revoke", &[Permission::View, Permission::Revoke])] +fn key_holds_its_permissions(#[case] key: &str, #[case] expected: &[Permission]) { + let held = key_permissions("admin", key).expect("key is valid"); + let held: Vec = Permission::ALL.into_iter().filter(|p| held.contains(*p)).collect(); + assert_eq!(held, expected); +} + +// oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control +#[rstest] +#[case::empty("")] +#[case::other_key("other")] +#[case::longer_key("admin2")] +#[case::empty_scope("admin.")] +#[case::unknown_permission("admin.view+audit")] +#[case::trailing_separator("admin.view+")] +#[case::display_name("admin.View")] +fn invalid_key_is_refused(#[case] key: &str) { + assert_eq!(key_permissions("admin", key), None::); +} + +/// Each operation's `403` response in DVLS's OpenAPI document names the permission the operation requires, and a +/// session holds the permissions of the application key that logged in. +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Management API +#[rstest] +#[case::get_identities(Method::GET, "", "view", "manage+revoke+rotate-root", "AgentIdentityView")] +#[case::get_identity(Method::GET, "/{id}", "view", "manage+revoke+rotate-root", "AgentIdentityView")] +#[case::patch_identity(Method::PATCH, "/{id}", "manage", "view+revoke+rotate-root", "AgentIdentityManage")] +#[case::delete_identity(Method::DELETE, "/{id}", "manage", "view+revoke+rotate-root", "AgentIdentityManage")] +#[case::revoke_identity( + Method::POST, + "/{id}/revoke", + "revoke", + "view+manage+rotate-root", + "AgentIdentityRevoke" +)] +#[case::request_renewal( + Method::POST, + "/{id}/request-renewal", + "manage", + "view+revoke+rotate-root", + "AgentIdentityManage" +)] +#[case::create_token( + Method::POST, + "/enrollment-token", + "manage", + "view+revoke+rotate-root", + "AgentIdentityManage" +)] +#[case::get_tokens( + Method::GET, + "/enrollment-token", + "view", + "manage+revoke+rotate-root", + "AgentIdentityView" +)] +#[case::get_token( + Method::GET, + "/enrollment-token/{id}", + "view", + "manage+revoke+rotate-root", + "AgentIdentityView" +)] +#[case::delete_token( + Method::DELETE, + "/enrollment-token/{id}", + "manage", + "view+revoke+rotate-root", + "AgentIdentityManage" +)] +#[case::get_rotation(Method::GET, "/rotation", "view", "manage+revoke+rotate-root", "AgentIdentityView")] +#[case::start_rotation( + Method::POST, + "/rotation", + "rotate-root", + "view+manage+revoke", + "AgentIdentityRotateRoot" +)] +#[case::get_settings(Method::GET, "/settings", "view", "manage+revoke+rotate-root", "AgentIdentityView")] +#[case::patch_settings( + Method::PATCH, + "/settings", + "manage", + "view+revoke+rotate-root", + "AgentIdentityManage" +)] +#[tokio::test] +async fn operation_requires_its_permission( + #[case] method: Method, + #[case] route: &str, + #[case] required: &str, + #[case] all_others: &str, + #[case] permission_name: &str, +) -> anyhow::Result<()> { + let app = App::for_test()?; + let path = route.replace("{id}", &Uuid::new_v4().to_string()); + + let refused = login(&app, &format!("{APP_KEY}.{all_others}")).await?; + let (status, body) = send(&app, method.clone(), &path, &[(TOKEN_ID_HEADER, &refused)], None).await?; + assert_eq!(status, 403); + assert_public_api_error(&body); + assert!( + body["message"] + .as_str() + .is_some_and(|message| message.contains(permission_name)), + "{body}" + ); + + let granted = login(&app, &format!("{APP_KEY}.{required}")).await?; + let (status, _) = send(&app, method, &path, &[(TOKEN_ID_HEADER, &granted)], None).await?; + assert!(!matches!(status, 401 | 403), "{status}"); + Ok(()) +} + +/// The `tokenId` header carries the credential, and header names are case-insensitive (RFC 9110, "5.1. Field Names"). +// oracle: spec RFC 9110 §5.1 +#[tokio::test] +async fn token_id_header_name_is_case_insensitive() -> anyhow::Result<()> { + let app = App::for_test()?; + let token_id = login(&app, APP_KEY).await?; + let (status, _) = send(&app, Method::GET, "/settings", &[("TOKENID", &token_id)], None).await?; + assert_eq!(status, 200); + Ok(()) +} + +/// A route that requires a session answers a request without a `tokenId` header like DVLS. +// oracle: reference DVLS built from Devolutions/RDM@c642866388b0 +#[tokio::test] +async fn missing_token_id_is_401_without_body() -> anyhow::Result<()> { + let app = App::for_test()?; + assert_eq!(get_settings_raw(&app, &[]).await?, no_token_id_401()); + Ok(()) +} + +/// A bearer `Authorization` header is no `tokenId` header, even when it carries an open session's `tokenId`. +// oracle: reference DVLS built from Devolutions/RDM@c642866388b0 +#[tokio::test] +async fn bearer_authorization_is_a_missing_token_id() -> anyhow::Result<()> { + let app = App::for_test()?; + let authorization = format!("Bearer {}", login(&app, APP_KEY).await?); + let response = get_settings_raw(&app, &[("authorization", &authorization)]).await?; + assert_eq!(response, no_token_id_401()); + Ok(()) +} + +/// A route that requires a session answers a `tokenId` that no login returned like DVLS, while another session is open. +// oracle: reference DVLS built from Devolutions/RDM@c642866388b0 +#[rstest] +#[case::unknown("other")] +#[case::app_key(APP_KEY)] +#[tokio::test] +async fn unknown_token_id_is_401_missing_or_invalid_token(#[case] token_id: &str) -> anyhow::Result<()> { + let app = App::for_test()?; + login(&app, APP_KEY).await?; + let response = get_settings_raw(&app, &[(TOKEN_ID_HEADER, token_id)]).await?; + assert_eq!(response, invalid_token_id_401()); + Ok(()) +} + +/// A request that repeats the `tokenId` header names no session, even when each header names the same open one. +// oracle: reference DVLS built from Devolutions/RDM@c642866388b0 +#[tokio::test] +async fn repeated_token_id_header_is_401_missing_or_invalid_token() -> anyhow::Result<()> { + let app = App::for_test()?; + let token_id = login(&app, APP_KEY).await?; + let headers = [ + (TOKEN_ID_HEADER, token_id.as_str()), + (TOKEN_ID_HEADER, token_id.as_str()), + ]; + assert_eq!(get_settings_raw(&app, &headers).await?, invalid_token_id_401()); + Ok(()) +} + +// ==== Enrollment tokens ==== + +/// `AgentIdentityPublicApi_CreateAgentIdentityEnrollmentToken` returns `201` with an +/// `AgentIdentityEnrollmentTokenCreated`, whose token `GetAgentIdentityEnrollmentToken` never returns. +/// `expiresOn` defaults to 90 days ahead, and a token without a friendly-name format or config omits them. +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_CreateAgentIdentityEnrollmentToken +#[tokio::test] +async fn create_token_returns_the_full_token_only_once() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (status, created) = call( + &app, + Method::POST, + "/enrollment-token", + Some(json!({ "name": "build agents", "maxUses": 5 })), + ) + .await?; + assert_eq!(status, 201); + assert_eq!(created.as_object().map(serde_json::Map::len), Some(2)); + // CONTRACT.agent-identity.md §2: an enrollment token is `dvaet1..`. + assert!( + created["token"] + .as_str() + .is_some_and(|token| token.starts_with("dvaet1.")), + "{created}" + ); + let id = created["enrollmentToken"]["id"].as_str().expect("token ID"); + let expected = json!({ + "id": id, + "name": "build agents", + "maxUses": 5, + "useCount": 0, + "expiresOn": rfc3339(now + 90 * DAY), + "state": "Active", + "createdOn": rfc3339(now), + "createdBy": "mock-admin", + }); + assert_eq!(created["enrollmentToken"], expected); + + let (status, read) = call(&app, Method::GET, &format!("/enrollment-token/{id}"), None).await?; + assert_eq!((status, read), (200, expected)); + Ok(()) +} + +/// `AgentIdentityEnrollmentToken.friendlyNameFormat` and `config` echo the created token's. +// oracle: spec DVLS public OpenAPI document §AgentIdentityEnrollmentToken +#[tokio::test] +async fn create_token_keeps_its_format_and_config() -> anyhow::Result<()> { + let app = App::for_test()?; + let body = json!({ + "name": "t", + "maxUses": 1, + "friendlyNameFormat": "{hostname}-{os_name}", + "config": { "site": "paris" }, + }); + let (status, created) = call(&app, Method::POST, "/enrollment-token", Some(body)).await?; + assert_eq!(status, 201); + assert_eq!(created["enrollmentToken"]["friendlyNameFormat"], "{hostname}-{os_name}"); + assert_eq!(created["enrollmentToken"]["config"], json!({ "site": "paris" })); + Ok(()) +} + +/// `AgentIdentityEnrollmentTokenCreateDTO`'s limits, at their bounds. +// oracle: spec DVLS public OpenAPI document §AgentIdentityEnrollmentTokenCreateDTO +#[rstest] +#[case::max_uses(|_| json!({ "name": "t", "maxUses": 1_000_000 }))] +#[case::expiry_365_days_ahead(|now| json!({ "name": "t", "maxUses": 1, "expiresOn": rfc3339(now + 365 * DAY) }))] +#[case::name_255_scalar_values(|_| json!({ "name": "é".repeat(255), "maxUses": 1 }))] +#[case::format_1024_scalar_values(|_| json!({ "name": "t", "maxUses": 1, "friendlyNameFormat": "é".repeat(1024) }))] +#[case::config_32_entries(|_| json!({ "name": "t", "maxUses": 1, "config": config_entries(32) }))] +#[case::config_key_64_bytes(|_| json!({ "name": "t", "maxUses": 1, "config": { "k".repeat(64): "v" } }))] +#[case::config_value_1024_bytes(|_| json!({ "name": "t", "maxUses": 1, "config": { "site": "v".repeat(1024) } }))] +#[tokio::test] +async fn create_token_accepts_its_bounds(#[case] body: fn(i64) -> Value) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (status, created) = call(&app, Method::POST, "/enrollment-token", Some(body(now))).await?; + assert_eq!(status, 201, "{created}"); + Ok(()) +} + +/// `AgentIdentityEnrollmentTokenCreateDTO.expiresOn` is a date-time, which keeps its fraction of a second: half a +/// second ahead is in the future, and the token expires then. +// oracle: spec DVLS public OpenAPI document §AgentIdentityEnrollmentTokenCreateDTO +#[tokio::test] +async fn token_expiry_keeps_its_fraction_of_a_second() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let expires_on = half_second_after(now); + let body = json!({ "name": "t", "maxUses": 1, "expiresOn": expires_on }); + let (status, created) = call(&app, Method::POST, "/enrollment-token", Some(body)).await?; + assert_eq!(status, 201, "{created}"); + assert_eq!( + ( + &created["enrollmentToken"]["expiresOn"], + &created["enrollmentToken"]["state"] + ), + (&json!(expires_on), &json!("Active")) + ); + Ok(()) +} + +/// `AgentIdentityPublicApi_CreateAgentIdentityEnrollmentToken`'s `400`: invalid parameters, including a friendly-name +/// format with an unpaired brace or an unknown placeholder, reported in a `PublicApiValidationError`. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_CreateAgentIdentityEnrollmentToken +#[rstest] +#[case::missing_name(|_| json!({ "maxUses": 1 }), "name")] +#[case::blank_name(|_| json!({ "name": " ", "maxUses": 1 }), "name")] +#[case::name_256_scalar_values(|_| json!({ "name": "n".repeat(256), "maxUses": 1 }), "name")] +#[case::missing_max_uses(|_| json!({ "name": "t" }), "maxUses")] +#[case::zero_uses(|_| json!({ "name": "t", "maxUses": 0 }), "maxUses")] +#[case::too_many_uses(|_| json!({ "name": "t", "maxUses": 1_000_001 }), "maxUses")] +#[case::expiry_now(|now| json!({ "name": "t", "maxUses": 1, "expiresOn": rfc3339(now) }), "expiresOn")] +#[case::expiry_beyond_365_days( + |now| json!({ "name": "t", "maxUses": 1, "expiresOn": rfc3339(now + 365 * DAY + 1) }), + "expiresOn" +)] +#[case::expiry_half_a_second_beyond_365_days( + |now| json!({ "name": "t", "maxUses": 1, "expiresOn": half_second_after(now + 365 * DAY) }), + "expiresOn" +)] +#[case::expiry_not_a_date_time(|_| json!({ "name": "t", "maxUses": 1, "expiresOn": "soon" }), "expiresOn")] +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens +#[case::unpaired_brace( + |_| json!({ "name": "t", "maxUses": 1, "friendlyNameFormat": "{hostname" }), + "friendlyNameFormat" +)] +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens +#[case::unknown_placeholder( + |_| json!({ "name": "t", "maxUses": 1, "friendlyNameFormat": "{nope}" }), + "friendlyNameFormat" +)] +#[case::format_1025_scalar_values( + |_| json!({ "name": "t", "maxUses": 1, "friendlyNameFormat": "é".repeat(1025) }), + "friendlyNameFormat" +)] +#[case::config_not_an_object(|_| json!({ "name": "t", "maxUses": 1, "config": "site=paris" }), "config")] +#[case::config_33_entries(|_| json!({ "name": "t", "maxUses": 1, "config": config_entries(33) }), "config")] +#[case::config_reserved_key( + |_| json!({ "name": "t", "maxUses": 1, "config": { "agent_channel_url": "https://elsewhere.example" } }), + "config.agent_channel_url" +)] +#[case::config_uppercase_key(|_| json!({ "name": "t", "maxUses": 1, "config": { "Site": "x" } }), "config.Site")] +#[case::config_key_with_a_dash(|_| json!({ "name": "t", "maxUses": 1, "config": { "site-id": "x" } }), "config.site-id")] +#[case::config_number_value(|_| json!({ "name": "t", "maxUses": 1, "config": { "site": 1 } }), "config.site")] +#[case::config_control_character( + |_| json!({ "name": "t", "maxUses": 1, "config": { "site": "a\u{1}b" } }), + "config.site" +)] +#[case::config_value_1025_bytes( + |_| json!({ "name": "t", "maxUses": 1, "config": { "site": "v".repeat(1025) } }), + "config.site" +)] +#[case::unknown_member(|now| json!({ "name": "t", "maxUses": 1, "expiresAt": rfc3339(now + DAY) }), "expiresAt")] +#[tokio::test] +async fn create_token_refuses_an_invalid_member( + #[case] body: fn(i64) -> Value, + #[case] field: &str, +) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (status, refused) = call(&app, Method::POST, "/enrollment-token", Some(body(now))).await?; + assert_eq!(status, 400); + assert_eq!(error_fields(&refused), [json!(field)]); + Ok(()) +} + +/// `AgentIdentityEnrollmentTokenCreateDTO.config` keys match `^[a-z][a-z0-9_]{0,63}$`, so at most 64 bytes. +// oracle: spec DVLS public OpenAPI document §AgentIdentityEnrollmentTokenCreateDTO +#[tokio::test] +async fn create_token_refuses_a_65_byte_config_key() -> anyhow::Result<()> { + let app = App::for_test()?; + let key = "k".repeat(65); + let body = json!({ "name": "t", "maxUses": 1, "config": { key.clone(): "v" } }); + let (status, refused) = call(&app, Method::POST, "/enrollment-token", Some(body)).await?; + assert_eq!(status, 400); + assert_eq!(error_fields(&refused), [json!(format!("config.{key}"))]); + Ok(()) +} + +/// Returns a token config whose identities, enrolled with `base_url` offering the channel, start with a config that +/// travels in a `ConfigUpdate` envelope of `envelope_len` bytes. +/// It stays within `AgentIdentityEnrollmentTokenCreateDTO.config`'s limits, 32 entries of at most 1024 bytes, since an +/// escaped `"` takes two bytes of JSON. +fn token_config_for_envelope(base_url: &str, envelope_len: usize) -> Value { + // CONTRACT.agent-identity.md §5.2: the server adds `version`, `revision` and `agent_channel_url` to the token's + // fields. + let identity_config_len = |token_config: &serde_json::Map| { + let mut config = token_config.clone(); + config.insert("version".to_owned(), json!(1)); + config.insert("revision".to_owned(), json!(1)); + config.insert("agent_channel_url".to_owned(), json!(base_url)); + config_update_len(&Value::Object(config)) + }; + let mut config: serde_json::Map = (0..31) + .map(|i| (format!("quotes_{i}"), json!("\"".repeat(1024)))) + .collect(); + config.insert("last".to_owned(), json!("x".repeat(1024))); + let quotes = envelope_len - identity_config_len(&config); + let last = format!("{}{}", "\"".repeat(quotes), "x".repeat(1024 - quotes)); + config.insert("last".to_owned(), json!(last)); + assert_eq!(identity_config_len(&config), envelope_len); + Value::Object(config) +} + +/// CONTRACT.agent-identity.md §7.1 holds no effective config past one channel envelope, so a token whose identities +/// would start with a config one byte past it is refused on `config`, in a `PublicApiValidationError`, and not +/// created. +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens +#[tokio::test] +async fn create_token_refuses_a_config_past_the_envelope_limit() -> anyhow::Result<()> { + let app = App::for_test()?; + let config = token_config_for_envelope(&app.base_url, ENVELOPE_LIMIT + 1); + let body = json!({ "name": "t", "maxUses": 1, "config": config }); + let (status, refused) = call(&app, Method::POST, "/enrollment-token", Some(body)).await?; + assert_eq!(status, 400); + assert_eq!(error_fields(&refused), [json!("config")]); + assert!(app.state.lock().await.tokens.is_empty()); + Ok(()) +} + +/// CONTRACT.agent-identity.md §7.1 accepts envelopes up to 64 KiB, so a token whose identities start with a config +/// whose `ConfigUpdate` envelope is exactly that size is created. +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §channel-service +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Enrollment tokens +#[tokio::test] +async fn create_token_accepts_a_config_at_the_envelope_limit() -> anyhow::Result<()> { + let app = App::for_test()?; + let config = token_config_for_envelope(&app.base_url, ENVELOPE_LIMIT); + let body = json!({ "name": "t", "maxUses": 1, "config": config }); + let (status, created) = call(&app, Method::POST, "/enrollment-token", Some(body)).await?; + assert_eq!(status, 201, "{created}"); + Ok(()) +} + +/// The mock parses management API bodies with the agent-facing body parser, which refuses a member name repeated at any +/// depth, as its README states. +/// No DVLS document decides repeated member names; the test guards that the parser stays in place. +// oracle: mutant a management API body parsed without refusing a repeated member name +#[rstest] +#[case::distinct_members(r#"{"flag":"first","other":"second"}"#, 201)] +#[case::repeated_member(r#"{"flag":"first","flag":"second"}"#, 400)] +#[tokio::test] +async fn create_token_refuses_a_repeated_member_name( + #[case] config: &str, + #[case] expected: u16, +) -> anyhow::Result<()> { + let app = App::for_test()?; + let token_id = login(&app, APP_KEY).await?; + let body = format!(r#"{{"name":"t","maxUses":1,"config":{config}}}"#); + let (status, _) = send( + &app, + Method::POST, + "/enrollment-token", + &[(TOKEN_ID_HEADER, &token_id)], + Some(body), + ) + .await?; + assert_eq!(status, expected); + Ok(()) +} + +/// `AgentIdentityPublicApi_CreateAgentIdentityEnrollmentToken`: "The first token also creates the Agent Identity root"; +/// a freshly started mock, like a DVLS that never used Agent Identity, holds none before it. +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Root CA +#[tokio::test] +async fn first_token_creates_the_root() -> anyhow::Result<()> { + let (app, _) = pristine_app().await?; + assert!(app.state.lock().await.roots.is_empty()); + create_token(&app, json!({ "name": "t", "maxUses": 1 })).await?; + let state = app.state.lock().await; + assert_eq!(state.roots.iter().filter(|root| root.published).count(), 1); + Ok(()) +} + +/// `AgentIdentityPublicApi_CreateAgentIdentityEnrollmentToken`'s `409`: "The first root cannot be created because the +/// data encryption key is unavailable". +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_CreateAgentIdentityEnrollmentToken +#[tokio::test] +async fn first_root_without_the_encryption_key_is_a_conflict() -> anyhow::Result<()> { + let (app, _) = pristine_app().await?; + app.state.lock().await.faults.root_key_unavailable = true; + let (status, body) = call( + &app, + Method::POST, + "/enrollment-token", + Some(json!({ "name": "t", "maxUses": 1 })), + ) + .await?; + assert_eq!(status, 409); + assert_public_api_error(&body); + assert!(app.state.lock().await.tokens.is_empty()); + Ok(()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens` returns a `PagedResultOfAgentIdentityEnrollmentToken`. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[tokio::test] +async fn list_tokens_returns_a_page() -> anyhow::Result<()> { + let app = App::for_test()?; + for name in ["c", "a", "b"] { + create_token(&app, json!({ "name": name, "maxUses": 1 })).await?; + } + let (status, page) = call(&app, Method::GET, "/enrollment-token?pageSize=2&pageNumber=2", None).await?; + assert_eq!(status, 200); + assert_eq!(names(&page), ["b"]); + assert_eq!( + ( + &page["currentPage"], + &page["pageSize"], + &page["totalCount"], + &page["totalPage"] + ), + (&json!(2), &json!(2), &json!(3), &json!(2)) + ); + Ok(()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens`' `sortField` (`createdOn`, `name` or `expiresOn`, by +/// default `createdOn`) and `sortOrder` (1 ascending by default, 0 descending). +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[rstest] +#[case::default("", ["c", "a", "b"])] +#[case::created_descending("?sortOrder=0", ["b", "a", "c"])] +#[case::name("?sortField=name", ["a", "b", "c"])] +#[case::name_descending("?sortField=name&sortOrder=0", ["c", "b", "a"])] +#[case::expiry("?sortField=expiresOn", ["a", "b", "c"])] +#[tokio::test] +async fn list_tokens_sorts(#[case] query: &str, #[case] expected: [&str; 3]) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + for (name, days) in [("c", 3), ("a", 1), ("b", 2)] { + let body = json!({ "name": name, "maxUses": 1, "expiresOn": rfc3339(now + days * DAY) }); + create_token(&app, body).await?; + } + let (status, page) = call(&app, Method::GET, &format!("/enrollment-token{query}"), None).await?; + assert_eq!(status, 200); + assert_eq!(names(&page), expected); + Ok(()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens` sorts by the `createdOn` property, whatever the creation +/// order: a token created at an earlier mock time comes first. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[rstest] +#[case::ascending("?sortField=createdOn&sortOrder=1", ["earlier", "later"])] +#[case::descending("?sortField=createdOn&sortOrder=0", ["later", "earlier"])] +#[tokio::test] +async fn list_tokens_sorts_by_creation_time(#[case] query: &str, #[case] expected: [&str; 2]) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + app.clock.freeze_at(now + 60); + create_token(&app, json!({ "name": "later", "maxUses": 1 })).await?; + app.clock.freeze_at(now); + create_token(&app, json!({ "name": "earlier", "maxUses": 1 })).await?; + let (status, page) = call(&app, Method::GET, &format!("/enrollment-token{query}"), None).await?; + assert_eq!(status, 200); + assert_eq!(names(&page), expected); + Ok(()) +} + +/// `AgentIdentityPublicApi_DeleteAgentIdentityEnrollmentToken` returns `204`, then `404` "already deleted"; a deleted +/// token is neither returned nor listed. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_DeleteAgentIdentityEnrollmentToken +#[tokio::test] +async fn deleted_token_is_gone() -> anyhow::Result<()> { + let app = App::for_test()?; + let id = create_token(&app, json!({ "name": "t", "maxUses": 1 })).await?; + let path = format!("/enrollment-token/{id}"); + assert_eq!(call(&app, Method::DELETE, &path, None).await?, (204, Value::Null)); + + let (status, body) = call(&app, Method::GET, &path, None).await?; + assert_eq!(status, 404); + assert_public_api_error(&body); + let (status, body) = call(&app, Method::DELETE, &path, None).await?; + assert_eq!(status, 404); + assert_public_api_error(&body); + let (_, page) = call(&app, Method::GET, "/enrollment-token", None).await?; + assert_eq!(page["totalCount"], 0); + Ok(()) +} + +// ==== Identities ==== + +/// The `(thumbprint, serialNumber, validFrom, validUntil)` of a DER certificate, as `AgentIdentityCertificate` defines +/// them: the thumbprint is the base64url of the SHA-256 of the DER encoding, and the serial number is hexadecimal. +fn certificate_fields(der: &[u8]) -> anyhow::Result<(String, String, String, String)> { + use der::Decode as _; + use sha2::Digest as _; + + let certificate = x509_cert::Certificate::from_der(der)?; + let tbs = certificate.tbs_certificate(); + let serial: String = tbs + .serial_number() + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let serial = serial.trim_start_matches('0').to_owned(); + let validity = tbs.validity(); + let unix = |time: x509_cert::time::Time| i64::try_from(time.to_unix_duration().as_secs()); + Ok(( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sha2::Sha256::digest(der)), + serial, + rfc3339(unix(validity.not_before)?), + rfc3339(unix(validity.not_after)?), + )) +} + +/// `AgentIdentityPublicApi_GetAgentIdentity` returns an `AgentIdentity` in the Full view; its certificate members +/// describe the issued certificate chain. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentity +#[tokio::test] +async fn get_identity_returns_the_full_view() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (id, chain) = enroll_with_chain(&app, json!({ "hostname": "host-a" })).await?; + let (thumbprint, serial, valid_from, valid_until) = certificate_fields(&chain[0])?; + let (root, ..) = certificate_fields(&chain[1])?; + // The certificate follows the default 90-day lifetime. + assert_eq!((&valid_from, &valid_until), (&rfc3339(now), &rfc3339(now + 90 * DAY))); + let token_id = app.state.lock().await.identities[&id].token_id; + + let (status, view) = call(&app, Method::GET, &format!("/{id}"), None).await?; + assert_eq!(status, 200); + // The friendly name follows the default format `{hostname}`. + let expected = json!({ + "id": id, + "friendlyName": "host-a", + "status": "Active", + "isConnected": false, + "lastSeenOn": rfc3339(now), + "certificate": { "issuerThumbprint": root, "validUntil": valid_until }, + "metadata": { "hostname": "host-a" }, + "certificates": [{ + "issuerThumbprint": root, + "serialNumber": serial, + "status": "Current", + "thumbprint": thumbprint, + "validFrom": valid_from, + "validUntil": valid_until, + }], + "createdOn": rfc3339(now), + "enrollmentTokenId": token_id, + "enrollmentTokenName": "test", + "isRenewalRequested": false, + }); + assert_eq!(view, expected); + Ok(()) +} + +/// `AgentIdentity.metadata` in the Summary view carries "only the keys requested with metadataKey and omits the field +/// when it has none of them"; the Full-view-only members are absent. +// oracle: spec DVLS public OpenAPI document §AgentIdentity +#[rstest] +#[case::no_keys("", Value::Null)] +#[case::one_key("?metadataKey=hostname", json!({ "hostname": "host-a" }))] +#[case::both_keys( + "?metadataKey=hostname&metadataKey=os_name", + json!({ "hostname": "host-a", "os_name": "Linux" }) +)] +#[case::absent_key("?metadataKey=machine_id", Value::Null)] +#[case::summary_view("?view=Summary&metadataKey=os_name", json!({ "os_name": "Linux" }))] +#[tokio::test] +async fn summary_view_carries_the_requested_metadata( + #[case] query: &str, + #[case] metadata: Value, +) -> anyhow::Result<()> { + let app = App::for_test()?; + enroll(&app, json!({ "hostname": "host-a", "os_name": "Linux" })).await?; + let (status, page) = call(&app, Method::GET, query, None).await?; + assert_eq!(status, 200); + let item = page["data"][0].as_object().expect("identity"); + assert_eq!(item.get("metadata").cloned().unwrap_or(Value::Null), metadata); + let mut members: Vec<&str> = item + .keys() + .map(String::as_str) + .filter(|key| *key != "metadata") + .collect(); + members.sort_unstable(); + assert_eq!( + members, + [ + "certificate", + "friendlyName", + "id", + "isConnected", + "lastSeenOn", + "status" + ] + ); + Ok(()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentities` with `view=Full` lists each identity as `GetAgentIdentity` returns it, +/// ignoring `metadataKey`. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[tokio::test] +async fn full_view_lists_each_identity_whole() -> anyhow::Result<()> { + let app = App::for_test()?; + let id = enroll(&app, json!({ "hostname": "host-a" })).await?; + let (_, page) = call(&app, Method::GET, "?view=Full&metadataKey=os_name", None).await?; + let (_, read) = call(&app, Method::GET, &format!("/{id}"), None).await?; + assert_eq!(page["data"], json!([read])); + Ok(()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentities`' `status` filter takes `AgentIdentityStatus` values, repeated. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[rstest] +#[case::active("?status=Active", 1)] +#[case::revoked("?status=Revoked", 1)] +#[case::grace("?status=Grace", 0)] +#[case::either("?status=Active&status=Revoked", 2)] +#[tokio::test] +async fn status_filter_selects_identities(#[case] query: &str, #[case] expected: u64) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + { + let mut state = app.state.lock().await; + state.insert_connected_identity(now, DAY); + let (revoked, _) = state.insert_connected_identity(now, DAY); + state.revoke(revoked, now).expect("identity exists"); + } + let (status, page) = call(&app, Method::GET, query, None).await?; + assert_eq!(status, 200); + assert_eq!(page["totalCount"], expected); + Ok(()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentities`' `lastSeenFrom` and `lastSeenTo` are inclusive date-times that keep +/// their fraction of a second. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[rstest] +#[case::from_the_same_second(|now| format!("?lastSeenFrom={}", rfc3339(now)), 1)] +#[case::from_half_a_second_later(|now| format!("?lastSeenFrom={}", half_second_after(now)), 0)] +#[case::to_the_same_second(|now| format!("?lastSeenTo={}", rfc3339(now)), 1)] +#[case::to_half_a_second_earlier(|now| format!("?lastSeenTo={}", half_second_after(now - 1)), 0)] +#[tokio::test] +async fn last_seen_filters_keep_fractions_of_a_second( + #[case] query: fn(i64) -> String, + #[case] expected: u64, +) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + app.state.lock().await.insert_connected_identity(now, DAY); + let (status, page) = call(&app, Method::GET, &query(now), None).await?; + assert_eq!((status, &page["totalCount"]), (200, &json!(expected))); + Ok(()) +} + +/// `AgentIdentityPublicApi_GetAgentIdentities`' `400`: invalid parameters, including enum values other than the +/// PascalCase ones of `AgentIdentityView` and `AgentIdentityStatus`, and empty values, since no parameter sets +/// `allowEmptyValue`. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[rstest] +#[case::lowercase_status("?status=active", "status")] +#[case::certificate_status("?status=Pending", "status")] +#[case::lowercase_view("?view=full", "view")] +#[case::token_id("?enrollmentTokenId=build-agents", "enrollmentTokenId")] +#[case::last_seen_from("?lastSeenFrom=yesterday", "lastSeenFrom")] +#[case::last_seen_to("?lastSeenTo=1700000000", "lastSeenTo")] +#[case::empty_view("?view=", "view")] +#[case::empty_status("?status=", "status")] +#[case::empty_token_id("?enrollmentTokenId=", "enrollmentTokenId")] +#[case::empty_last_seen_from("?lastSeenFrom=", "lastSeenFrom")] +#[tokio::test] +async fn list_identities_refuses_an_invalid_filter(#[case] query: &str, #[case] field: &str) -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, body) = call(&app, Method::GET, query, None).await?; + assert_eq!(status, 400); + assert_eq!(error_fields(&body), [json!(field)]); + Ok(()) +} + +/// `AgentIdentityPublicApi_PatchAgentIdentity` renames and returns the Full view; "Only the provided fields are +/// changed". +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_PatchAgentIdentity +#[rstest] +#[case::renamed(json!({ "friendlyName": "renamed" }), "renamed")] +#[case::empty_patch(json!({}), "test-1")] +#[case::null_name(json!({ "friendlyName": null }), "test-1")] +#[tokio::test] +async fn patch_identity_changes_the_provided_name(#[case] patch: Value, #[case] expected: &str) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (id, _) = app.state.lock().await.insert_connected_identity(now, DAY); + let (status, view) = call(&app, Method::PATCH, &format!("/{id}"), Some(patch)).await?; + assert_eq!(status, 200); + assert_eq!(view["friendlyName"], expected); + assert!( + view["certificates"].is_array(), + "the Full view carries the certificates" + ); + let (_, read) = call(&app, Method::GET, &format!("/{id}"), None).await?; + assert_eq!(read, view); + Ok(()) +} + +/// `AgentIdentityPatchDTO.friendlyName`: "1 to 255 Unicode scalar values that do not start or end with white space". +// oracle: spec DVLS public OpenAPI document §AgentIdentityPatchDTO +#[rstest] +#[case::empty(json!({ "friendlyName": "" }), "friendlyName")] +#[case::leading_space(json!({ "friendlyName": " name" }), "friendlyName")] +#[case::trailing_tab(json!({ "friendlyName": "name\t" }), "friendlyName")] +#[case::too_long(json!({ "friendlyName": "n".repeat(256) }), "friendlyName")] +#[case::not_a_string(json!({ "friendlyName": 7 }), "friendlyName")] +#[case::unknown_member(json!({ "name": "renamed" }), "name")] +#[tokio::test] +async fn patch_identity_refuses_an_invalid_name(#[case] patch: Value, #[case] field: &str) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (id, _) = app.state.lock().await.insert_connected_identity(now, DAY); + let (status, body) = call(&app, Method::PATCH, &format!("/{id}"), Some(patch)).await?; + assert_eq!(status, 400); + assert_eq!(error_fields(&body), [json!(field)]); + Ok(()) +} + +/// `AgentIdentityPublicApi_RevokeAgentIdentity` returns `204`, also for a revoked identity, which "can then neither +/// authenticate nor renew": its live streams close. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_RevokeAgentIdentity +#[tokio::test] +async fn revoke_identity_is_idempotent() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (id, mut stream) = app.state.lock().await.insert_connected_identity(now, DAY); + let path = format!("/{id}/revoke"); + assert_eq!(call(&app, Method::POST, &path, None).await?, (204, Value::Null)); + assert_eq!(call(&app, Method::POST, &path, None).await?, (204, Value::Null)); + assert!(matches!(stream.try_recv(), Ok(StreamPush::CloseRevoked))); + + let (_, view) = call(&app, Method::GET, &format!("/{id}"), None).await?; + assert_eq!( + (&view["status"], &view["revokedOn"]), + (&json!("Revoked"), &json!(rfc3339(now))) + ); + Ok(()) +} + +/// `AgentIdentityPublicApi_DeleteAgentIdentity` deletes only a revoked identity: `409` before, `204`, then `404`. +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Identities +#[tokio::test] +async fn delete_identity_requires_revocation() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (id, _) = app.state.lock().await.insert_connected_identity(now, DAY); + let path = format!("/{id}"); + let (status, body) = call(&app, Method::DELETE, &path, None).await?; + assert_eq!(status, 409); + assert_public_api_error(&body); + + call(&app, Method::POST, &format!("{path}/revoke"), None).await?; + assert_eq!(call(&app, Method::DELETE, &path, None).await?, (204, Value::Null)); + assert_eq!(call(&app, Method::GET, &path, None).await?.0, 404); + assert_eq!(call(&app, Method::DELETE, &path, None).await?.0, 404); + Ok(()) +} + +/// `AgentIdentityPublicApi_RequestAgentIdentityRenewal` sets the request-renewal flag and notifies the connected agent. +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Identities +#[tokio::test] +async fn request_renewal_flags_and_notifies() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (id, mut stream) = app.state.lock().await.insert_connected_identity(now, DAY); + assert_eq!( + call(&app, Method::POST, &format!("/{id}/request-renewal"), None).await?, + (202, Value::Null) + ); + assert!(matches!(stream.try_recv(), Ok(StreamPush::RenewRequested("admin")))); + let (_, view) = call(&app, Method::GET, &format!("/{id}"), None).await?; + assert_eq!(view["isRenewalRequested"], true); + Ok(()) +} + +/// `AgentIdentityPublicApi_RequestAgentIdentityRenewal`'s `409`: "The identity is revoked". +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_RequestAgentIdentityRenewal +#[tokio::test] +async fn request_renewal_of_a_revoked_identity_is_a_conflict() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let (id, _) = app.state.lock().await.insert_connected_identity(now, DAY); + call(&app, Method::POST, &format!("/{id}/revoke"), None).await?; + let (status, body) = call(&app, Method::POST, &format!("/{id}/request-renewal"), None).await?; + assert_eq!(status, 409); + assert_public_api_error(&body); + Ok(()) +} + +/// The `404` of each operation on one identity or token, for an unknown GUID and for an ID that is not one. +#[rstest] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentity +#[case::get_identity(Method::GET, "/{id}")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_PatchAgentIdentity +#[case::patch_identity(Method::PATCH, "/{id}")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_DeleteAgentIdentity +#[case::delete_identity(Method::DELETE, "/{id}")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_RevokeAgentIdentity +#[case::revoke_identity(Method::POST, "/{id}/revoke")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_RequestAgentIdentityRenewal +#[case::request_renewal(Method::POST, "/{id}/request-renewal")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentToken +#[case::get_token(Method::GET, "/enrollment-token/{id}")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_DeleteAgentIdentityEnrollmentToken +#[case::delete_token(Method::DELETE, "/enrollment-token/{id}")] +#[tokio::test] +async fn unknown_record_is_404( + #[case] method: Method, + #[case] route: &str, + #[values("00000000-0000-0000-0000-000000000000", "not-a-guid")] id: &str, +) -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, body) = call(&app, method, &route.replace("{id}", id), Some(json!({}))).await?; + assert_eq!(status, 404); + assert_public_api_error(&body); + Ok(()) +} + +// ==== Paging ==== + +/// `pageSize` allows up to 100 identities ("Every item carries metadata and certificates") and 500 tokens, 25 by +/// default. +#[rstest] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[case::identities_minimum("?pageSize=1", 1)] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[case::identities_maximum("?pageSize=100", 100)] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[case::tokens_maximum("/enrollment-token?pageSize=500", 500)] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[case::default_size("/enrollment-token", 25)] +#[tokio::test] +async fn page_size_within_bounds_is_accepted(#[case] path: &str, #[case] page_size: u64) -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, page) = call(&app, Method::GET, path, None).await?; + assert_eq!((status, &page["pageSize"]), (200, &json!(page_size))); + Ok(()) +} + +/// The paging parameters' schemas: `pageNumber` ≥ 1, `pageSize` from 1 to its maximum, `sortOrder` 0 or 1, and +/// `sortField` among the allowed values; an empty value is not absent, since no parameter sets `allowEmptyValue`. +#[rstest] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[case::identity_page_size_101("?pageSize=101", "pageSize")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[case::token_page_size_501("/enrollment-token?pageSize=501", "pageSize")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[case::page_size_zero("?pageSize=0", "pageSize")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[case::page_number_zero("?pageNumber=0", "pageNumber")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[case::page_number_not_an_integer("/enrollment-token?pageNumber=first", "pageNumber")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[case::sort_order_two("?sortOrder=2", "sortOrder")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[case::identity_sort_by_name("?sortField=name", "sortField")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[case::token_sort_by_use_count("/enrollment-token?sortField=useCount", "sortField")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[case::empty_page_number("?pageNumber=", "pageNumber")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[case::empty_page_size("/enrollment-token?pageSize=", "pageSize")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[case::empty_sort_order("?sortOrder=", "sortOrder")] +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityEnrollmentTokens +#[case::empty_sort_field("/enrollment-token?sortField=", "sortField")] +#[tokio::test] +async fn paging_out_of_bounds_is_refused(#[case] path: &str, #[case] field: &str) -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, body) = call(&app, Method::GET, path, None).await?; + assert_eq!(status, 400); + assert_eq!(error_fields(&body), [json!(field)]); + Ok(()) +} + +/// A page past the last is `400`; page 1 of an empty listing is the last page. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentities +#[rstest] +#[case::empty_first_page(0, 1, 200)] +#[case::empty_second_page(0, 2, 400)] +#[case::last_page(2, 2, 200)] +#[case::past_the_last_page(2, 3, 400)] +#[tokio::test] +async fn page_past_the_last_is_refused( + #[case] identities: usize, + #[case] page_number: u64, + #[case] expected: u16, +) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + for _ in 0..identities { + app.state.lock().await.insert_connected_identity(now, DAY); + } + let path = format!("?pageSize=1&pageNumber={page_number}"); + assert_eq!(call(&app, Method::GET, &path, None).await?.0, expected); + Ok(()) +} + +/// `PagedResultOfAgentIdentity` of an empty listing: no page, no data. +// oracle: spec DVLS public OpenAPI document §PagedResultOfAgentIdentity +#[tokio::test] +async fn empty_listing_has_no_page() -> anyhow::Result<()> { + let app = App::for_test()?; + let (_, page) = call(&app, Method::GET, "", None).await?; + assert_eq!( + page, + json!({ "currentPage": 1, "data": [], "pageSize": 25, "totalCount": 0, "totalPage": 0 }) + ); + Ok(()) +} + +// ==== Rotation ==== + +/// `AgentIdentityRotation` while Idle omits every root and time and counts 0. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentityRotation +#[tokio::test] +async fn idle_rotation_has_no_roots() -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, rotation) = call(&app, Method::GET, "/rotation", None).await?; + assert_eq!( + (status, rotation), + (200, json!({ "phase": "Idle", "oldRootActiveIdentityCount": 0 })) + ); + Ok(()) +} + +/// `AgentIdentityPublicApi_StartAgentIdentityRotation` with `{}` uses the rotation maximum: the latest expiry among the +/// old root's unexpired current or pending certificates of identities that are not revoked. +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Rotation +#[tokio::test] +async fn rotation_defaults_to_the_rotation_maximum() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let old_root = { + let mut state = app.state.lock().await; + state.insert_connected_identity(now, 3600); + state.insert_connected_identity(now, 7200); + state.issuing_root().thumbprint.clone() + }; + let (status, started) = call(&app, Method::POST, "/rotation", Some(json!({}))).await?; + assert_eq!(status, 202); + let expected = { + let state = app.state.lock().await; + let root_view = |thumbprint: &str| { + let root = state + .roots + .iter() + .find(|root| root.thumbprint == thumbprint) + .expect("known root"); + json!({ + "thumbprint": root.thumbprint, + "validFrom": rfc3339(root.not_before), + "validUntil": rfc3339(root.not_after), + }) + }; + let new_root = state.issuing_root().thumbprint.clone(); + assert_ne!(new_root, old_root); + json!({ + "phase": "Rotating", + "startedOn": rfc3339(now), + "deadlineOn": rfc3339(now + 7200), + "oldRoot": root_view(&old_root), + "newRoot": root_view(&new_root), + "oldRootActiveIdentityCount": 2, + }) + }; + assert_eq!(started, expected); + assert_eq!(call(&app, Method::GET, "/rotation", None).await?, (200, expected)); + Ok(()) +} + +fn tokyo_time(unix_secs: i64) -> String { + let offset = time::UtcOffset::from_hms(9, 0, 0).expect("valid offset"); + time::OffsetDateTime::from_unix_timestamp(unix_secs) + .expect("valid time") + .to_offset(offset) + .format(&time::format_description::well_known::Rfc3339) + .expect("formattable time") +} + +/// `AgentIdentityRotationCreateDTO.deadlineOn` is "UTC unless an offset is given". +// oracle: spec DVLS public OpenAPI document §AgentIdentityRotationCreateDTO +#[rstest] +#[case::utc(|now| rfc3339(now + 1800))] +#[case::without_offset(|now| rfc3339(now + 1800).trim_end_matches('Z').to_owned())] +#[case::with_offset(|now| tokyo_time(now + 1800))] +#[tokio::test] +async fn rotation_deadline_is_utc_unless_an_offset_is_given(#[case] deadline: fn(i64) -> String) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + app.state.lock().await.insert_connected_identity(now, 3600); + let body = json!({ "deadlineOn": deadline(now) }); + let (status, started) = call(&app, Method::POST, "/rotation", Some(body)).await?; + assert_eq!((status, &started["deadlineOn"]), (202, &json!(rfc3339(now + 1800)))); + Ok(()) +} + +/// `AgentIdentityRotationCreateDTO.deadlineOn`: "A time at or before now requests an emergency rotation", which +/// completes at once and unpublishes the old root (CONTRACT.agent-identity.md §8.3). +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Rotation +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §root-rotation +#[rstest] +#[case::now(0)] +#[case::past(-60)] +#[tokio::test] +async fn deadline_at_or_before_now_is_an_emergency(#[case] offset: i64) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + app.state.lock().await.insert_connected_identity(now, 3600); + let body = json!({ "deadlineOn": rfc3339(now + offset) }); + assert_eq!(call(&app, Method::POST, "/rotation", Some(body)).await?.0, 202); + let (_, rotation) = call(&app, Method::GET, "/rotation", None).await?; + assert_eq!(rotation["phase"], "Idle"); + let state = app.state.lock().await; + let published: Vec<&str> = state + .roots + .iter() + .filter(|root| root.published) + .map(|root| root.thumbprint.as_str()) + .collect(); + assert_eq!(published, [state.issuing_root().thumbprint.as_str()]); + Ok(()) +} + +/// `AgentIdentityPublicApi_StartAgentIdentityRotation`'s `422`: "deadlineOn is after the rotation maximum", even by a +/// fraction of a second. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_StartAgentIdentityRotation +#[rstest] +#[case::one_second(|maximum| rfc3339(maximum + 1))] +#[case::half_a_second(half_second_after)] +#[tokio::test] +async fn deadline_after_the_rotation_maximum_is_unprocessable( + #[case] deadline: fn(i64) -> String, +) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + app.state.lock().await.insert_connected_identity(now, 3600); + let body = json!({ "deadlineOn": deadline(now + 3600) }); + let (status, refused) = call(&app, Method::POST, "/rotation", Some(body)).await?; + assert_eq!(status, 422); + assert_public_api_error(&refused); + assert!(app.state.lock().await.rotation.is_none()); + Ok(()) +} + +fn rotation_in_progress(state: &mut State, now: i64) { + state.insert_connected_identity(now, 3600); + state.start_rotation(None, now).expect("first rotation starts"); +} + +fn no_root_yet(state: &mut State, now: i64) { + *state = State::new(now); +} + +fn root_key_unavailable(state: &mut State, _: i64) { + state.faults.root_key_unavailable = true; +} + +/// `AgentIdentityPublicApi_StartAgentIdentityRotation`'s `409`: "A rotation is in progress, no root exists yet, or the +/// root key is unavailable". +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_StartAgentIdentityRotation +#[rstest] +#[case::rotation_in_progress(rotation_in_progress)] +#[case::no_root_yet(no_root_yet)] +#[case::root_key_unavailable(root_key_unavailable)] +#[tokio::test] +async fn rotation_conflict_is_409(#[case] arrange: fn(&mut State, i64)) -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + arrange(&mut *app.state.lock().await, now); + let (status, body) = call(&app, Method::POST, "/rotation", Some(json!({}))).await?; + assert_eq!(status, 409); + assert_public_api_error(&body); + Ok(()) +} + +/// `AgentIdentityPublicApi_StartAgentIdentityRotation` requires an `AgentIdentityRotationCreateDTO` body, whose +/// `deadlineOn` is a date-time with no `"now"` literal. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_StartAgentIdentityRotation +#[rstest] +#[case::empty_body(None, Value::Null)] +#[case::now_literal(Some(json!({ "deadlineOn": "now" })), json!("deadlineOn"))] +#[case::unknown_member(Some(json!({ "deadline": "2030-01-01T00:00:00Z" })), json!("deadline"))] +#[tokio::test] +async fn rotation_refuses_an_invalid_body(#[case] body: Option, #[case] field: Value) -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, refused) = call(&app, Method::POST, "/rotation", body).await?; + assert_eq!(status, 400); + assert_eq!(error_fields(&refused), [field]); + Ok(()) +} + +// ==== Settings ==== + +/// `AgentIdentitySettings` by default: the channel enabled and offered, 90-day certificates (CONTRACT.agent-identity.md +/// §4), and 10 rotation notifications per second (DVLS's Agent Identity intent, "Load"). +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_GetAgentIdentitySettings +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §certificates +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Load +#[tokio::test] +async fn settings_have_their_defaults() -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, settings) = call(&app, Method::GET, "/settings", None).await?; + assert_eq!(status, 200); + assert_eq!( + settings, + json!({ + "authorityId": app.authority_id, + "isAgentChannelEnabled": true, + "isAgentChannelAvailable": true, + "certificateLifetimeDays": 90, + "rotationNotificationsPerSecond": 10, + }) + ); + Ok(()) +} + +/// `AgentIdentitySettingsPatchDTO.isAgentChannelEnabled` switches the channel off and on: `config.agent_channel_url` +/// leaves and returns (CONTRACT.agent-identity.md §5.2), each change bumping the config revision. +// oracle: spec DVLS public OpenAPI document §AgentIdentitySettingsPatchDTO +// oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enroll +#[tokio::test] +async fn channel_setting_withdraws_and_restores_the_channel_url() -> anyhow::Result<()> { + let app = App::for_test()?; + let id = enroll(&app, json!({})).await?; + + let disable = json!({ "isAgentChannelEnabled": false }); + let (status, settings) = call(&app, Method::PATCH, "/settings", Some(disable)).await?; + assert_eq!(status, 200); + assert_eq!( + (&settings["isAgentChannelEnabled"], &settings["isAgentChannelAvailable"]), + (&json!(false), &json!(false)) + ); + let disabled = app.state.lock().await.identities[&id].config.clone(); + assert_eq!( + (disabled.get("agent_channel_url"), &disabled["revision"]), + (None, &json!(2)) + ); + + let enable = json!({ "isAgentChannelEnabled": true }); + assert_eq!(call(&app, Method::PATCH, "/settings", Some(enable)).await?.0, 200); + let enabled = app.state.lock().await.identities[&id].config.clone(); + assert_eq!( + (&enabled["agent_channel_url"], &enabled["revision"]), + (&json!(app.base_url), &json!(3)) + ); + Ok(()) +} + +/// `AgentIdentitySettings.isAgentChannelAvailable`: "it is enabled and the hosting supports it"; the mock's +/// `channel_available` fault stands for the hosting. +// oracle: spec DVLS public OpenAPI document §AgentIdentitySettings +#[tokio::test] +async fn channel_is_unavailable_without_hosting_support() -> anyhow::Result<()> { + let app = App::for_test()?; + let turned_off = app.state.lock().await.update_channel_available(false, &app.base_url); + assert!(turned_off.is_ok()); + let (_, settings) = call(&app, Method::GET, "/settings", None).await?; + assert_eq!( + (&settings["isAgentChannelEnabled"], &settings["isAgentChannelAvailable"]), + (&json!(true), &json!(false)) + ); + Ok(()) +} + +/// `AgentIdentitySettings.certificateLifetimeDays`: "The lifetime of the identity certificates issued from now on". +// oracle: spec DVLS public OpenAPI document §AgentIdentitySettings +#[tokio::test] +async fn certificate_lifetime_applies_to_new_certificates() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let patch = json!({ "certificateLifetimeDays": 7 }); + let (status, settings) = call(&app, Method::PATCH, "/settings", Some(patch)).await?; + assert_eq!((status, &settings["certificateLifetimeDays"]), (200, &json!(7))); + let id = enroll(&app, json!({})).await?; + let (_, view) = call(&app, Method::GET, &format!("/{id}"), None).await?; + assert_eq!(view["certificate"]["validUntil"], rfc3339(now + 7 * DAY)); + Ok(()) +} + +/// The `leaf_lifetime_secs` mock fault overrides `certificateLifetimeDays` with seconds, for expiry tests. +// oracle: intent crates/agent-identity-mock/INTENT.md §Mock-only control +#[tokio::test] +async fn leaf_lifetime_fault_overrides_the_setting() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + app.state.lock().await.faults.leaf_lifetime_secs = Some(60); + let id = enroll(&app, json!({})).await?; + let (_, view) = call(&app, Method::GET, &format!("/{id}"), None).await?; + assert_eq!(view["certificate"]["validUntil"], rfc3339(now + 60)); + Ok(()) +} + +/// Returns, for each stream, whether it received a rotation `RenewRequested` since the last call. +fn rotation_notified(streams: &mut [UnboundedReceiver]) -> Vec { + streams + .iter_mut() + .map(|stream| matches!(stream.try_recv(), Ok(StreamPush::RenewRequested("rotation")))) + .collect() +} + +/// `AgentIdentitySettings.rotationNotificationsPerSecond`: "How many connected identities are notified per second during +/// a root rotation"; an identity beyond the rate is notified in the next second. +// oracle: spec DVLS public OpenAPI document §AgentIdentitySettings +// oracle: intent Devolutions/RDM@0acd9d2474bf9fd4a030e146a28097bf4ddf391a:DVLS/Server/server/Common/AgentIdentity/INTENT.md §Load +#[tokio::test] +async fn rotation_notifies_at_the_settings_rate() -> anyhow::Result<()> { + let (app, now) = frozen_app()?; + let patch = json!({ "rotationNotificationsPerSecond": 2 }); + assert_eq!(call(&app, Method::PATCH, "/settings", Some(patch)).await?.0, 200); + let mut streams: Vec<_> = { + let mut state = app.state.lock().await; + (0..3).map(|_| state.insert_connected_identity(now, DAY).1).collect() + }; + assert_eq!(call(&app, Method::POST, "/rotation", Some(json!({}))).await?.0, 202); + let first_window = rotation_notified(&mut streams); + assert_eq!(first_window.iter().filter(|sent| **sent).count(), 2); + + // The rate window is real time, while the mock clock stays frozen. + tokio::time::sleep(Duration::from_millis(1100)).await; + assert_eq!(call(&app, Method::GET, "/rotation", None).await?.0, 200); + let second_window = rotation_notified(&mut streams); + let expected: Vec = first_window.iter().map(|sent| !sent).collect(); + assert_eq!(second_window, expected); + Ok(()) +} + +/// `AgentIdentitySettingsPatchDTO`'s limits, at their bounds. +// oracle: spec DVLS public OpenAPI document §AgentIdentitySettingsPatchDTO +#[rstest] +#[case::shortest_lifetime("certificateLifetimeDays", 1)] +#[case::longest_lifetime("certificateLifetimeDays", 365)] +#[case::slowest_rate("rotationNotificationsPerSecond", 1)] +#[case::fastest_rate("rotationNotificationsPerSecond", 1000)] +#[tokio::test] +async fn settings_accept_their_bounds(#[case] member: &str, #[case] value: i64) -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, settings) = call(&app, Method::PATCH, "/settings", Some(json!({ member: value }))).await?; + assert_eq!((status, &settings[member]), (200, &json!(value))); + Ok(()) +} + +/// `AgentIdentityPublicApi_PatchAgentIdentitySettings`' `400`: values outside `AgentIdentitySettingsPatchDTO`'s ranges +/// or types, and members it does not define, such as the read-only `authorityId`. +// oracle: spec DVLS public OpenAPI document §AgentIdentityPublicApi_PatchAgentIdentitySettings +#[rstest] +#[case::zero_lifetime(json!({ "certificateLifetimeDays": 0 }), "certificateLifetimeDays")] +#[case::lifetime_366_days(json!({ "certificateLifetimeDays": 366 }), "certificateLifetimeDays")] +#[case::fractional_lifetime(json!({ "certificateLifetimeDays": 1.5 }), "certificateLifetimeDays")] +#[case::zero_rate(json!({ "rotationNotificationsPerSecond": 0 }), "rotationNotificationsPerSecond")] +#[case::rate_1001(json!({ "rotationNotificationsPerSecond": 1001 }), "rotationNotificationsPerSecond")] +#[case::channel_not_a_boolean(json!({ "isAgentChannelEnabled": "no" }), "isAgentChannelEnabled")] +#[case::authority_id(json!({ "authorityId": Uuid::nil() }), "authorityId")] +#[case::channel_availability(json!({ "isAgentChannelAvailable": true }), "isAgentChannelAvailable")] +#[tokio::test] +async fn settings_refuse_an_invalid_member(#[case] patch: Value, #[case] field: &str) -> anyhow::Result<()> { + let app = App::for_test()?; + let (status, body) = call(&app, Method::PATCH, "/settings", Some(patch)).await?; + assert_eq!(status, 400); + assert_eq!(error_fields(&body), [json!(field)]); + Ok(()) +} diff --git a/crates/agent-identity-mock/src/api_agent.rs b/crates/agent-identity-mock/src/api_agent.rs new file mode 100644 index 000000000..a98f19667 --- /dev/null +++ b/crates/agent-identity-mock/src/api_agent.rs @@ -0,0 +1,593 @@ +//! Agent-facing API (CONTRACT.agent-identity.md §5), under `/api/agent-identity/v1`. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{DefaultBodyLimit, State as AxumState}; +use axum::http::header::{AUTHORIZATION, LOCATION, RETRY_AFTER}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse as _, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde_json::{Map, Value, json}; + +use crate::app::{App, rfc3339}; +use crate::oracle::{AuthenticatedIdentity, Endpoint, Rejection, verify_request}; +use crate::state::{ApiError, DropTarget, FailNextResponse, State}; + +/// Marks a processed mock fault; the dispatcher aborts the connection without responding. +pub(crate) const DROP_HEADER: &str = "x-mock-drop"; +pub(crate) const INJECTED_HEADER: &str = "x-mock-injected"; + +pub(crate) fn router(app: Arc) -> Router { + Router::new() + .route("/api/agent-identity/v1/trust-anchor", get(trust_anchor)) + .route("/api/agent-identity/v1/enroll", post(enroll)) + .route("/api/agent-identity/v1/renew", post(renew)) + .route("/api/agent-identity/v1/confirm", post(confirm)) + .route("/api/agent-identity/v1/check-in", post(check_in)) + .layer(DefaultBodyLimit::max(64 * 1024)) + .with_state(app) +} + +/// §5.4 error body: `{ "error", "message", "server_time" }` on every non-2xx +/// agent-facing response. +pub(crate) fn agent_error(app: &App, status: u16, code: &str, message: &str) -> Response { + ( + StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + Json(json!({ + "error": code, + "message": message, + "server_time": rfc3339(app.now()), + })), + ) + .into_response() +} + +pub(crate) fn api_error_response(app: &App, err: &ApiError) -> Response { + agent_error(app, err.status, err.code, &err.message) +} + +pub(crate) fn rejection_response(app: &App, rejection: Rejection) -> Response { + agent_error(app, rejection.http_status(), rejection.code(), rejection.code()) +} + +fn injected_response(app: &App, fault: FailNextResponse) -> Response { + let mut response = if let Some(code) = fault.error { + agent_error(app, fault.status, code, "injected mock response") + } else { + StatusCode::from_u16(fault.status) + .expect("validated mock fault status") + .into_response() + }; + response + .headers_mut() + .insert(INJECTED_HEADER, "1".parse().expect("static value")); + if (300..400).contains(&fault.status) { + let target = format!("{}/__mock__/redirect-target", app.base_url); + response.headers_mut().insert( + LOCATION, + HeaderValue::from_str(&target).expect("mock base URL is a valid header"), + ); + } + if let Some(secs) = fault.retry_after_secs { + response.headers_mut().insert( + RETRY_AFTER, + HeaderValue::from_str(&secs.to_string()).expect("Retry-After seconds are valid"), + ); + } + response +} + +async fn trust_anchor(AxumState(app): AxumState>) -> Response { + app.tick().await; + let state = app.state.lock().await; + let roots: Vec = state + .roots + .iter() + .filter(|r| r.published) + .map(|r| { + json!({ + "certificate": crate::ca::base64_der(&r.cert_der), + "thumbprint": r.thumbprint, + "not_before": rfc3339(r.not_before), + "not_after": rfc3339(r.not_after), + }) + }) + .collect(); + Json(json!({ "roots": roots })).into_response() +} + +/// Parses `Authorization: Bearer `; the token must be well-formed +/// (`dvaet1..` with a 32-byte secret) to even reach the lookup, and a repeated header is `token_invalid` +/// ([CONTRACT.agent-identity.md][contract], "2.1 Authority behavior"). +/// The token authenticates by its exact `secret` text alone and the `bag` is never checked (§2); the engine decodes only +/// the canonical encoding (§1), so another encoding of the same secret bytes is `token_invalid`. +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#21-authority-behavior +fn parse_bearer(headers: &HeaderMap) -> Result<[u8; 32], ApiError> { + if headers.get_all(AUTHORIZATION).iter().count() != 1 { + return Err(ApiError::token_invalid()); + } + let token = headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(ApiError::token_invalid)?; + token + .strip_prefix("dvaet1.") + .and_then(|rest| rest.rsplit_once('.')) + .and_then(|(_bag, secret)| URL_SAFE_NO_PAD.decode(secret).ok()) + .and_then(|secret| <[u8; 32]>::try_from(secret).ok()) + .ok_or_else(ApiError::token_invalid) +} + +/// Returns the enrollment token the mock issues for `secret`: `dvaet1..` ([CONTRACT.agent-identity.md][contract], "2. Enrollment token"). +/// +/// [contract]: ../../../docs/agent-identity/CONTRACT.agent-identity.md#2-enrollment-token +pub(crate) fn enrollment_token(base_url: &str, secret: &[u8; 32]) -> String { + let bag = URL_SAFE_NO_PAD.encode(json!({ "u": base_url }).to_string()); + format!("dvaet1.{bag}.{}", URL_SAFE_NO_PAD.encode(secret)) +} + +/// Parses an enroll/renew body: `{ "csr": "", "metadata": {...} }`. +fn parse_csr_body(body: &[u8]) -> Result<(Vec, Map), ApiError> { + let value = crate::json::parse_request_body(body) + .map_err(|error| ApiError::invalid_request(format!("malformed JSON body: {error}")))?; + let object = value + .as_object() + .ok_or_else(|| ApiError::invalid_request("body must be a JSON object"))?; + let csr = object + .get("csr") + .and_then(Value::as_str) + .ok_or_else(|| ApiError::invalid_request("missing `csr`"))?; + // The engine rejects anything but the canonical form (§1), such as missing padding or nonzero pad bits. + let csr_der = base64::engine::general_purpose::STANDARD + .decode(csr) + .map_err(|_| ApiError::invalid_request("`csr` is not canonical base64"))?; + let metadata = object + .get("metadata") + .and_then(Value::as_object) + .cloned() + .ok_or_else(|| ApiError::invalid_request("`metadata` must be an object"))?; + Ok((csr_der, metadata)) +} + +async fn enroll(AxumState(app): AxumState>, headers: HeaderMap, body: Bytes) -> Response { + app.tick().await; + { + let mut state = app.state.lock().await; + state.requests.enroll_total += 1; + state.requests.request_sequence.push("enroll"); + } + let secret = match parse_bearer(&headers) { + Ok(secret) => secret, + Err(err) => return api_error_response(&app, &err), + }; + let mut state = app.state.lock().await; + state.observe_enroll(&secret); + let Some(token_id) = state.token_id(&secret) else { + return api_error_response(&app, &ApiError::token_invalid()); + }; + if state.retry_barrier == Some(DropTarget::Enroll) && state.barrier_triggered == Some(DropTarget::Enroll) { + state.requests.enroll_retry_503 += 1; + return injected_response( + &app, + FailNextResponse { + endpoint: DropTarget::Enroll, + status: 503, + error: None, + retry_after_secs: None, + }, + ); + } + if let Some(fault) = state.fail_next_response(DropTarget::Enroll) { + return injected_response(&app, fault); + } + drop(state); + let (csr_der, metadata) = match parse_csr_body(&body) { + Ok(parsed) => parsed, + Err(err) => return api_error_response(&app, &err), + }; + + let mut state = app.state.lock().await; + let now = app.now(); + let outcome = match state.enroll(&secret, &csr_der, metadata, now, &app.base_url) { + Ok(outcome) => outcome, + Err(err) => { + if err.code == "identity_revoked" { + *state.requests.enroll_revoked_by_token.entry(token_id).or_default() += 1; + } + return api_error_response(&app, &err); + } + }; + let should_drop = state.faults.drop_next_response == Some(DropTarget::Enroll); + if should_drop { + // The mock-only drop fault commits the request, then aborts without a response. + state.faults.drop_next_response = None; + state.barrier_triggered = Some(DropTarget::Enroll); + } + let config = state + .identities + .get(&outcome.identity_id) + .expect("enrolled identity exists") + .config + .clone(); + drop(state); + + let body = json!({ + "authority_id": app.authority_id, + "identity_id": outcome.identity_id, + "certificate_chain": outcome + .certificate_chain + .iter() + .map(|c| crate::ca::base64_der(c)) + .collect::>(), + "config": config, + }); + let mut response = Json(body).into_response(); + if should_drop { + response + .headers_mut() + .insert(DROP_HEADER, "1".parse().expect("static value")); + } + response +} + +async fn renew(AxumState(app): AxumState>, headers: HeaderMap, body: Bytes) -> Response { + app.tick().await; + let now = app.now(); + let mut state = app.state.lock().await; + state.requests.renew += 1; + state.requests.request_sequence.push("renew"); + let keyid = headers + .get("signature-input") + .and_then(|value| value.to_str().ok()) + .and_then(|input| input.split_once(";keyid=\"")) + .and_then(|(_, rest)| rest.split_once('"')) + .map(|(keyid, _)| keyid.to_owned()); + state.requests.renew_attempt_keyids.push(keyid); + if state.retry_barrier == Some(DropTarget::Renew) && state.barrier_triggered == Some(DropTarget::Renew) { + state.requests.renew_retry_503 += 1; + return injected_response( + &app, + FailNextResponse { + endpoint: DropTarget::Renew, + status: 503, + error: None, + retry_after_secs: None, + }, + ); + } + if let Some(fault) = state.fail_next_response(DropTarget::Renew) { + return injected_response(&app, fault); + } + + // §6 verification, checks in the contract's order. + let auth = authenticate(&mut state, Endpoint::Renew, &headers, &body, now); + let auth = match auth { + Ok(auth) => auth, + Err(rejection) => return rejection_response(&app, rejection), + }; + state.record_event( + auth.identity_id(), + "renew_received", + json!({ "cert_thumbprint": auth.cert_thumbprint() }), + ); + + let (csr_der, metadata) = match parse_csr_body(&body) { + Ok(parsed) => parsed, + Err(err) => return api_error_response(&app, &err), + }; + let chain = match state.renew(&auth, &csr_der, metadata, now) { + Ok(chain) => chain, + Err(err) => return api_error_response(&app, &err), + }; + let should_drop = state.faults.drop_next_response == Some(DropTarget::Renew); + if should_drop { + state.faults.drop_next_response = None; + state.barrier_triggered = Some(DropTarget::Renew); + } + drop(state); + + let mut response = Json(json!({ + "certificate_chain": chain.iter().map(|c| crate::ca::base64_der(c)).collect::>(), + })) + .into_response(); + if should_drop { + response + .headers_mut() + .insert(DROP_HEADER, "1".parse().expect("static value")); + } + response +} + +async fn confirm(AxumState(app): AxumState>, headers: HeaderMap, body: Bytes) -> Response { + app.tick().await; + let mut state = app.state.lock().await; + let now = app.now(); + state.requests.confirm += 1; + state.requests.request_sequence.push("confirm"); + if state.retry_barrier == Some(DropTarget::Confirm) && state.barrier_triggered == Some(DropTarget::Confirm) { + state.requests.confirm_retry_503 += 1; + return injected_response( + &app, + FailNextResponse { + endpoint: DropTarget::Confirm, + status: 503, + error: None, + retry_after_secs: None, + }, + ); + } + if let Some(fault) = state.fail_next_response(DropTarget::Confirm) { + if state.retry_barrier == Some(DropTarget::Confirm) { + state.barrier_triggered = Some(DropTarget::Confirm); + } + return injected_response(&app, fault); + } + let auth = match authenticate(&mut state, Endpoint::Confirm, &headers, &body, now) { + Ok(auth) => auth, + Err(rejection) => return rejection_response(&app, rejection), + }; + if !body.is_empty() { + return api_error_response(&app, &ApiError::invalid_request("confirm body must be empty")); + } + state.confirm(&auth, now); + state.requests.request_sequence.push("confirm_204"); + let should_drop = state.faults.drop_next_response == Some(DropTarget::Confirm); + if should_drop { + state.faults.drop_next_response = None; + state.barrier_triggered = Some(DropTarget::Confirm); + } + let mut response = StatusCode::NO_CONTENT.into_response(); + if should_drop { + response + .headers_mut() + .insert(DROP_HEADER, "1".parse().expect("static value")); + } + response +} + +async fn check_in(AxumState(app): AxumState>, headers: HeaderMap, body: Bytes) -> Response { + app.tick().await; + let mut state = app.state.lock().await; + let now = app.now(); + state.requests.check_in += 1; + state.requests.request_sequence.push("check-in"); + if let Some(fault) = state.fail_next_response(DropTarget::CheckIn) { + return injected_response(&app, fault); + } + let auth = match authenticate(&mut state, Endpoint::CheckIn, &headers, &body, now) { + Ok(auth) => auth, + Err(rejection) => return rejection_response(&app, rejection), + }; + let metadata = match parse_check_in_body(&body) { + Ok(metadata) => metadata, + Err(error) => return api_error_response(&app, &error), + }; + let identity = state + .identities + .get_mut(&auth.identity_id()) + .expect("authenticated identity exists"); + identity.metadata = metadata; + identity.last_seen_at = Some(now); + let renewal_requested = identity.renewal_requested.is_some(); + let config = identity.config.clone(); + let revision = identity.config_revision(); + state.record_event( + auth.identity_id(), + "check_in_received", + json!({ "revision": revision, "renewal_requested": renewal_requested }), + ); + let should_drop = state.faults.drop_next_response == Some(DropTarget::CheckIn); + if should_drop { + state.faults.drop_next_response = None; + } + let mut response = Json(json!({ "config": config, "renewal_requested": renewal_requested })).into_response(); + if should_drop { + response + .headers_mut() + .insert(DROP_HEADER, "1".parse().expect("static value")); + } + response +} + +fn parse_check_in_body(body: &[u8]) -> Result, ApiError> { + let value = crate::json::parse_request_body(body) + .map_err(|error| ApiError::invalid_request(format!("malformed JSON body: {error}")))?; + let metadata = value + .get("metadata") + .and_then(Value::as_object) + .cloned() + .ok_or_else(|| ApiError::invalid_request("`metadata` must be an object"))?; + crate::name_eval::validate_metadata(&metadata).map_err(ApiError::invalid_request)?; + Ok(metadata) +} + +fn authenticate( + state: &mut State, + endpoint: Endpoint, + headers: &HeaderMap, + body: &[u8], + now: i64, +) -> Result { + if headers.get_all("signature-input").iter().count() != 1 + || headers.get_all("signature").iter().count() != 1 + || matches!(endpoint, Endpoint::Renew | Endpoint::CheckIn) + && headers.get_all("content-digest").iter().count() != 1 + { + return Err(Rejection::SignatureInvalid); + } + let header_bytes = |name: &str| headers.get(name).map(|value| value.as_bytes().to_vec()); + let signature_input = header_bytes("signature-input"); + let signature = header_bytes("signature"); + let content_digest = header_bytes("content-digest"); + let State { + identities, + cert_index, + nonces, + .. + } = state; + let mut lookup = |keyid: &str| crate::state::lookup_cert(identities, cert_index, keyid); + verify_request( + endpoint, + "POST", + signature_input.as_deref(), + signature.as_deref(), + content_digest.as_deref(), + body, + now, + &mut lookup, + nonces, + ) +} + +#[cfg(test)] +mod tests { + use axum::http::header::AUTHORIZATION; + use axum::http::{HeaderMap, HeaderValue}; + use rstest::rstest; + + use super::{enrollment_token, parse_bearer, parse_check_in_body, parse_csr_body}; + + fn code(result: Result) -> Result<(), &'static str> { + result.map(|_| ()).map_err(|error| error.code) + } + + /// CONTRACT.agent-identity.md §1: "A JSON request body that repeats a member name at any depth [...] → + /// `invalid_request`". + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + #[rstest] + #[case::csr(r#"{"csr":"AAEC","csr":"AAEC","metadata":{}}"#)] + #[case::metadata(r#"{"csr":"AAEC","metadata":{},"metadata":{}}"#)] + #[case::metadata_key(r#"{"csr":"AAEC","metadata":{"hostname":"a","hostname":"b"}}"#)] + #[case::escaped_name(r#"{"csr":"AAEC","c\u0073r":"AAEC","metadata":{}}"#)] + #[case::unknown_member(r#"{"csr":"AAEC","metadata":{},"x":1,"x":1}"#)] + #[case::object_in_array(r#"{"csr":"AAEC","metadata":{},"x":[{"y":1,"y":1}]}"#)] + fn csr_body_with_a_repeated_member_name_is_invalid_request(#[case] body: &str) { + assert_eq!(code(parse_csr_body(body.as_bytes())), Err("invalid_request")); + } + + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + #[rstest] + #[case::metadata(r#"{"metadata":{},"metadata":{}}"#)] + #[case::metadata_key(r#"{"metadata":{"hostname":"a","hostname":"b"}}"#)] + fn check_in_body_with_a_repeated_member_name_is_invalid_request(#[case] body: &str) { + assert_eq!(code(parse_check_in_body(body.as_bytes())), Err("invalid_request")); + } + + /// CONTRACT.agent-identity.md §1: "A JSON request body with more than 64 nested objects and arrays, counting the + /// top-level object, [...] → `invalid_request`". + /// The unknown member `x` nests `wrappers` containers around `innermost`, so each `_64` case holds 64 objects and + /// arrays and each `_65` case 65, counting the top-level object and an empty innermost one. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + #[rstest] + #[case::arrays_64(63, "[", "0", "]", Ok(()))] + #[case::arrays_65(64, "[", "0", "]", Err("invalid_request"))] + #[case::objects_64(63, r#"{"y":"#, "0", "}", Ok(()))] + #[case::objects_65(64, r#"{"y":"#, "0", "}", Err("invalid_request"))] + #[case::empty_array_65(63, r#"{"y":"#, "[]", "}", Err("invalid_request"))] + #[case::empty_object_65(63, "[", "{}", "]", Err("invalid_request"))] + fn csr_body_nests_at_most_64_objects_and_arrays( + #[case] wrappers: usize, + #[case] open: &str, + #[case] innermost: &str, + #[case] close: &str, + #[case] expected: Result<(), &'static str>, + ) { + let nested = format!("{}{innermost}{}", open.repeat(wrappers), close.repeat(wrappers)); + let body = format!(r#"{{"csr":"AAEC","metadata":{{}},"x":{nested}}}"#); + assert_eq!(code(parse_csr_body(body.as_bytes())), expected); + } + + /// CONTRACT.agent-identity.md §1: a body "holding a string that is not valid Unicode, such as an escaped lone + /// surrogate, in any member, including an unknown one → `invalid_request`". + /// `hostname` is a known metadata key and `x` an unknown member; a surrogate pair in order escapes one scalar + /// value, U+1F600, and `ED A0 80` is U+D800 encoded as if it were a scalar value, which UTF-8 forbids. + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + #[rstest] + #[case::escaped_surrogate_pair(br#"{"csr":"AAEC","metadata":{"hostname":"\ud83d\ude00"}}"#, Ok(()))] + #[case::escaped_lone_leading_surrogate( + br#"{"csr":"AAEC","metadata":{"hostname":"\ud800"}}"#, + Err("invalid_request") + )] + #[case::escaped_lone_trailing_surrogate(br#"{"csr":"AAEC","metadata":{},"x":"\udc00"}"#, Err("invalid_request"))] + #[case::escaped_reversed_surrogate_pair( + br#"{"csr":"AAEC","metadata":{},"x":"\ude00\ud83d"}"#, + Err("invalid_request") + )] + #[case::escaped_lone_surrogate_in_a_name(br#"{"csr":"AAEC","metadata":{},"\ud800":0}"#, Err("invalid_request"))] + #[case::raw_invalid_byte(b"{\"csr\":\"AAEC\",\"metadata\":{\"hostname\":\"\xff\"}}", Err("invalid_request"))] + #[case::raw_encoded_surrogate(b"{\"csr\":\"AAEC\",\"metadata\":{},\"x\":\"\xed\xa0\x80\"}", Err("invalid_request"))] + fn csr_body_strings_must_be_valid_unicode(#[case] body: &[u8], #[case] expected: Result<(), &'static str>) { + assert_eq!(code(parse_csr_body(body)), expected); + } + + /// One name in several objects is not a repetition, and receivers ignore unknown fields (CONTRACT.agent-identity.md + /// §1). + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + #[test] + fn same_name_in_different_objects_is_accepted() { + let body = r#"{"csr":"AAEC","metadata":{"csr":"a"},"x":{"csr":[1,true,null,{"csr":0.5}]}}"#; + assert_eq!(code(parse_csr_body(body.as_bytes())), Ok(())); + } + + /// CONTRACT.agent-identity.md §1: "a request body holding another form [than the canonical `base64`] → + /// `invalid_request`". + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + #[rstest] + #[case::canonical("AAE=", Ok(()))] + #[case::missing_padding("AAE", Err("invalid_request"))] + #[case::nonzero_pad_bits("AAF=", Err("invalid_request"))] + #[case::url_alphabet("-_8=", Err("invalid_request"))] + #[case::line_break("AA\\nE=", Err("invalid_request"))] + fn csr_must_be_canonical_base64(#[case] csr: &str, #[case] expected: Result<(), &'static str>) { + let body = format!(r#"{{"csr":"{csr}","metadata":{{}}}}"#); + assert_eq!(code(parse_csr_body(body.as_bytes())), expected); + } + + fn bearer(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).expect("valid header"), + ); + headers + } + + const BASE_URL: &str = "https://127.0.0.1:1/mock"; + + fn with_bag(token: &str, bag: &str) -> String { + let (prefix, rest) = token.split_once('.').expect("token prefix"); + let (_, secret) = rest.split_once('.').expect("token secret"); + format!("{prefix}.{bag}.{secret}") + } + + /// CONTRACT.agent-identity.md §2: "The authority authenticates a token by its exact `secret` text alone and never checks the `bag`". + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + #[rstest] + #[case::issued_token(|token: &str| token.to_owned())] + #[case::other_json_bag(|token: &str| with_bag(token, "e30"))] + #[case::undecodable_bag(|token: &str| with_bag(token, "A"))] + #[case::empty_bag(|token: &str| with_bag(token, ""))] + fn token_authenticates_by_its_secret_text_alone(#[case] alter: fn(&str) -> String) { + let token = alter(&enrollment_token(BASE_URL, &[0; 32])); + assert_eq!(parse_bearer(&bearer(&token)).ok(), Some([0; 32])); + } + + /// Any other secret text is another token, even a non-canonical `base64url` encoding of the same bytes (CONTRACT.agent-identity.md §1, §2). + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §conventions + // oracle: contract docs/agent-identity/CONTRACT.agent-identity.md §enrollment-token + #[rstest] + #[case::padded_secret(|token: &str| format!("{token}="))] + #[case::nonzero_pad_bits(|token: &str| format!("{}B", &token[..token.len() - 1]))] + #[case::standard_alphabet(|token: &str| format!("{}+", &token[..token.len() - 1]))] + #[case::other_prefix(|token: &str| token.replacen("dvaet1", "dvaet2", 1))] + fn token_with_other_secret_text_is_invalid(#[case] alter: fn(&str) -> String) { + let token = alter(&enrollment_token(BASE_URL, &[0; 32])); + assert_eq!(code(parse_bearer(&bearer(&token))), Err("token_invalid")); + } +} diff --git a/crates/agent-identity-mock/src/api_mock.rs b/crates/agent-identity-mock/src/api_mock.rs new file mode 100644 index 000000000..60cfa69fe --- /dev/null +++ b/crates/agent-identity-mock/src/api_mock.rs @@ -0,0 +1,465 @@ +//! This module serves the mock control API ([mock intent][mock-intent], "Mock-only control") +//! under `/__mock__`. +//! No authentication is required. +//! +//! [mock-intent]: ../INTENT.md#mock-only-control + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{Query, State as AxumState}; +use axum::http::StatusCode; +use axum::response::{IntoResponse as _, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use uuid::Uuid; + +use crate::app::{App, rfc3339}; +use crate::state::{ApiError, DropTarget, FailNextResponse, Faults, PushKind}; + +pub(crate) fn router(app: Arc) -> Router { + Router::new() + .route("/__mock__/faults", post(faults)) + .route("/__mock__/config", post(update_config)) + .route("/__mock__/config/stale", post(stale_config)) + .route("/__mock__/reset", post(reset)) + .route("/__mock__/time/advance", post(time_advance)) + .route("/__mock__/time/freeze", post(time_freeze)) + .route("/__mock__/events", get(events)) + .route("/__mock__/handshake", post(handshake).get(handshake_state)) + .route("/__mock__/retry-barrier", post(retry_barrier)) + .route("/__mock__/requests", get(requests)) + .route("/__mock__/redirect-target", get(redirect_target).post(redirect_target)) + .route("/__mock__/reconnect", post(reconnect)) + .with_state(app) +} + +#[derive(Deserialize)] +struct RequestQuery { + token_id: Option, +} + +async fn requests(AxumState(app): AxumState>, Query(query): Query) -> Json { + let state = app.state.lock().await; + let enroll = query.token_id.map_or_else( + || state.requests.enroll_by_token.values().sum(), + |id| state.requests.enroll_by_token.get(&id).copied().unwrap_or(0), + ); + let enroll_identity_revoked = query.token_id.map_or_else( + || state.requests.enroll_revoked_by_token.values().sum(), + |id| state.requests.enroll_revoked_by_token.get(&id).copied().unwrap_or(0), + ); + Json(json!({ + "enroll": enroll, + "enroll_identity_revoked": enroll_identity_revoked, + "enroll_total": state.requests.enroll_total, + "enroll_retry_503": state.requests.enroll_retry_503, + "renew": state.requests.renew, + "renew_attempt_keyids": state.requests.renew_attempt_keyids, + "renew_retry_503": state.requests.renew_retry_503, + "confirm": state.requests.confirm, + "confirm_retry_503": state.requests.confirm_retry_503, + "check_in": state.requests.check_in, + "connect": state.requests.connect, + "channel_attempts": state.requests.channel_attempts, + "redirect_hits": state.requests.redirect_hits, + "request_sequence": state.requests.request_sequence, + "authenticated_connects": state.requests.authenticated_connects, + "correlated_acks": state.requests.correlated_acks, + "overlap_open": state.requests.overlap_open, + "active_streams": state.streams.len(), + "paused_hellos": state.paused_hellos.len(), + })) +} + +async fn redirect_target(AxumState(app): AxumState>) -> StatusCode { + app.state.lock().await.requests.redirect_hits += 1; + StatusCode::IM_A_TEAPOT +} + +#[derive(Deserialize)] +struct EventQuery { + identity_id: Uuid, +} + +async fn events(AxumState(app): AxumState>, Query(query): Query) -> Json { + let state = app.state.lock().await; + Json(json!({ + "events": state.events.iter() + .filter(|event| event.identity_id == query.identity_id) + .map(|event| &event.body) + .collect::>(), + })) +} + +#[derive(Deserialize)] +struct HandshakeRequest { + pause: bool, +} + +async fn handshake(AxumState(app): AxumState>, body: Bytes) -> Response { + let request: HandshakeRequest = match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => return control_error(&ApiError::invalid_request("body requires a boolean pause field")), + }; + app.handshake_gate.send_replace(!request.pause); + handshake_view(&app).await.into_response() +} + +async fn handshake_state(AxumState(app): AxumState>) -> Json { + handshake_view(&app).await +} + +async fn handshake_view(app: &App) -> Json { + let gate = app.handshake_gate.subscribe(); + let paused = !*gate.borrow(); + let state = app.state.lock().await; + let mut paused_stream_ids = state.paused_hellos.iter().copied().collect::>(); + paused_stream_ids.sort_unstable(); + Json(json!({ "paused": paused, "paused_stream_ids": paused_stream_ids })) +} + +#[derive(Deserialize)] +struct RetryBarrierRequest { + endpoint: String, + pause: bool, +} + +async fn retry_barrier(AxumState(app): AxumState>, body: Bytes) -> Response { + let request: RetryBarrierRequest = match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => return control_error(&ApiError::invalid_request("body requires endpoint and boolean pause")), + }; + let endpoint = match request.endpoint.as_str() { + "enroll" => DropTarget::Enroll, + "renew" => DropTarget::Renew, + "confirm" => DropTarget::Confirm, + _ => return control_error(&ApiError::invalid_request("endpoint must be enroll, renew or confirm")), + }; + let mut state = app.state.lock().await; + if request.pause { + state.retry_barrier = Some(endpoint); + state.barrier_triggered = None; + } else if state.retry_barrier == Some(endpoint) { + state.retry_barrier = None; + } + Json(json!({ "paused": state.retry_barrier == Some(endpoint) })).into_response() +} + +#[derive(Deserialize)] +struct ReconnectRequest { + identity_id: Uuid, +} + +async fn reconnect(AxumState(app): AxumState>, Json(request): Json) -> Response { + let mut state = app.state.lock().await; + if !state.identities.contains_key(&request.identity_id) { + return control_error(&ApiError::not_found("unknown identity")); + } + state.push_to_identity(request.identity_id, &PushKind::Reconnect("mock")); + StatusCode::ACCEPTED.into_response() +} + +async fn update_config(AxumState(app): AxumState>, body: Bytes) -> Response { + let value: Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => return control_error(&ApiError::invalid_request("malformed JSON body")), + }; + let Some(fields) = value.get("fields").and_then(Value::as_object) else { + return control_error(&ApiError::invalid_request("fields must be a JSON object")); + }; + if fields + .keys() + .any(|name| matches!(name.as_str(), "version" | "revision" | "agent_channel_url")) + { + return control_error(&ApiError::invalid_request("config fields cannot replace reserved keys")); + } + let mut state = app.state.lock().await; + match state.merge_config_fields(fields) { + Ok(updated) => Json(json!({ "updatedIdentities": updated })).into_response(), + Err(error) => control_error(&error), + } +} + +#[derive(Deserialize)] +struct StaleConfigRequest { + identity_id: Uuid, + revision: u64, +} + +async fn stale_config(AxumState(app): AxumState>, body: Bytes) -> Response { + let request: StaleConfigRequest = match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => return control_error(&ApiError::invalid_request("body requires identity_id and revision")), + }; + let mut state = app.state.lock().await; + match state.replay_stale_config(request.identity_id, request.revision) { + Ok(sent) => Json(json!({ "sent": sent })).into_response(), + Err(error) => control_error(&error), + } +} + +fn control_error(err: &ApiError) -> Response { + ( + StatusCode::from_u16(err.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + Json(json!({ "error": err.code, "message": err.message })), + ) + .into_response() +} + +fn faults_view(faults: &Faults) -> Value { + json!({ + "drop_next_response": faults.drop_next_response.map(|t| match t { + DropTarget::Enroll => "enroll", + DropTarget::Renew => "renew", + DropTarget::Confirm => "confirm", + DropTarget::CheckIn => "check-in", + }), + "fail_next_response": faults.fail_next_response.map(|fault| json!({ + "endpoint": match fault.endpoint { + DropTarget::Enroll => "enroll", + DropTarget::Renew => "renew", + DropTarget::Confirm => "confirm", + DropTarget::CheckIn => "check-in", + }, + "status": fault.status, + "error": fault.error, + "retry_after_secs": fault.retry_after_secs, + })), + "clock_skew_secs": faults.clock_skew_secs, + "leaf_lifetime_secs": faults.leaf_lifetime_secs, + "channel_available": faults.channel_available, + "channel_broken": faults.channel_broken, + "malformed_channel_url": match faults.malformed_channel_url { + None => json!(false), + Some(crate::state::MalformedChannelUrl::Http) => json!(true), + Some(crate::state::MalformedChannelUrl::Query) => json!("query"), + Some(crate::state::MalformedChannelUrl::Fragment) => json!("fragment"), + }, + "rotation_rate_limit_per_sec": faults.rotation_rate_limit_per_sec, + "config_version": faults.config_version, + "root_key_unavailable": faults.root_key_unavailable, + }) +} + +/// `POST faults` merges fields: absent keys are unchanged, and `null` clears an optional field. +async fn faults(AxumState(app): AxumState>, body: Bytes) -> Response { + app.tick().await; + let value: Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => return control_error(&ApiError::invalid_request("malformed JSON body")), + }; + let Some(patch) = value.as_object() else { + return control_error(&ApiError::invalid_request("body must be a JSON object")); + }; + + let invalid = |msg: &str| control_error(&ApiError::invalid_request(msg)); + let mut state = app.state.lock().await; + for (key, value) in patch { + match key.as_str() { + "fail_next_response" => match value { + Value::Null => state.faults.fail_next_response = None, + Value::Object(fields) => { + let endpoint = match fields.get("endpoint").and_then(Value::as_str) { + Some("enroll") => DropTarget::Enroll, + Some("renew") => DropTarget::Renew, + Some("confirm") => DropTarget::Confirm, + Some("check-in") => DropTarget::CheckIn, + _ => return invalid("fail_next_response.endpoint must be enroll, renew, confirm or check-in"), + }; + let Some(status) = fields + .get("status") + .and_then(Value::as_u64) + .and_then(|status| u16::try_from(status).ok()) + .filter(|status| (300..=599).contains(status)) + else { + return invalid("fail_next_response.status must be 300..599"); + }; + let retry_after_secs = match fields.get("retry_after_secs") { + None => None, + Some(Value::Number(value)) => match value.as_u64() { + Some(secs) => Some(secs), + None => return invalid("fail_next_response.retry_after_secs must be nonnegative"), + }, + Some(_) => return invalid("fail_next_response.retry_after_secs must be nonnegative"), + }; + let error = match fields.get("error") { + None => None, + Some(Value::String(value)) => Some(match value.as_str() { + "token_invalid" => "token_invalid", + "token_exhausted" => "token_exhausted", + "token_expired" => "token_expired", + "identity_revoked" => "identity_revoked", + "identity_unknown" => "identity_unknown", + "certificate_expired" => "certificate_expired", + "signature_invalid" => "signature_invalid", + "clock_skew" => "clock_skew", + "invalid_request" => "invalid_request", + "unavailable" => "unavailable", + _ => return invalid("fail_next_response.error must be a §5.4 error code"), + }), + Some(_) => return invalid("fail_next_response.error must be a §5.4 error code"), + }; + state.faults.fail_next_response = Some(FailNextResponse { + endpoint, + status, + error, + retry_after_secs, + }); + } + _ => return invalid("fail_next_response must be an object or null"), + }, + "drop_next_response" => match value { + Value::Null => state.faults.drop_next_response = None, + Value::String(s) if s == "enroll" => state.faults.drop_next_response = Some(DropTarget::Enroll), + Value::String(s) if s == "renew" => state.faults.drop_next_response = Some(DropTarget::Renew), + Value::String(s) if s == "confirm" => state.faults.drop_next_response = Some(DropTarget::Confirm), + Value::String(s) if s == "check-in" => state.faults.drop_next_response = Some(DropTarget::CheckIn), + _ => return invalid("drop_next_response must be enroll, renew, confirm, check-in or null"), + }, + "clock_skew_secs" => match value { + Value::Null => state.faults.clock_skew_secs = None, + Value::Number(n) => match n.as_i64() { + Some(secs) => state.faults.clock_skew_secs = Some(secs), + None => return invalid("clock_skew_secs must be an integer or null"), + }, + _ => return invalid("clock_skew_secs must be an integer or null"), + }, + "leaf_lifetime_secs" => match value { + Value::Null => state.faults.leaf_lifetime_secs = None, + Value::Number(n) => match n.as_i64() { + Some(secs) if secs > 0 => state.faults.leaf_lifetime_secs = Some(secs), + _ => return invalid("leaf_lifetime_secs must be a positive integer or null"), + }, + _ => return invalid("leaf_lifetime_secs must be a positive integer or null"), + }, + "channel_available" => match value { + Value::Bool(b) => { + if let Err(error) = state.update_channel_available(*b, &app.base_url) { + return control_error(&error); + } + } + _ => return invalid("channel_available must be a boolean"), + }, + "channel_broken" => match value { + Value::Bool(b) => state.faults.channel_broken = *b, + _ => return invalid("channel_broken must be a boolean"), + }, + "malformed_channel_url" => match value { + Value::Null | Value::Bool(false) => state.faults.malformed_channel_url = None, + Value::Bool(true) => { + state.faults.malformed_channel_url = Some(crate::state::MalformedChannelUrl::Http); + } + Value::String(value) => { + state.faults.malformed_channel_url = Some(match value.as_str() { + "query" => crate::state::MalformedChannelUrl::Query, + "fragment" => crate::state::MalformedChannelUrl::Fragment, + _ => return invalid("malformed_channel_url must be true, false, query, fragment or null"), + }); + } + _ => return invalid("malformed_channel_url must be true, false, query, fragment or null"), + }, + "rotation_rate_limit_per_sec" => match value { + Value::Null => state.faults.rotation_rate_limit_per_sec = None, + Value::Number(n) => match n.as_u64().and_then(|v| u32::try_from(v).ok()) { + Some(rate) if rate > 0 => state.faults.rotation_rate_limit_per_sec = Some(rate), + _ => return invalid("rotation_rate_limit_per_sec must be a positive integer or null"), + }, + _ => return invalid("rotation_rate_limit_per_sec must be a positive integer or null"), + }, + "config_version" => { + let version = match value { + Value::Null => None, + Value::Number(n) => match n.as_u64() { + Some(version) => Some(version), + None => return invalid("config_version must be an unsigned integer or null"), + }, + _ => return invalid("config_version must be an unsigned integer or null"), + }; + if let Err(error) = state.update_config_version(version) { + return control_error(&error); + } + } + "root_key_unavailable" => match value { + Value::Bool(b) => state.faults.root_key_unavailable = *b, + _ => return invalid("root_key_unavailable must be a boolean"), + }, + _ => return invalid("unknown fault field"), + } + } + app.clock.set_skew(state.faults.clock_skew_secs); + state.tick(app.now()); + Json(faults_view(&state.faults)).into_response() +} + +#[derive(Deserialize, Default)] +#[serde(deny_unknown_fields)] +struct ResetRequest { + #[serde(default)] + without_root: bool, +} + +/// `POST reset` clears tokens, identities, nonces, faults, settings, rotation and the clock offset. +/// It creates a fresh root, unless `without_root` asks for a DVLS that has never created one, and keeps +/// `authority_id`, the TLS certificate, and the management API's application identity and sessions. +async fn reset(AxumState(app): AxumState>, body: Bytes) -> Response { + let request = if body.is_empty() { + ResetRequest::default() + } else { + match serde_json::from_slice::(&body) { + Ok(request) => request, + Err(_) => return control_error(&ApiError::invalid_request("body allows only a boolean without_root")), + } + }; + let mut state = app.state.lock().await; + app.clock.reset(); + app.handshake_gate.send_replace(true); + match state.reset(app.now(), !request.without_root) { + Ok(()) => Json(json!({ "authority_id": app.authority_id })).into_response(), + Err(err) => control_error(&ApiError::internal(format!("reset failed: {err:#}"))), + } +} + +/// `POST time/advance { secs }`: advances the mock clock. +async fn time_advance(AxumState(app): AxumState>, body: Bytes) -> Response { + let value: Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => return control_error(&ApiError::invalid_request("malformed JSON body")), + }; + let secs = value + .as_object() + .and_then(|o: &Map| o.get("secs")) + .and_then(Value::as_i64) + .ok_or_else(|| ApiError::invalid_request("`secs` must be an integer")); + let secs = match secs { + Ok(secs) => secs, + Err(err) => return control_error(&err), + }; + let mut state = app.state.lock().await; + app.clock.advance_by(secs); + let now = app.now(); + state.tick(now); + Json(json!({ "now": now, "server_time": rfc3339(now) })).into_response() +} + +async fn time_freeze(AxumState(app): AxumState>, body: Bytes) -> Response { + let value: Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(_) => return control_error(&ApiError::invalid_request("malformed JSON body")), + }; + let Some(now) = value.get("now").and_then(Value::as_i64).filter(|now| *now != i64::MIN) else { + return control_error(&ApiError::invalid_request("`now` must be a Unix second")); + }; + let mut state = app.state.lock().await; + app.clock.freeze_at(now); + state.tick(now); + let published_roots = state + .roots + .iter() + .filter(|root| root.published) + .map(|root| &root.thumbprint) + .collect::>(); + Json(json!({ "now": now, "published_roots": published_roots })).into_response() +} diff --git a/crates/agent-identity-mock/src/app.rs b/crates/agent-identity-mock/src/app.rs new file mode 100644 index 000000000..e0237e5c5 --- /dev/null +++ b/crates/agent-identity-mock/src/app.rs @@ -0,0 +1,99 @@ +//! Shared application handle and time helpers. + +use std::sync::Arc; + +use tokio::sync::{Mutex, watch}; +use uuid::Uuid; + +use crate::api_admin::ManagementLogin; +use crate::clock::MockClock; +use crate::state::State; + +pub struct App { + /// Stable for the lifetime of the process, including across resets (§5.2). + pub authority_id: Uuid, + /// e.g. `https://127.0.0.1:8443/mock`. + pub base_url: String, + /// e.g. `/mock`. + pub prefix: String, + /// The management API's application identity and its sessions, which a reset keeps; see the README for the keys + /// derived from it. + pub login: ManagementLogin, + pub clock: MockClock, + /// Mock-only barrier for proving that a key survives until Hello authenticates. + pub handshake_gate: watch::Sender, + pub state: Mutex, +} + +impl App { + /// Builds an app under the `/mock` prefix whose application identity is `admin` with secret `secret` and DVLS's + /// 7-minute `tokenId` lifetime, for handler tests. + #[cfg(test)] + pub(crate) fn for_test() -> anyhow::Result> { + let clock = MockClock::new(); + let state = State::with_root(clock.now())?; + Ok(Arc::new(App { + authority_id: Uuid::new_v4(), + base_url: "https://127.0.0.1:1/mock".to_owned(), + prefix: "/mock".to_owned(), + login: ManagementLogin::new( + "admin".to_owned(), + "secret".to_owned(), + std::time::Duration::from_secs(7 * 60), + ), + clock, + handshake_gate: watch::channel(true).0, + state: Mutex::new(state), + })) + } + + pub fn now(&self) -> i64 { + self.clock.now() + } + + /// Re-evaluates certificate expiry and rotation completion before each request. + pub async fn tick(self: &Arc) { + let mut state = self.state.lock().await; + state.tick(self.now()); + } +} + +/// RFC 3339 UTC with a `Z` designator, as in the contract's examples (§1). +pub(crate) fn rfc3339(unix_secs: i64) -> String { + use time::format_description::well_known::Rfc3339; + match time::OffsetDateTime::from_unix_timestamp(unix_secs) { + Ok(t) => t.format(&Rfc3339).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()), + Err(_) => "1970-01-01T00:00:00Z".to_owned(), + } +} + +const NANOS_PER_SEC: i128 = 1_000_000_000; + +/// Converts Unix seconds to Unix nanoseconds, the precision of management API date-times. +pub(crate) fn nanos(unix_secs: i64) -> i128 { + i128::from(unix_secs) * NANOS_PER_SEC +} + +/// RFC 3339 UTC with a `Z` designator and only the fractional digits the instant needs. +pub(crate) fn rfc3339_nanos(unix_nanos: i128) -> String { + use time::format_description::well_known::Rfc3339; + match time::OffsetDateTime::from_unix_timestamp_nanos(unix_nanos) { + Ok(t) => t.format(&Rfc3339).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()), + Err(_) => "1970-01-01T00:00:00Z".to_owned(), + } +} + +/// Parses a management API date-time to Unix nanoseconds, keeping its fraction of a second: RFC 3339, or ISO 8601 +/// without an offset, which is UTC. +/// DVLS's OpenAPI document reads every date-time "UTC unless an offset is given". +pub(crate) fn parse_date_time(value: &str) -> Option { + use time::format_description::well_known::{Iso8601, Rfc3339}; + time::OffsetDateTime::parse(value, &Rfc3339) + .ok() + .or_else(|| { + time::PrimitiveDateTime::parse(value, &Iso8601::DEFAULT) + .ok() + .map(time::PrimitiveDateTime::assume_utc) + }) + .map(time::OffsetDateTime::unix_timestamp_nanos) +} diff --git a/crates/agent-identity-mock/src/ca.rs b/crates/agent-identity-mock/src/ca.rs new file mode 100644 index 000000000..974d77065 --- /dev/null +++ b/crates/agent-identity-mock/src/ca.rs @@ -0,0 +1,285 @@ +//! In-memory CA per CONTRACT.agent-identity.md §4: a P-256 root per rotation generation and +//! per-identity leaves (`CN=`, SAN URI `urn:uuid:`, EKU +//! clientAuth, KU digitalSignature). + +use std::str::FromStr as _; + +use anyhow::Context as _; +use base64::Engine as _; +use der::Encode as _; +use der::asn1::{Ia5String, ObjectIdentifier}; +use p256::ecdsa::{DerSignature, SigningKey, VerifyingKey}; +use p256::elliptic_curve::Generate as _; +use sha2::Digest as _; +use spki::EncodePublicKey as _; +use x509_cert::builder::profile::BuilderProfile; +use x509_cert::builder::{Builder as _, CertificateBuilder}; +use x509_cert::ext::Extension; +use x509_cert::ext::pkix::name::GeneralName; +use x509_cert::ext::pkix::{BasicConstraints, ExtendedKeyUsage, KeyUsage, KeyUsages, SubjectAltName}; +use x509_cert::name::Name; +use x509_cert::serial_number::SerialNumber; +use x509_cert::time::{Time, Validity}; + +/// id-kp-clientAuth (RFC 5280). +const ID_KP_CLIENT_AUTH: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.5.5.7.3.2"); + +const ROOT_LIFETIME_SECS: i64 = 10 * 365 * 24 * 3600; + +/// `base64url(SHA-256(certificate DER))` (§1). +pub(crate) fn thumbprint(der: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sha2::Sha256::digest(der)) +} + +/// Base64 (standard, padded) of a DER blob, for JSON bodies (§1). +pub(crate) fn base64_der(der: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(der) +} + +fn unix_to_time(secs: i64) -> anyhow::Result