From 57859a329c613a4cf72635ed4bf6dc234e4f3bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 20 Oct 2026 04:00:36 +0900 Subject: [PATCH 001/169] feat(agent): add Agent Identity device enrollment and agent channel Devolutions Agent gains an identity per authority (e.g.: DVLS, PSU): the device enrolls once from a reusable token, the authority mints an identity and issues an X.509 certificate, and the agent authenticates later requests and a bidirectional gRPC channel with that identity. - `docs/agent-identity/CONTRACT.md` defines what the agent and an authority agree on, with shared test vectors and an independent vector verifier; the design rationale lives in the shared intent `INTENT.agent-identity.md`, symlinked into each component. - `device-key` keeps device keys in the Windows software provider or in files, under an access rule granting only the caller and SYSTEM. - `agent-identity` and `agent-identity-httpsig` hold pending enrollment, stored identities, the REST client and RFC 9421 request signing; `agent-channel-proto` defines the channel for Rust and .NET. - `devolutions-agent identity enroll` writes a pending enrollment, and the service enrolls from it. - `agent-identity-mock` and `agent-identity-conformance` form an independent conformance suite that the testsuite runs against the agent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 + .github/workflows/publish-libraries.yml | 4 +- Cargo.lock | 423 +- Cargo.toml | 4 + clippy.toml | 5 + .../INTENT.agent-identity.md | 1 + crates/agent-channel-client/INTENT.md | 4 + crates/agent-channel-proto/Cargo.toml | 20 + .../INTENT.agent-identity.md | 1 + crates/agent-channel-proto/INTENT.md | 18 + crates/agent-channel-proto/build.rs | 11 + .../Devolutions.Agent.Channel.csproj | 31 + crates/agent-channel-proto/dotnet/build.ps1 | 17 + .../agent-channel-proto/proto/channel.proto | 65 + crates/agent-channel-proto/src/lib.rs | 18 + crates/agent-identity-conformance/Cargo.toml | 52 + .../INTENT.agent-identity.md | 1 + crates/agent-identity-conformance/INTENT.md | 84 + crates/agent-identity-conformance/README.md | 44 + .../agent-identity-conformance/src/agent.rs | 5696 ++++++++++++++++ .../agent-identity-conformance/src/channel.rs | 254 + .../agent-identity-conformance/src/client.rs | 533 ++ crates/agent-identity-conformance/src/main.rs | 703 ++ .../src/protocol.rs | 5953 +++++++++++++++++ .../agent-identity-conformance/src/signer.rs | 263 + .../src/system_fixture.rs | 805 +++ .../src/system_probe.rs | 431 ++ .../agent-identity-conformance/src/windows.rs | 1014 +++ .../tests/skip_exit.rs | 96 + crates/agent-identity-httpsig/Cargo.toml | 30 + .../INTENT.agent-identity.md | 1 + crates/agent-identity-httpsig/INTENT.md | 10 + crates/agent-identity-httpsig/clippy.toml | 7 + crates/agent-identity-httpsig/src/lib.rs | 317 + crates/agent-identity-httpsig/src/tests.rs | 375 ++ crates/agent-identity-mock/Cargo.toml | 58 + .../INTENT.agent-identity.md | 1 + crates/agent-identity-mock/INTENT.md | 40 + crates/agent-identity-mock/README.md | 132 + crates/agent-identity-mock/clippy.toml | 9 + crates/agent-identity-mock/src/api_admin.rs | 581 ++ crates/agent-identity-mock/src/api_agent.rs | 431 ++ crates/agent-identity-mock/src/api_mock.rs | 423 ++ crates/agent-identity-mock/src/app.rs | 53 + crates/agent-identity-mock/src/ca.rs | 201 + crates/agent-identity-mock/src/channel.rs | 599 ++ crates/agent-identity-mock/src/clock.rs | 99 + crates/agent-identity-mock/src/lib.rs | 22 + crates/agent-identity-mock/src/main.rs | 155 + crates/agent-identity-mock/src/name_eval.rs | 138 + crates/agent-identity-mock/src/oracle/csr.rs | 46 + crates/agent-identity-mock/src/oracle/mod.rs | 39 + .../agent-identity-mock/src/oracle/proof.rs | 24 + crates/agent-identity-mock/src/oracle/sfv.rs | 303 + .../agent-identity-mock/src/oracle/verify.rs | 414 ++ crates/agent-identity-mock/src/server.rs | 201 + crates/agent-identity-mock/src/state.rs | 1517 +++++ .../agent-identity-mock/src/vector_tests.rs | 397 ++ crates/agent-identity/Cargo.toml | 52 + .../agent-identity/INTENT.agent-identity.md | 1 + crates/agent-identity/INTENT.md | 115 + crates/agent-identity/clippy.toml | 9 + crates/agent-identity/src/client.rs | 777 +++ crates/agent-identity/src/csr.rs | 105 + crates/agent-identity/src/lib.rs | 14 + crates/agent-identity/src/macos_acl.rs | 96 + crates/agent-identity/src/metadata.rs | 259 + crates/agent-identity/src/pending.rs | 486 ++ crates/agent-identity/src/private_file.rs | 495 ++ crates/agent-identity/src/rejected.rs | 117 + crates/agent-identity/src/state.rs | 1328 ++++ crates/agent-identity/src/state_lock.rs | 154 + crates/agent-identity/src/token.rs | 149 + crates/agent-identity/src/windows.rs | 1617 +++++ crates/device-key/Cargo.toml | 39 + crates/device-key/INTENT.md | 85 + crates/device-key/clippy.toml | 7 + crates/device-key/src/file.rs | 955 +++ crates/device-key/src/keystore.rs | 629 ++ crates/device-key/src/lib.rs | 199 + crates/device-key/src/macos_acl.rs | 93 + crates/device-key/src/windows_acl.rs | 72 + .../device-key/test-data/fixed-signature.json | 5 + crates/http-client-proxy/src/lib.rs | 23 +- devolutions-agent/Cargo.toml | 3 + devolutions-agent/src/config.rs | 180 +- devolutions-agent/src/identity.rs | 335 + .../src/identity/INTENT.agent-identity.md | 1 + devolutions-agent/src/identity/INTENT.md | 36 + devolutions-agent/src/lib.rs | 1 + devolutions-agent/src/main.rs | 59 + devolutions-agent/src/service.rs | 13 + docs/agent-identity/CONTRACT.md | 441 ++ docs/agent-identity/INTENT.DVLS.md | 72 + docs/agent-identity/INTENT.agent-identity.md | 163 + docs/agent-identity/test-vectors.json | 1298 ++++ docs/agent-identity/verify_vectors.py | 165 + .../INTENT.agent-identity.md | 1 + package/AgentWindowsManaged/INTENT.md | 8 + testsuite/src/cli.rs | 36 + testsuite/tests/agent_identity/mod.rs | 405 ++ testsuite/tests/main.rs | 1 + 102 files changed, 34236 insertions(+), 41 deletions(-) create mode 120000 crates/agent-channel-client/INTENT.agent-identity.md create mode 100644 crates/agent-channel-client/INTENT.md create mode 100644 crates/agent-channel-proto/Cargo.toml create mode 120000 crates/agent-channel-proto/INTENT.agent-identity.md create mode 100644 crates/agent-channel-proto/INTENT.md create mode 100644 crates/agent-channel-proto/build.rs create mode 100644 crates/agent-channel-proto/dotnet/Devolutions.Agent.Channel/Devolutions.Agent.Channel.csproj create mode 100644 crates/agent-channel-proto/dotnet/build.ps1 create mode 100644 crates/agent-channel-proto/proto/channel.proto create mode 100644 crates/agent-channel-proto/src/lib.rs create mode 100644 crates/agent-identity-conformance/Cargo.toml create mode 120000 crates/agent-identity-conformance/INTENT.agent-identity.md create mode 100644 crates/agent-identity-conformance/INTENT.md create mode 100644 crates/agent-identity-conformance/README.md create mode 100644 crates/agent-identity-conformance/src/agent.rs create mode 100644 crates/agent-identity-conformance/src/channel.rs create mode 100644 crates/agent-identity-conformance/src/client.rs create mode 100644 crates/agent-identity-conformance/src/main.rs create mode 100644 crates/agent-identity-conformance/src/protocol.rs create mode 100644 crates/agent-identity-conformance/src/signer.rs create mode 100644 crates/agent-identity-conformance/src/system_fixture.rs create mode 100644 crates/agent-identity-conformance/src/system_probe.rs create mode 100644 crates/agent-identity-conformance/src/windows.rs create mode 100644 crates/agent-identity-conformance/tests/skip_exit.rs create mode 100644 crates/agent-identity-httpsig/Cargo.toml create mode 120000 crates/agent-identity-httpsig/INTENT.agent-identity.md create mode 100644 crates/agent-identity-httpsig/INTENT.md create mode 100644 crates/agent-identity-httpsig/clippy.toml create mode 100644 crates/agent-identity-httpsig/src/lib.rs create mode 100644 crates/agent-identity-httpsig/src/tests.rs create mode 100644 crates/agent-identity-mock/Cargo.toml create mode 120000 crates/agent-identity-mock/INTENT.agent-identity.md create mode 100644 crates/agent-identity-mock/INTENT.md create mode 100644 crates/agent-identity-mock/README.md create mode 100644 crates/agent-identity-mock/clippy.toml create mode 100644 crates/agent-identity-mock/src/api_admin.rs create mode 100644 crates/agent-identity-mock/src/api_agent.rs create mode 100644 crates/agent-identity-mock/src/api_mock.rs create mode 100644 crates/agent-identity-mock/src/app.rs create mode 100644 crates/agent-identity-mock/src/ca.rs create mode 100644 crates/agent-identity-mock/src/channel.rs create mode 100644 crates/agent-identity-mock/src/clock.rs create mode 100644 crates/agent-identity-mock/src/lib.rs create mode 100644 crates/agent-identity-mock/src/main.rs create mode 100644 crates/agent-identity-mock/src/name_eval.rs create mode 100644 crates/agent-identity-mock/src/oracle/csr.rs create mode 100644 crates/agent-identity-mock/src/oracle/mod.rs create mode 100644 crates/agent-identity-mock/src/oracle/proof.rs create mode 100644 crates/agent-identity-mock/src/oracle/sfv.rs create mode 100644 crates/agent-identity-mock/src/oracle/verify.rs create mode 100644 crates/agent-identity-mock/src/server.rs create mode 100644 crates/agent-identity-mock/src/state.rs create mode 100644 crates/agent-identity-mock/src/vector_tests.rs create mode 100644 crates/agent-identity/Cargo.toml create mode 120000 crates/agent-identity/INTENT.agent-identity.md create mode 100644 crates/agent-identity/INTENT.md create mode 100644 crates/agent-identity/clippy.toml create mode 100644 crates/agent-identity/src/client.rs create mode 100644 crates/agent-identity/src/csr.rs create mode 100644 crates/agent-identity/src/lib.rs create mode 100644 crates/agent-identity/src/macos_acl.rs create mode 100644 crates/agent-identity/src/metadata.rs create mode 100644 crates/agent-identity/src/pending.rs create mode 100644 crates/agent-identity/src/private_file.rs create mode 100644 crates/agent-identity/src/rejected.rs create mode 100644 crates/agent-identity/src/state.rs create mode 100644 crates/agent-identity/src/state_lock.rs create mode 100644 crates/agent-identity/src/token.rs create mode 100644 crates/agent-identity/src/windows.rs create mode 100644 crates/device-key/Cargo.toml create mode 100644 crates/device-key/INTENT.md create mode 100644 crates/device-key/clippy.toml create mode 100644 crates/device-key/src/file.rs create mode 100644 crates/device-key/src/keystore.rs create mode 100644 crates/device-key/src/lib.rs create mode 100644 crates/device-key/src/macos_acl.rs create mode 100644 crates/device-key/src/windows_acl.rs create mode 100644 crates/device-key/test-data/fixed-signature.json create mode 100644 devolutions-agent/src/identity.rs create mode 120000 devolutions-agent/src/identity/INTENT.agent-identity.md create mode 100644 devolutions-agent/src/identity/INTENT.md create mode 100644 docs/agent-identity/CONTRACT.md create mode 100644 docs/agent-identity/INTENT.DVLS.md create mode 100644 docs/agent-identity/INTENT.agent-identity.md create mode 100644 docs/agent-identity/test-vectors.json create mode 100644 docs/agent-identity/verify_vectors.py create mode 120000 package/AgentWindowsManaged/INTENT.agent-identity.md create mode 100644 package/AgentWindowsManaged/INTENT.md create mode 100644 testsuite/tests/agent_identity/mod.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a05966bd22..5489048ce5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1247,6 +1247,10 @@ 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-installer-event-log-tests: name: Agent installer Event Log lifecycle tests runs-on: windows-2022 diff --git a/.github/workflows/publish-libraries.yml b/.github/workflows/publish-libraries.yml index e7206968d3..2faa724fdf 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 b20da8dd1d..51036558ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,137 @@ dependencies = [ "const-oid 0.10.2", ] +[[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-client-proxy", + "httpdate", + "libc", + "p256 0.14.0-rc.14", + "reqwest", + "rustls-pemfile 2.2.0", + "serde", + "serde_json", + "sha2 0.11.0", + "signature 3.0.0", + "spki 0.8.0", + "sysinfo", + "time", + "tracing", + "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", + "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-types", + "rand 0.10.2", + "rcgen", + "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 +1567,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 +1634,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 +1881,31 @@ 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-identity", "agent-tunnel-proto", "anyhow", "async-trait", @@ -1752,6 +1916,7 @@ dependencies = [ "camino", "ceviche", "ctrlc", + "device-key", "devolutions-agent-shared", "devolutions-gateway-task", "devolutions-log", @@ -1796,6 +1961,7 @@ dependencies = [ "win-api-wrappers", "windows 0.61.3", "x509-parser", + "zeroize", ] [[package]] @@ -2130,6 +2296,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 +2469,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 +2490,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 +2503,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 +2535,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 +2560,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 +2680,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 +2961,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2802,13 +3020,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", ] @@ -3114,6 +3343,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 +3445,7 @@ dependencies = [ "tokio 1.52.3", "tokio-rustls", "tower-service", + "webpki-roots 1.0.8", ] [[package]] @@ -5177,30 +5432,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 +5490,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 +5685,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 +5756,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 +5776,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 +5871,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 +6093,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 +6799,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.8", ] [[package]] @@ -6519,6 +6818,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 +6874,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 +7275,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 +7514,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 +7611,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 +7769,7 @@ dependencies = [ "bytemuck", "byteorder", "cfg-if", - "crypto-bigint", + "crypto-bigint 0.7.5", "crypto-mac", "cryptoki", "curve25519-dalek", @@ -7439,8 +7782,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 +7791,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 +8371,7 @@ dependencies = [ "futures-core", "pin-project-lite 0.2.17", "tokio 1.52.3", + "tokio-util", ] [[package]] @@ -8183,6 +8527,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "webpki-roots 0.26.11", ] [[package]] @@ -9696,7 +10041,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 +10109,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/Cargo.toml b/Cargo.toml index 374d997fad..70cd7be055 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ members = [ "testsuite", "tools/generate-openapi", ] +# Intent-only directories until their crates land (Phase 2). +exclude = [ + "crates/agent-channel-client", +] default-members = [ "devolutions-agent", "devolutions-agent-updater", diff --git a/clippy.toml b/clippy.toml index 2ded6b0730..888f42c893 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/INTENT.agent-identity.md b/crates/agent-channel-client/INTENT.agent-identity.md new file mode 120000 index 0000000000..56d20dc7a1 --- /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 0000000000..054f792885 --- /dev/null +++ b/crates/agent-channel-client/INTENT.md @@ -0,0 +1,4 @@ +This crate is the agent's side of the channel defined in `agent-channel-proto`. + +- 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 to 5 min, unless the outcome is terminal. diff --git a/crates/agent-channel-proto/Cargo.toml b/crates/agent-channel-proto/Cargo.toml new file mode 100644 index 0000000000..493032613d --- /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 0000000000..56d20dc7a1 --- /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 0000000000..dc8f5313c3 --- /dev/null +++ b/crates/agent-channel-proto/INTENT.md @@ -0,0 +1,18 @@ +This crate defines the agent channel between the agent and an authority. + +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. + +| 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 | + +- 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 0000000000..5678d1cb84 --- /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 0000000000..982092e7e2 --- /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 0000000000..d99783219b --- /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 0000000000..b633959b2c --- /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 0000000000..6e0422fadd --- /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.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/Cargo.toml b/crates/agent-identity-conformance/Cargo.toml new file mode 100644 index 0000000000..84ed16f2cf --- /dev/null +++ b/crates/agent-identity-conformance/Cargo.toml @@ -0,0 +1,52 @@ +[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"] } +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 0000000000..56d20dc7a1 --- /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 0000000000..8a1c208788 --- /dev/null +++ b/crates/agent-identity-conformance/INTENT.md @@ -0,0 +1,84 @@ +This is the independent conformance suite for Agent Identity. +Its expectations derive only from these sources, never from the implementation under test: + +- [CONTRACT.md](../../docs/agent-identity/CONTRACT.md) and its test vectors; +- `agent-channel-proto`; +- the DVLS management interface, 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: base URL, admin credentials, agent binary path, target (mock or DVLS). +- 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 interface, and the mock also through its control surface. +- It depends on no agent implementation crate. +- What it observes, in order of preference: + 1. the management interface; + 2. the agent's files; + 3. logs, as a last resort. +- Tests that need mock fault injection are skipped when the target is DVLS. + 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. +- 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. + +## 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 interface:** + - pagination stays stable during concurrent enrollment; + - views, filters and bounds; + - every route requires the management 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 0000000000..bfec1160b6 --- /dev/null +++ b/crates/agent-identity-conformance/README.md @@ -0,0 +1,44 @@ +# 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 --admin-token ` + --authority-id --extra-trusted-root ` + --second-base-url --second-admin-token ` + --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. +On DVLS, supply `--disposable-dvls-target` only when rotation is safe, and provide `--unprivileged-admin-token` to check permission denials. +The disposable rotation checks require no other active devices on the published root; otherwise they report `SKIP` before rotating. +`--dvls-rotation-window-secs` defaults to 60; `--leaf-lifetime-secs` defaults to 90 days and must match the server setting. +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. +`--expect-channel` defaults to `true`; use `--expect-channel false` when the server intentionally omits `config.agent_channel_url`. +When the URL is absent, only gRPC-dependent checks become N/A; HTTP confirm checks and channelless renewal still run. + +`PASS` and `FAIL` report assertions, while `N/A` marks platform-inapplicable or explicitly unavailable-channel tests. +`SKIP` means a required fixture is missing; 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`. +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. +It rejects unrecorded `*.p8` files and PKCS#8 private keys anywhere under `/identity`, plus leftover temporary siblings of identity files; other unknown files produce warnings. +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 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. + +[Agent Identity V1 contract]: ../../docs/agent-identity/CONTRACT.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 0000000000..89563dea92 --- /dev/null +++ b/crates/agent-identity-conformance/src/agent.rs @@ -0,0 +1,5696 @@ +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 _}; +use http::Method; +#[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::protocol::has_certificate; +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"]; + +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(()) +} + +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, + } +} + +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, +} + +impl AgentCase { + async fn new(ctx: Context, renewal_after_secs: Option, run: bool) -> anyhow::Result { + let dir = tempfile::Builder::new() + .prefix("identity-agent-") + .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(|target| 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, + }; + 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(()) + } + + 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 { + 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, device: &Value) -> anyhow::Result<()> { + let metadata = device + .get("metadata") + .context("admin full view omitted agent metadata")?; + validate_agent_metadata(metadata, self.ctx.agent_version.as_deref()) + } + + async fn device(&mut self, target: &Target, device_id: &str) -> anyhow::Result { + let response = target.device(device_id).await?; + if response.status == 200 { + self.observe_agent_metadata(&response.body)?; + } + Ok(response) + } + + 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.devices_for(token, "&view=full").await?; + expect_status(&listing, 200)?; + if count(&listing.body, "totalCount")? == 1 { + let device = &listing.body["data"][0]; + let id = field(device, "id")?.to_owned(); + 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["device_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(device)?; + 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_device(&mut self, target: &Target, device_id: &str, what: &str, check: F) -> anyhow::Result + where + F: Fn(&Value) -> bool, + { + let start = Instant::now(); + loop { + let response = self.device(target, device_id).await?; + if response.status == 200 && check(&response.body) { + return Ok(response.body); + } + self.check_running()?; + ensure!(start.elapsed() < WAIT, "agent did not reach {what} within 20 seconds"); + tokio::time::sleep(POLL).await; + } + } + + async fn until_pending_deleted( + &mut self, + target: &Target, + pending: &str, + attempted: &Token, + expected_devices: u64, + expected_uses: u64, + ) -> anyhow::Result<()> { + let start = Instant::now(); + loop { + let listing = target.devices_for(attempted, "").await?; + expect_status(&listing, 200)?; + ensure!( + count(&listing.body, "totalCount")? == expected_devices, + "permanently invalid pending token changed its device count" + ); + ensure!( + count(&target.token_record(&attempted.id).await?.body, "usedCount")? == 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, + device_id: &str, + error: &str, + ) -> anyhow::Result { + let start = Instant::now(); + loop { + let _ = self.device(target, device_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, + device_id: &str, + what: &str, + check: F, + ) -> anyhow::Result + where + F: Fn(&Value) -> bool, + { + let start = Instant::now(); + loop { + let _ = self.device(target, device_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; + } + } + + 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() + ); + ensure!( + !is_temporary_sibling(&path, &documented, &self.ctx.key_name_prefix), + "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 { + 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) + } + + fn check_key_protection_inner( + &self, + _authority_id: &str, + state: &Value, + require_current: bool, + ) -> anyhow::Result<()> { + if require_current { + ensure!( + state["keys"]["current"]["key_name"].is_string(), + "settled identity has no current key name" + ); + } + for slot in ["current", "pending", "previous"] { + let Some(name) = state["keys"][slot]["key_name"].as_str() else { + continue; + }; + validate_key_name(&self.ctx.key_name_prefix, name)?; + let expected_spki = state["keys"][slot]["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()?; + // A record can briefly name a key that has not been created yet or was already deleted (C15). + #[cfg(windows)] + if self.ctx.key_backend == KeyBackend::WindowsSoftwareProvider { + let exists = crate::windows::machine_key_exists(name)?; + ensure!( + exists || !require_current || slot != "current", + "settled current machine key is missing" + ); + if exists { + 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" + ); + } + } + } + #[cfg(any(unix, windows))] + if self.ctx.key_backend == KeyBackend::File { + let path = self.key_file_path(name); + ensure!( + path.exists() || !require_current || slot != "current", + "settled current file-backed key is missing" + ); + if path.exists() { + #[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" + ); + } + } + } + #[cfg(not(any(unix, windows)))] + let _ = expected_spki; + } + Ok(()) + } + + 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:#}"); + } + } + } +} + +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>, +{ + let mut case = AgentCase::new(ctx, renewal_after_secs, run).await?; + let result = work(&mut case).await; + let audit = case.finish().await; + 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:#}"), + } +} + +async fn new_token(case: &AgentCase, uses: u32) -> anyhow::Result { + case.ctx + .target + .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(); + 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.devices_for(token, "").await?; + expect_status(&listing, 200)?; + let record = target.token_record(&token.id).await?; + expect_status(&record, 200)?; + if count(&listing.body, "totalCount")? == 1 + && count(&record.body, "usedCount")? == 1 + && let Some(progress) = case.in_progress()? + { + ensure!(attempts > before, "enrollment committed without a recorded request"); + 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"); + ensure!( + target.control("faults", &json!({})).await?.body["drop_next_response"].is_null(), + "the dropped enroll response fault was not consumed" + ); + let id = field(&listing.body["data"][0], "id")?.to_owned(); + return Ok((id, key)); + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not commit a dropped-response enrollment and persist its key within 20 seconds" + ); + tokio::time::sleep(POLL).await; + } +} + +async fn assert_terminal_stops( + case: &mut AgentCase, + target: &Target, + authority: &str, + device_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_device = if let Some(id) = device_id { + Some(case.device(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) = device_id { + let now = case.device(target, id).await?; + ensure!( + now.body["connected"] == false + && before_device + .as_ref() + .is_some_and(|before| now.body["certificates"] == before.body["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, device: &Value) -> anyhow::Result<()> { + ensure!(state["version"] == 1, "stored identity has wrong version"); + ensure!( + state["device_id"] == field(device, "id")?, + "stored identity has wrong device 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": 1 }); + if case.ctx.channel_available { + expected["agent_channel_url"] = json!(case.ctx.target.base_url); + } + ensure!( + state["config"] == expected, + "agent did not store the initial mock config and revision 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!( + device["metadata"]["hostname"] == 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: &Value) -> anyhow::Result<&str> { + record["certificates"] + .as_array() + .context("no admin certificates")? + .iter() + .find(|cert| cert["status"] == "current") + .and_then(|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") +} + +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(()) +} + +async fn assert_new_auth_precedes_old_close( + target: &Target, + device_id: &str, + old_thumb: &str, + new_thumb: &str, +) -> anyhow::Result<()> { + let started = Instant::now(); + loop { + let events = target.events(device_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<()> { + 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 admin = case.device(&target, &id).await?; + expect_status(&admin, 200)?; + ensure!(admin.body["id"] == id, "enrolled device absent from admin API"); + assert_stored_identity(case, &token, &state, &admin.body)?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_bad_pending_files_do_not_reject_valid_tokens(ctx: Context) -> anyhow::Result<()> { + 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 listing = target.devices_for(token, "").await?; + expect_status(&listing, 200)?; + let devices = count(&listing.body, "totalCount")?; + let uses = count(&target.token_record(&token.id).await?.body, "usedCount")?; + ensure!( + devices <= 1 && uses <= 1, + "correct pending token {name} created more than one device" + ); + all_enrolled &= devices == 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<()> { + #[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<()> { + 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")?; + 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<()> { + 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 device"); + ensure!( + count(&target.requests(Some(&token)).await?, "enroll")? > before_restart, + "agent did not retry enrollment after restart" + ); + ensure!( + count(&target.token_record(&token.id).await?.body, "usedCount")? == 1, + "retry consumed a second token use" + ); + ensure!( + key_name(&state, "current")? == key, + "agent did not reuse the in-progress key" + ); + ensure!( + !case.in_progress_path().exists(), + "in-progress enrollment record survived success" + ); + let admin = case.device(&target, &id).await?; + expect_status(&admin, 200)?; + assert_stored_identity(case, &token, &state, &admin.body)?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_two_pending_tokens_same_authority_last_wins(ctx: Context) -> anyhow::Result<()> { + with_agent(ctx, None, true, |case| { + Box::pin(async move { + 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": 15 + } + })) + .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?; + ensure!( + count(&target.devices_for(&original_token, "").await?.body, "totalCount")? == 1 + && count(&target.devices_for(&replacement, "").await?.body, "totalCount")? == 1 + && count(&target.token_record(&original_token.id).await?.body, "usedCount")? == 1 + && count(&target.token_record(&replacement.id).await?.body, "usedCount")? == 1 + && !case.pending_path_for(&original_token.text).exists() + && !case.pending_path_for(&replacement.text).exists() + && !case.in_progress_path_for(&original_token.text).exists() + && !case.in_progress_path_for(&replacement.text).exists(), + "independent pending tokens were not both consumed and cleaned up" + ); + let admin = case.device(&target, &original_id).await?; + expect_status(&admin, 200)?; + assert_stored_identity(case, &original_token, &state, &admin.body)?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_two_pending_tokens_different_authorities_progress_independently( + ctx: Context, +) -> anyhow::Result<()> { + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let first = case.ctx.target.clone(); + let second = case.ctx.second.clone().context("second authority required")?; + let first_token = first.create_token(1, Duration::from_secs(3600), None).await?; + let second_token = second.create_token(1, Duration::from_secs(3600), None).await?; + first + .faults(&json!({ + "fail_next_response": { + "endpoint": "enroll", + "status": 503, + "retry_after_secs": 15 + } + })) + .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?; + let first_authority = field(&first_state, "authority_id")?; + let second_authority = field(&second_state, "authority_id")?; + ensure!( + first_authority != second_authority + && first_state["device_id"] == first_id + && second_state["device_id"] == second_id + && case.state(first_authority)?["device_id"] == first_id + && case.state(second_authority)?["device_id"] == second_id + && !case.pending_path_for(&first_token.text).exists() + && !case.pending_path_for(&second_token.text).exists() + && !case.in_progress_path_for(&first_token.text).exists() + && !case.in_progress_path_for(&second_token.text).exists() + && count(&first.token_record(&first_token.id).await?.body, "usedCount")? == 1 + && count(&second.token_record(&second_token.id).await?.body, "usedCount")? == 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<()> { + 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.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")?; + ensure!( + count(&requests, "enroll_device_revoked")? <= 1, + "agent retried enrollment again after receiving device_revoked" + ); + if count(&requests, "enroll_device_revoked")? == 1 + && !case.pending_path().exists() + && !case.in_progress_path().exists() + && !key_exists(case, &key)? + && !case.has_stored_identity()? + { + break attempts; + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not remove the revoked enrollment, in-progress record and key within 20 seconds" + ); + 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 device_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!( + count(&target.token_record(&token.id).await?.body, "usedCount")? == 1 + && count(&target.devices_for(&token, "").await?.body, "totalCount")? == 1, + "revoked enrollment created another device or consumed another use" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_pending_file_deleted_on_permanent_error(ctx: Context) -> anyhow::Result<()> { + 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, devices, 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.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.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, devices, 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<()> { + 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; + let devices = target.devices_for(&token, "").await?; + ensure!( + count(&devices.body, "totalCount")? == 0, + "unreachable enrollment created a device" + ); + 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!( + count(&target.devices_for(&token, "").await?.body, "totalCount")? == 0 + && count(&target.token_record(&token.id).await?.body, "usedCount")? == 0, + "transient HTTP error created a device 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!( + count(&target.token_record(&token.id).await?.body, "usedCount")? == 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(()) +} + +#[cfg(unix)] +fn is_error_level_log_line(line: &str) -> bool { + 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<()> { + #[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.token_record(&token.id).await?; + expect_status(&token_record, 200)?; + let used_before = count(&token_record.body, "usedCount")?; + 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" + ); + } + let devices = target.devices_for(&token, "").await?; + expect_status(&devices, 200)?; + ensure!( + count(&devices.body, "totalCount")? == 0, + "unavailable backend created a device" + ); + let record = target.token_record(&token.id).await?; + expect_status(&record, 200)?; + ensure!( + count(&record.body, "usedCount")? == 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<()> { + 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_device(&target, &id, "working channel for renewal faults", |device| { + device["connected"] == true + }) + .await?; + for status in [302, 429, 503] { + let before = target.requests(None).await?; + let device_before = case.device(&target, &id).await?.body; + let stored_before = case.state(&authority)?; + let old_thumb = current_thumbprint(&device_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?; + expect_status( + &target + .admin(Method::POST, &format!("/devices/{id}/request-renewal"), None) + .await?, + 202, + )?; + wait_for_injected_attempt(case, &target, "renew", attempts).await?; + let failed = case.device(&target, &id).await?.body; + let stored = case.state(&authority)?; + ensure!( + failed["certificates"] == device_before["certificates"] + && failed["metadata"] == device_before["metadata"] + && failed["lastSeenAt"] == device_before["lastSeenAt"] + && stored["config"] == stored_before["config"] + && key_name(&stored, "current")? == old_key + && stored["keys"]["pending"]["certificate_chain"].is_null() + && stored.get("rejected").is_none(), + "injected renew {status} changed server or committed identity state" + ); + if status == 429 { + tokio::time::sleep(Duration::from_millis(2600)).await; + ensure!( + count(&target.requests(None).await?, "renew")? == attempts + 1, + "renew retry ignored Retry-After: 3 seconds" + ); + } + let renewed = case + .until_device(&target, &id, "renew retry confirmed", |device| { + current_thumbprint(device).is_ok_and(|thumb| thumb != old_thumb) && device["connected"] == true + }) + .await?; + case.until_state(&target, &authority, &id, "renew retry persisted", |state| { + key_name(state, "current").is_ok_and(|name| name != old_key) + && state["keys"].get("pending").is_none() + && state.get("rejected").is_none() + }) + .await?; + ensure!( + count(&target.requests(None).await?, "renew")? >= attempts + 2 + && count(&target.requests(None).await?, "redirect_hits")? == count(&before, "redirect_hits")? + && renewed["renewalRequested"] == false, + "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<()> { + 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 device_before = case.device(&target, &id).await?.body; + 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 = case.device(&target, &id).await?.body; + let stored = case.state(&authority)?; + ensure!( + failed["certificates"] == device_before["certificates"] + && failed["metadata"] == device_before["metadata"] + && failed["lastSeenAt"] == device_before["lastSeenAt"] + && 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 { + tokio::time::sleep(Duration::from_millis(2600)).await; + ensure!( + count(&target.requests(None).await?, "check_in")? == attempts + 1, + "check-in retry ignored Retry-After: 3 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.device(&target, &id).await?.body["metadata"]["hostname"] == 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<()> { + 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<()> { + 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 = count(&target.token_record(&token.id).await?.body, "usedCount")?; + 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 listing = target.devices_for(&token, "").await?; + ensure!( + count(&listing.body, "totalCount")? == 1, + "duplicate token enrolled another device" + ); + 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")?)?["device_id"] == id, + "same token replaced identity" + ); + ensure!( + count(&target.token_record(&token.id).await?.body, "usedCount")? == uses, + "same token used again" + ); + let final_devices = target.devices_for(&token, "").await?; + expect_status(&final_devices, 200)?; + ensure!( + count(&final_devices.body, "totalCount")? == 1 && final_devices.body["data"][0]["id"] == id, + "same token created a new device" + ); + 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_devices, 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_devices, 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_devices, expected_uses).await + }) + }) + .await +} + +async fn assert_same_token_replay_dropped( + case: &mut AgentCase, + target: &Target, + token: &Token, + pending: &str, + expected_devices: 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_devices, 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_devices, 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_devices: u64, + expected_uses: u64, + attempts: Option, +) -> anyhow::Result<()> { + let listing = target.devices_for(token, "").await?; + expect_status(&listing, 200)?; + ensure!( + count(&listing.body, "totalCount")? == expected_devices, + "rewritten permanently failed token created another device" + ); + let record = target.token_record(&token.id).await?; + expect_status(&record, 200)?; + ensure!( + count(&record.body, "usedCount")? == 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<()> { + 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<()> { + 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<()> { + same_token_after_permanent_outcome(ctx, PermanentTokenOutcome::Malformed).await +} + +pub(crate) async fn a_same_token_after_device_revoked_no_enrollment(ctx: Context) -> 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 (id, key) = dropped_enrollment(case, &token).await?; + target.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_device_revoked")? == 1 + && !key_exists(case, &key)? + && target.device(&id).await?.body["status"] == "revoked", + "lost response was not followed by a permanent device_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<()> { + 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_device(&target, &id, "connected", |device| device["connected"] == true) + .await?; + target.revoke(&id).await?; + case.until_rejected(&target, &authority, &id, "device_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.devices_for(&token, "").await?; + case.check_running()?; + ensure!(start.elapsed() < WAIT, "same token was not discarded after rejection"); + tokio::time::sleep(POLL).await; + } + ensure!( + count(&target.token_record(&token.id).await?.body, "usedCount")? == 1, + "rejected identity consumed another token use" + ); + let final_devices = target.devices_for(&token, "").await?; + expect_status(&final_devices, 200)?; + ensure!( + count(&final_devices.body, "totalCount")? == 1 && final_devices.body["data"][0]["id"] == id, + "same token created a new device after rejection" + ); + ensure!( + case.state(&authority)?["rejected"]["code"] == "device_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<()> { + 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 device"); + 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)?["device_id"] == next_id, + "stored identity not replaced" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_rejected_identity_replaced(ctx: Context) -> anyhow::Result<()> { + 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_device(&target, &old_id, "old connection", |record| record["connected"] == true) + .await?; + target.revoke(&old_id).await?; + case.until_rejected(&target, &authority, &old_id, "device_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 device 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_device(&target, &new_id, "replacement connection", |record| { + record["connected"] == true + }) + .await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_cli_identity_enroll_writes_pending_file(ctx: Context) -> anyhow::Result<()> { + 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<()> { + 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<()> { + 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, 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.device(&target, &id).await?; + let old_thumb = current_thumbprint(&before.body)?.to_owned(); + let name = field(&before.body, "friendlyName")?.to_owned(); + let start = Instant::now(); + let renewed = loop { + let record = case.device(&target, &id).await?; + expect_status(&record, 200)?; + if current_thumbprint(&record.body).is_ok_and(|thumb| thumb != old_thumb) { + break record.body; + } + 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.device(&target, &id).await?.body) + .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["friendlyName"] == name, "friendly name changed on renewal"); + ensure!( + renewed["certificates"].as_array().is_some_and(|items| items + .iter() + .any(|cert| cert["thumbprint"] == old_thumb && cert["status"] == "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_device(&target, &id, "offline channel after restart", |device| { + device["connected"] == false + }) + .await?; + case.start().await?; + let resumed = case + .until_device(&target, &id, "reconnected with persisted key", |device| { + device["connected"] == true && current_thumbprint(device).is_ok_and(|thumb| thumb == new_thumb) + }) + .await?; + ensure!( + resumed["certificate"]["issuer"] == renewed["certificate"]["issuer"], + "agent changed issuer while loading the persisted key" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_renewal_lost_response_retried(ctx: Context) -> 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?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + let before = case.device(&target, &id).await?; + let old_thumb = current_thumbprint(&before.body)?.to_owned(); + case.until_device(&target, &id, "channel before requested renewal", |device| { + device["connected"] == true + }) + .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?; + expect_status( + &target + .admin(Method::POST, &format!("/devices/{id}/request-renewal"), None) + .await?, + 202, + )?; + let started = Instant::now(); + let (pending_thumb, pending_key) = loop { + let record = case.device(&target, &id).await?; + expect_status(&record, 200)?; + 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.body["certificates"] + .as_array() + .context("missing certificates")? + .iter() + .find(|cert| cert["status"] == "pending"); + if renew_count > attempts + && let Some(pending) = pending + && let Ok(key) = key_name(&state, "pending") + { + break (field(pending, "thumbprint")?.to_owned(), key.to_owned()); + } + case.check_running()?; + ensure!( + started.elapsed() < WAIT, + "agent did not persist the first pending renewal" + ); + tokio::time::sleep(POLL).await; + }; + ensure!( + target.control("faults", &json!({})).await?.body["drop_next_response"].is_null(), + "drop-next-renew fault was not consumed" + ); + 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_device(&target, &id, "retried renewal after restart", |device| { + current_thumbprint(device).is_ok_and(|thumb| thumb == pending_thumb) + && device["renewalRequested"] == false + }) + .await?; + ensure!( + renewed["certificates"].as_array().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.state(&authority)?; + ensure!( + key_name(&state, "current")? == pending_key, + "agent did not reuse its persisted CSR key after restart" + ); + case.check_key_protection(&authority, &state)?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_renewal_lost_confirm_response_retried(ctx: Context) -> 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?; + 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_device(&target, &id, "old-key channel", |record| record["connected"] == true) + .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?; + expect_status( + &target + .admin(Method::POST, &format!("/devices/{id}/request-renewal"), None) + .await?, + 202, + )?; + let started = Instant::now(); + let (new_thumb, pending_key) = loop { + let record = case.device(&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.body) + && 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_device(&target, &id, "new channel after confirm retry", |record| { + record["connected"] == true && 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<()> { + 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.device(&target, &id).await?.body; + 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?; + expect_status( + &target + .admin(Method::POST, &format!("/devices/{id}/request-renewal"), None) + .await?, + 202, + )?; + 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.device(&target, &id).await?.body["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.device(&target, &id).await?.body["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_device(&target, &id, "channelless confirm resolved", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != old_thumb) && record["renewalRequested"] == false + }) + .await?; + case.until_state(&target, &authority, &id, "confirmed key stored", |state| { + state["keys"].get("pending").is_none() && state.get("rejected").is_none() + }) + .await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_confirm_expired_pending_renews_with_fresh_key(ctx: Context) -> anyhow::Result<()> { + 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.device(&target, &id).await?; + let stored = case.state(&authority)?; + let requests = target.requests(None).await?; + if count(&requests, "confirm")? > count(&before, "confirm")? + && count(&requests, "confirm_retry_503")? >= 1 + && let Ok(thumb) = current_thumbprint(&record.body) + && thumb != old_thumb + && let Ok(pending_key) = key_name(&stored, "pending") + { + 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() + && has_certificate(&case.device(&target, &id).await?.body, &first_thumb, "current"), + "dropped confirm did not promote the pending certificate server-side" + ); + let expiry = time::OffsetDateTime::parse( + field(&case.device(&target, &id).await?.body["certificate"], "notAfter")?, + &Rfc3339, + )? + .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, + )?; + case.start().await?; + let confirmed_device = case + .until_device(&target, &id, "fresh-key renewal after rejected confirm", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != old_thumb && thumb != first_thumb) + && has_certificate(record, &first_thumb, "retired") + && record["renewalRequested"] == false + }) + .await?; + let new_thumb = current_thumbprint(&confirmed_device)?.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?; + let requests = target.requests(None).await?; + ensure!( + count(&requests, "renew")? >= count(&before, "renew")? + 2 + && count(&requests, "confirm")? >= count(&before, "confirm")? + 4, + "agent did not renew with the expired pending key and 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.device(&target, &id).await?; + let stored = case.state(&authority)?; + let requests = target.requests(None).await?; + if count(&requests, "confirm_retry_503")? >= 1 + && let Some(pending) = record.body["certificates"] + .as_array() + .and_then(|certs| certs.iter().find(|cert| cert["status"] == "pending")) + && let Ok(key) = key_name(&stored, "pending") + { + ensure!( + current_thumbprint(&record.body)? == old_thumb, + "injected confirm failure promoted the pending certificate" + ); + break (field(pending, "thumbprint")?.to_owned(), 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.device(&target, &id).await?; + let certificate = pending.body["certificates"] + .as_array() + .context("device certificates missing")? + .iter() + .find(|cert| cert["thumbprint"] == first_thumb) + .context("pending certificate missing")?; + let expiry = time::OffsetDateTime::parse(field(certificate, "notAfter")?, &Rfc3339)?.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?; + let renew_before_restart = count(&target.requests(None).await?, "renew")?; + target + .faults(&json!({ + "fail_next_response": { + "endpoint": "renew", + "status": 401, + "error": "device_unknown" + } + })) + .await?; + expect_status( + &target + .control("retry-barrier", &json!({ "endpoint": "confirm", "pause": false })) + .await?, + 200, + )?; + case.start().await?; + let renewed = case + .until_device( + &target, + &id, + "fallback renewal after pending-key device_unknown", + |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != old_thumb && thumb != first_thumb) + && has_certificate(record, &first_thumb, "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.get("rejected").is_none() + }) + .await?; + ensure!( + crate::signer::thumbprint( + settled["keys"]["current"]["certificate_chain"][0] + .as_str() + .context("fallback certificate missing")? + )? == 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?; + let requests = target.requests(None).await?; + let keyids = requests["renew_attempt_keyids"] + .as_array() + .context("mock renew signing-key trace missing")?; + let index = usize::try_from(renew_before_restart)?; + ensure!( + keyids.get(index) == Some(&json!(first_thumb)) + && keyids.get(index + 1) == Some(&json!(old_thumb)) + && count(&requests, "renew")? >= count(&before, "renew")? + 3 + && count(&requests, "confirm")? >= count(&before, "confirm")? + 4 + && target + .events(&id) + .await? + .iter() + .any(|event| { event["type"] == "renew_received" && event["cert_thumbprint"] == old_thumb }), + "device_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<()> { + 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.device(&target, &id).await?.body; + let stored = case.state(&authority)?; + if count(&target.requests(None).await?, "confirm_retry_503")? > 0 + && let Some(pending) = record["certificates"] + .as_array() + .and_then(|certs| certs.iter().find(|cert| cert["status"] == "pending")) + && stored["keys"]["pending"]["key_name"].is_string() + { + break (field(pending, "thumbprint")?.to_owned(), 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.device(&target, &id).await?.body["certificates"].clone(); + let not_before = + time::OffsetDateTime::parse(field(&pending_certificate, "notBefore")?, &Rfc3339)?.unix_timestamp(); + let not_after = + time::OffsetDateTime::parse(field(&pending_certificate, "notAfter")?, &Rfc3339)?.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.device(&target, &id).await?.body["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<()> { + 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 original = case + .until_device(&target, &id, "old certificate channel", |record| { + record["connected"] == true + }) + .await?; + let old_thumb = current_thumbprint(&original)?.to_owned(); + let expiry = + time::OffsetDateTime::parse(field(&original["certificate"], "notAfter")?, &Rfc3339)?.unix_timestamp(); + let before = target.requests(None).await?; + case.stop().await?; + case.until_device(&target, &id, "offline expired channel", |record| { + record["connected"] == false + }) + .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_device(&target, &id, "within-grace renewal and reconnect", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != old_thumb) && record["connected"] == true + }) + .await?; + ensure!( + renewed["certificate"]["issuer"] == original["certificate"]["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" + ); + 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_device(&target, &id, "old certificate channel", |record| { + record["connected"] == true + }) + .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<()> { + 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.device(&target, &id).await?; + expect_status(&enrolled, 200)?; + ensure!( + enrolled.body["metadata"].is_object(), + "enrollment metadata was not an object" + ); + ensure!( + enrolled.body["metadata"]["hostname"] == case.metadata_hostname, + "enrollment ignored the metadata override" + ); + let connected = if case.ctx.mock() { + ensure!( + enrolled.body["connected"] == false, + "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.device(&target, &id).await?; + ensure!( + held.body["connected"] == false && held.body["metadata"] == enrolled.body["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_device(&target, &id, "authenticated Hello", |device| { + device["connected"] == true + }) + .await?; + ensure!( + count(&target.requests(None).await?, "authenticated_connects")? > 0, + "released Hello did not authenticate" + ); + connected + } else { + case.until_device(&target, &id, "connected channel", |device| device["connected"] == true) + .await? + }; + ensure!( + connected["lastSeenAt"].as_str().is_some(), + "channel did not update lastSeenAt" + ); + ensure!(connected["metadata"].is_object(), "Hello metadata not stored"); + ensure!( + connected["metadata"]["hostname"] == case.metadata_hostname, + "initial Hello ignored the metadata override" + ); + for key in REQUIRED_METADATA_KEYS { + ensure!( + !field(&connected["metadata"], key)?.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!( + field(&connected, "lastSeenAt")? != field(&enrolled.body, "lastSeenAt")?, + "authenticated Hello did not refresh lastSeenAt" + ); + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_make_before_break_on_renewal(ctx: Context) -> anyhow::Result<()> { + 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, _) = case.enrolled(&target, &token).await?; + let connected = case + .until_device(&target, &id, "first connection", |device| device["connected"] == true) + .await?; + let old_thumb = current_thumbprint(&connected)?.to_owned(); + let start = Instant::now(); + let new_thumb = loop { + let record = case.device(&target, &id).await?; + expect_status(&record, 200)?; + ensure!( + record.body["connected"] == true, + "agent disconnected during make-before-break" + ); + if let Ok(thumb) = current_thumbprint(&record.body) + && thumb != old_thumb + { + ensure!( + record.body["certificates"].as_array().is_some_and(|certs| certs + .iter() + .any(|cert| cert["thumbprint"] == old_thumb && cert["status"] == "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?; + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_old_key_deleted_after_confirm_without_channel(ctx: Context) -> anyhow::Result<()> { + 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 first = case + .until_device(&target, &id, "old-root channel", |record| record["connected"] == true) + .await?; + ensure!( + first["metadata"]["hostname"] == 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 > 1) + && state["config"].get("agent_channel_url").is_none() + }) + .await?; + case.until_device(&target, &id, "closed old channel before offline renewal", |record| { + record["connected"] == false + }) + .await?; + expect_status( + &target + .admin(Method::POST, &format!("/devices/{id}/request-renewal"), None) + .await?, + 202, + )?; + let start = Instant::now(); + let confirmed = loop { + let record = case.device(&target, &id).await?; + expect_status(&record, 200)?; + if current_thumbprint(&record.body).is_ok_and(|thumb| thumb != old_thumb) { + break record.body; + } + if !key_exists(case, &old_key)? { + let refreshed = case.device(&target, &id).await?; + ensure!( + current_thumbprint(&refreshed.body).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"]["hostname"] == renewed_hostname + && confirmed["renewalRequested"] == false + && confirmed["certificates"].as_array().is_some_and(|certs| { + certs + .iter() + .any(|cert| cert["thumbprint"] == old_thumb && cert["status"] == "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<()> { + 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?; + let record = case + .until_device(&target, &id, "connected channel", |record| record["connected"] == true) + .await?; + let original = current_thumbprint(&record)?.to_owned(); + let acknowledgments = if case.ctx.mock() { + Some(count(&target.requests(None).await?, "correlated_acks")?) + } else { + None + }; + expect_status( + &target + .admin(Method::POST, &format!("/devices/{id}/request-renewal"), None) + .await?, + 202, + )?; + let renewed = case + .until_device(&target, &id, "requested renewal", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != original) + && record["renewalRequested"] == false + }) + .await?; + ensure!(renewed["connected"] == true, "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; + } + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_request_renewal_while_offline(ctx: Context) -> 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?; + case.write_pending(&token.text)?; + let (id, _) = case.enrolled(&target, &token).await?; + let first = case + .until_device(&target, &id, "initial connection", |record| record["connected"] == true) + .await?; + let original = current_thumbprint(&first)?.to_owned(); + case.stop().await?; + let _ = case + .until_device(&target, &id, "offline channel", |record| record["connected"] == false) + .await?; + expect_status( + &target + .admin(Method::POST, &format!("/devices/{id}/request-renewal"), None) + .await?, + 202, + )?; + ensure!( + case.device(&target, &id).await?.body["renewalRequested"] == true, + "offline renewal flag missing" + ); + case.start().await?; + let renewed = case + .until_device(&target, &id, "renewal after reconnect", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != original) + && record["renewalRequested"] == false + }) + .await?; + ensure!( + renewed["connected"] == true, + "agent did not reconnect after offline request" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_request_renewal_without_channel(ctx: Context) -> anyhow::Result<()> { + 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.device(&target, &id).await?; + ensure!( + state["config"].get("agent_channel_url").is_none() && initial.body["connected"] == false, + "no-channel renewal fixture unexpectedly offers a channel" + ); + let original = current_thumbprint(&initial.body)?.to_owned(); + let before = if case.ctx.mock() { + Some(target.requests(None).await?) + } else { + None + }; + expect_status( + &target + .admin(Method::POST, &format!("/devices/{id}/request-renewal"), None) + .await?, + 202, + )?; + ensure!( + case.device(&target, &id).await?.body["renewalRequested"] == true, + "channelless renewal flag was not persisted" + ); + case.until_device(&target, &id, "check-in-triggered channelless renewal", |record| { + current_thumbprint(record).is_ok_and(|thumb| thumb != original) + && record["renewalRequested"] == false + && record["connected"] == false + }) + .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)?; + } + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_reconnect_make_before_break(ctx: Context) -> 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?; + case.write_pending(&token.text)?; + let (id, _) = case.enrolled(&target, &token).await?; + case.until_device(&target, &id, "first channel", |record| record["connected"] == true) + .await?; + ensure!( + case.device(&target, &id).await?.body["metadata"]["hostname"] == 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!({ "device_id": id })).await?, 202)?; + let started = Instant::now(); + loop { + let current = case.device(&target, &id).await?; + ensure!( + current.body["connected"] == true, + "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.device(&target, &id).await?.body["metadata"]["hostname"] == "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<()> { + 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_device(&target, &id, "initial connection", |record| record["connected"] == true) + .await?; + ensure!( + state.get("rejected").is_none(), + "identity was rejected before revocation" + ); + let before = time::OffsetDateTime::now_utc(); + target.revoke(&id).await?; + let _ = case.until_rejected(&target, &authority, &id, "device_revoked").await?; + let revoked = case.device(&target, &id).await?; + ensure!(revoked.body["connected"] == false, "revoked agent remains connected"); + assert_terminal_stops(case, &target, &authority, Some(&id), "device_revoked", before).await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_device_unknown_recorded(ctx: Context) -> 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?; + case.write_pending(&token.text)?; + let (id, state) = case.enrolled(&target, &token).await?; + let authority = field(&state, "authority_id")?.to_owned(); + case.until_device(&target, &id, "connection before reset", |record| { + record["connected"] == true + }) + .await?; + ensure!(state.get("rejected").is_none(), "identity was rejected before reset"); + let before = time::OffsetDateTime::now_utc(); + target.reset().await?; + expect_status(&case.device(&target, &id).await?, 404)?; + let rejected = case.until_rejected(&target, &authority, &id, "device_unknown").await?; + ensure!(rejected["rejected"]["at"].as_str().is_some(), "rejection time missing"); + assert_terminal_stops(case, &target, &authority, None, "device_unknown", before).await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_signed_rejection_without_live_stream(ctx: Context) -> anyhow::Result<()> { + 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.device(&target, &id).await?.body["connected"] == false, + "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.revoke(&id).await?; + case.until_rejected(&target, &authority, &id, "device_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 device_revoked from check-in before any renewal" + ); + assert_terminal_stops(case, &target, &authority, Some(&id), "device_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!( + case.device(&target, &id).await?.body["connected"] == false, + "channel-open rejection fixture authenticated prematurely" + ); + let since = time::OffsetDateTime::now_utc(); + target.reset().await?; + let rejected = case.until_rejected(&target, &authority, &id, "device_unknown").await?; + ensure!( + rejected["rejected"]["code"] == "device_unknown" + && count(&target.requests(None).await?, "connect")? > 0, + "agent did not record device_unknown from a channel-open rejection" + ); + assert_terminal_stops(case, &target, &authority, None, "device_unknown", since).await?; + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_no_channel_when_absent(ctx: Context) -> anyhow::Result<()> { + 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.device(&target, &id).await?.body["metadata"]["hostname"] == 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.device(&target, &id).await?.body["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.device(&target, &id).await?.body["connected"] == false, + "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!({ "channel_available": true, "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.device(&target, &id).await?.body["connected"] == false, + "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<()> { + 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<()> { + 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<()> { + 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_device(&target, &id, "initial channel", |record| record["connected"] == true) + .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_device(&target, &id, "closed channel after config update", |record| { + record["connected"] == false + }) + .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.device(&target, &id).await?.body["connected"] == false + && 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<()> { + 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(); + ensure!( + state["config"].get("agent_channel_url").is_none() + && case.device(&target, &id).await?.body["connected"] == false, + "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 > 1) + && 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_device(&target, &id, "channel opened from check-in config", |record| { + record["connected"] == true + }) + .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<()> { + 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 && count(&state["config"], "revision")? == 1, + "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.device(&target, &id).await?.body["connected"] == false + && count(&case.state(&authority)?["config"], "revision")? == 1, + "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 > 1) + && 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.device(&target, &id).await?.body["connected"] == false, + "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<()> { + 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_device(&target, &id, "initial channel", |record| record["connected"] == true) + .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!({ "device_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!({ "device_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.device(&target, &id).await?.body["connected"] == true, + "agent checked in periodically despite a working channel" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_multi_authority(ctx: Context) -> anyhow::Result<()> { + with_agent(ctx, None, true, |case| { + Box::pin(async move { + let first = case.ctx.target.clone(); + let second = case.ctx.second.clone().context("second authority required")?; + let first_token = first.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_device(&first, &first_id, "first connection", |record| { + record["connected"] == true + }) + .await?; + let second_token = second.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_device(&second, &second_id, "second connection", |record| { + record["connected"] == true + }) + .await?; + ensure!( + case.device(&first, &first_id).await?.body["connected"] == true, + "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_device(&first, &first_id, "first authority offline", |record| { + record["connected"] == false + }) + .await?; + case.until_device(&second, &second_id, "second authority offline", |record| { + record["connected"] == false + }) + .await?; + case.start().await?; + case.until_device(&first, &first_id, "first authority reconnected", |record| { + record["connected"] == true + }) + .await?; + case.until_device(&second, &second_id, "second authority reconnected", |record| { + record["connected"] == true + }) + .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<()> { + #[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_device(&target, &id, "signed channel Hello", |device| { + device["connected"] == true + }) + .await?; + } else { + case.set_metadata_hostname("acl-signing-probe")?; + case.until_device(&target, &id, "signed check-in", |device| { + device["metadata"]["hostname"] == "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_device(&target, &id, "SYSTEM-only key signed channel Hello", |device| { + device["connected"] == true + }) + .await?; + } else { + case.set_metadata_hostname("system-acl-signing-probe")?; + case.until_device(&target, &id, "SYSTEM-only key signed check-in", |device| { + device["metadata"]["hostname"] == "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<()> { + #[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<()> { + 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_device(&target, &id, "old-root connection", |record| { + record["connected"] == true + }) + .await?; + let old_root = field(&old["certificate"], "issuer")?.to_owned(); + let started = if case.ctx.mock() { + target.rotate(None).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)?)) + .format(&Rfc3339)?; + target.rotate(Some(&deadline)).await? + }; + expect_status(&started, 202)?; + let initial = count(&started.body, "activeDevicesOnOldRoot")?; + ensure!(initial >= 1, "the agent's old-root device was not counted"); + let new = case + .until_device(&target, &id, "connected new-root certificate", |record| { + record["certificate"]["issuer"] != old_root + && record["connected"] == true + && record["certificates"] + .as_array() + .is_some_and(|items| items.iter().any(|cert| cert["status"] == "retired")) + }) + .await?; + ensure!(new["certificate"]["issuer"] != old_root, "agent did not migrate roots"); + let new_root = field(&new["certificate"], "issuer")?.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) + }) + .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_device(&target, &id, "offline new-root channel", |record| { + record["connected"] == false + }) + .await?; + let authenticated_before = if case.ctx.mock() { + Some(count(&target.requests(None).await?, "authenticated_connects")?) + } else { + None + }; + case.start().await?; + case.until_device(&target, &id, "new-root channel after restart", |record| { + record["connected"] == true && record["certificate"]["issuer"] == 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.admin(Method::GET, "/ca/rotation", None).await?; + if rotation.body["phase"] == "rotating" { + ensure!( + count(&rotation.body, "activeDevicesOnOldRoot")? < 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<()> { + 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.device(&target, &id).await?; + ensure!( + before.body["connected"] == false, + "channel unavailable but agent connected" + ); + let old_root = field(&before.body["certificate"], "issuer")?.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?; + expect_status(&target.rotate(None).await?, 202)?; + let new = case + .until_device(&target, &id, "check-in-triggered new-root renewal", |record| { + record["certificates"].as_array().is_some_and(|certs| { + certs + .iter() + .any(|cert| cert["issuer"] != old_root && cert["status"] == "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"] == false, + "scheduled renewal unexpectedly required a channel" + ); + let pending = new["certificates"] + .as_array() + .context("scheduled certificates missing")? + .iter() + .find(|cert| cert["issuer"] != old_root && cert["status"] == "pending") + .context("new-root pending certificate missing")?; + let new_root = field(pending, "issuer")?.to_owned(); + let pending_thumb = field(pending, "thumbprint")?.to_owned(); + 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 + && has_certificate(&case.device(&target, &id).await?.body, &pending_thumb, "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_device(&target, &id, "confirmed new-root key after restart", |record| { + record["certificate"]["issuer"] == 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)?; + let final_state = case.state(&authority)?; + ensure!( + key_name(&final_state, "current")? == pending_key + && final_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)), + "restarted agent did not keep the confirmed new-root chain" + ); + Ok(()) + }) + }) + .await +} + +pub(crate) async fn a_rotation_migrates_on_schedule(ctx: Context) -> anyhow::Result<()> { + 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.device(&target, &id).await?; + ensure!(old.body["connected"] == false, "scheduled-rotation fixture connected"); + let old_root = field(&old.body["certificate"], "issuer")?.to_owned(); + let old_thumb = current_thumbprint(&old.body)?.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" + ); + expect_status(&target.rotate(None).await?, 202)?; + let wait = Instant::now(); + let migrated = loop { + let record = case.device(&target, &id).await?.body; + if record["certificate"]["issuer"] != old_root + && record["renewalRequested"] == false + && record["connected"] == false + { + 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 = field(&migrated["certificate"], "issuer")?.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<()> { + 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() { + 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" + )); + } + + #[tokio::test] + async fn orphan_audit_accepts_both_records_and_cleanup_is_prefix_scoped() -> 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 prefix = format!("conformance-{}", uuid::Uuid::new_v4().simple()); + let target = Target::new("https://localhost/mock".to_owned(), "test".to_owned(), None, None)?; + let mut case = AgentCase::new( + Context { + 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: prefix.clone(), + agent_version: None, + unprivileged_admin_token: None, + agent_bin: PathBuf::new(), + key_backend: KeyBackend::File, + work_dir, + }, + None, + false, + ) + .await?; + 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)?; + let leftover = identity.with_file_name("identity.json.tmp"); + std::fs::write(&leftover, b"temp")?; + ensure!( + case.audit_identity_tree().is_err(), + "leftover identity.json sibling 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" + ); + for leftover in [ + case.key_file_path(&name).with_extension("p8.tmp"), + case.path() + .join("identity") + .join("pending") + .join(format!("{}.json.tmp", token_file_id("untracked-token"))), + ] { + std::fs::write(&leftover, b"partial temporary file")?; + ensure!( + case.audit_identity_tree().is_err(), + "temporary sibling survived after its record was removed" + ); + std::fs::remove_file(leftover)?; + } + 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(()) + } + + #[test] + fn agent_metadata_requires_c14_fields_and_stable_platform_values() -> anyhow::Result<()> { + 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(()) + } +} diff --git a/crates/agent-identity-conformance/src/channel.rs b/crates/agent-identity-conformance/src/channel.rs new file mode 100644 index 0000000000..49ebabc0ff --- /dev/null +++ b/crates/agent-identity-conformance/src/channel.rs @@ -0,0 +1,254 @@ +use std::time::Duration; + +use agent_channel_proto::agent_channel_client::AgentChannelClient; +use agent_channel_proto::{Ack, AgentMessage, Hello, ServerMessage, agent_message, server_message}; +use anyhow::{Context as _, ensure}; +use http::Request as HttpRequest; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::body::BoxBody; +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; + +pub(crate) struct ChannelStream { + pub(crate) 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 = identity.config["revision"] + .as_u64() + .context("identity config has no revision")?; + 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 Some(server_message::Payload::Challenge(payload)) = challenge.payload.as_ref() else { + anyhow::bail!("expected a channel challenge"); + }; + let id = uuid::Uuid::new_v4().to_string(); + let proof = + proof_override.unwrap_or_else(|| identity.key.channel_proof(&payload.challenge, &self.headers.nonce)); + self.sender + .send(AgentMessage { + id: id.clone(), + 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, + })), + }) + .await + .context("send channel Hello")?; + Ok(id) + } + + 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<()> { + let sender = self.sender.clone(); + sender + .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() + ); + 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(()) +} + +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 client = AgentChannelClient::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")?); + } + let stream = client.connect(request).await?.into_inner(); + Ok(ChannelStream { + sender, + stream, + headers, + }) +} diff --git a/crates/agent-identity-conformance/src/client.rs b/crates/agent-identity-conformance/src/client.rs new file mode 100644 index 0000000000..072489f964 --- /dev/null +++ b/crates/agent-identity-conformance/src/client.rs @@ -0,0 +1,533 @@ +use std::path::{Path, PathBuf}; +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::signer::{KeyPair, SignedHeaders}; + +#[derive(Clone)] +pub(crate) struct Target { + pub(crate) base_url: String, + pub(crate) admin_token: String, + 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, + admin_token: String, + 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(), + admin_token, + ca_path: ca_path.map(Path::to_path_buf), + authority_id, + http: builder.build()?, + }) + } + + pub(crate) async fn send( + &self, + method: Method, + path: &str, + body: Option<&[u8]>, + bearer: Option<&str>, + signed: Option<&SignedHeaders>, + ) -> anyhow::Result { + 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")?; + let body = decode_reply_body(path, status, &bytes)?; + Ok(Reply { status, body }) + } + + pub(crate) async fn admin(&self, method: Method, path: &str, body: Option<&Value>) -> anyhow::Result { + let bytes = body.map(serde_json::to_vec).transpose()?; + self.send( + method, + &format!("/api/v3/agent-identity{path}"), + bytes.as_deref(), + Some(&self.admin_token), + None, + ) + .await + } + + 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 token = self.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)?; + self.revoke(&identity.device_id).await?; + expect_status( + &self + .admin(Method::DELETE, &format!("/devices/{}", identity.device_id), None) + .await?, + 204, + )?; + expect_status( + &self + .admin(Method::DELETE, &format!("/enrollment-tokens/{}", token.id), None) + .await?, + 204, + )?; + 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, device_id: &str) -> anyhow::Result> { + let reply = self + .send( + Method::GET, + &format!("/__mock__/events?device_id={device_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["device_id"] == device_id, + "mock event belongs to the wrong device" + ); + 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 create_token( + &self, + max_uses: u32, + lifetime: Duration, + friendly_name_format: Option<&str>, + ) -> anyhow::Result { + let expires = (time::OffsetDateTime::now_utc() + time::Duration::seconds(i64::try_from(lifetime.as_secs())?)) + .format(&Rfc3339)?; + let mut body = json!({ + "name": format!("conformance-{}", uuid::Uuid::new_v4()), + "maxUses": max_uses, + "expiresAt": expires, + }); + if let Some(format) = friendly_name_format { + body["friendlyNameFormat"] = Value::String(format.to_owned()); + } + let reply = self.admin(Method::POST, "/enrollment-tokens", Some(&body)).await?; + expect_status(&reply, 201)?; + Ok(Token { + text: field(&reply.body, "token")?.to_owned(), + id: field(&reply.body["record"], "id")?.to_owned(), + }) + } + + 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 device(&self, id: &str) -> anyhow::Result { + self.admin(Method::GET, &format!("/devices/{id}"), None).await + } + + pub(crate) async fn devices_for(&self, token: &Token, extra_query: &str) -> anyhow::Result { + let page_size = if extra_query.contains("pageSize=") { + "" + } else { + "&pageSize=100" + }; + self.admin( + Method::GET, + &format!("/devices?enrollmentTokenId={}{page_size}{extra_query}", token.id), + None, + ) + .await + } + + pub(crate) async fn token_record(&self, id: &str) -> anyhow::Result { + self.admin(Method::GET, &format!("/enrollment-tokens/{id}"), None).await + } + + pub(crate) async fn revoke(&self, id: &str) -> anyhow::Result<()> { + expect_status( + &self.admin(Method::POST, &format!("/devices/{id}/revoke"), None).await?, + 204, + ) + } + + pub(crate) async fn rotate(&self, deadline: Option<&str>) -> anyhow::Result { + let body = deadline.map_or_else(|| json!({}), |value| json!({ "deadline": value })); + self.admin(Method::POST, "/ca/rotation", Some(&body)).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) + } +} + +fn decode_reply_body(path: &str, status: u16, bytes: &[u8]) -> anyhow::Result { + if bytes.is_empty() || (path.starts_with("/api/v3/agent-identity") && !(200..300).contains(&status)) { + return Ok(Value::Null); + } + serde_json::from_slice(bytes).context("decode JSON response") +} + +pub(crate) struct Token { + pub(crate) text: String, + pub(crate) id: String, +} + +pub(crate) struct Identity { + pub(crate) device_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 { + device_id: field(&reply.body, "device_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")?; + 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 admin_denials_are_status_only_but_agent_errors_need_json() -> anyhow::Result<()> { + let opaque = b"access denied"; + ensure!( + decode_reply_body("/api/v3/agent-identity/devices", 403, opaque)?.is_null(), + "admin denial required a JSON body" + ); + ensure!( + decode_reply_body("/api/agent-identity/v1/renew", 401, opaque).is_err(), + "agent-facing error accepted a non-JSON body" + ); + Ok(()) + } + + #[test] + fn enrollment_accepts_unknown_fields_but_not_null_known_fields() -> anyhow::Result<()> { + 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(), + "device_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", "device_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/main.rs b/crates/agent-identity-conformance/src/main.rs new file mode 100644 index 0000000000..15e8e6dcb5 --- /dev/null +++ b/crates/agent-identity-conformance/src/main.rs @@ -0,0 +1,703 @@ +#![expect( + clippy::print_stdout, + clippy::print_stderr, + reason = "the conformance runner reports every test and its summary to the CLI" +)] + +mod agent; +mod channel; +mod client; +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::time::{Duration, Instant}; + +use anyhow::{Context as _, ensure}; + +use crate::client::Target; + +type TestFuture = Pin> + Send>>; +type TestFn = fn(Context) -> TestFuture; +const RUN_DEADLINE: Duration = Duration::from_secs(14 * 60 + 30); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Protocol, + Agent, +} + +struct Test { + name: &'static str, + kind: Kind, + mock_only: bool, + needs_second: bool, + needs_channel: bool, + needs_no_channel: 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_channel: false, + needs_no_channel: 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_channel: false, + needs_no_channel: false, + run, + } + } + + const fn second(mut self) -> Self { + self.needs_second = true; + self + } + + const fn channel(mut self) -> Self { + self.needs_channel = true; + self + } + + const fn without_channel(mut self) -> Self { + self.needs_no_channel = true; + self + } +} + +#[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", + } + } +} + +#[derive(Clone)] +struct Context { + 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, + unprivileged_admin_token: Option, + agent_bin: PathBuf, + key_backend: KeyBackend, + work_dir: PathBuf, +} + +impl Context { + fn mock(&self) -> bool { + self.target_kind == TargetKind::Mock + } +} + +struct Options { + target: Option, + base_url: Option, + admin_token: Option, + authority_id: Option, + agent_bin: Option, + ca_path: Option, + second_base_url: Option, + second_admin_token: Option, + second_authority_id: Option, + second_ca_path: Option, + unprivileged_admin_token: Option, + key_backend: KeyBackend, + work_dir: PathBuf, + filter: String, + list: bool, + disposable_dvls_target: bool, + dvls_rotation_window_secs: u64, + leaf_lifetime_secs: u64, + expect_channel: bool, + allow_incomplete: bool, + agent_version: Option, +} + +impl Default for Options { + fn default() -> Self { + Self { + target: None, + base_url: None, + admin_token: None, + authority_id: None, + agent_bin: None, + ca_path: None, + second_base_url: None, + second_admin_token: None, + second_authority_id: None, + second_ca_path: None, + unprivileged_admin_token: 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: false, + dvls_rotation_window_secs: 60, + leaf_lifetime_secs: 90 * 24 * 3600, + expect_channel: true, + allow_incomplete: false, + agent_version: None, + } + } +} + +impl Options { + fn parse() -> anyhow::Result { + let mut options = Self::default(); + let mut args = env::args().skip(1); + while let Some(flag) = 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; + } + let value = args.next().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), + "--admin-token" => options.admin_token = Some(value), + "--authority-id" => options.authority_id = Some(value), + "--agent-bin" => options.agent_bin = Some(value.into()), + "--agent-version" => { + ensure!(!value.is_empty(), "--agent-version must not be empty"); + options.agent_version = Some(value); + } + "--extra-trusted-root" => options.ca_path = Some(value.into()), + "--second-base-url" => options.second_base_url = Some(value), + "--second-admin-token" => options.second_admin_token = Some(value), + "--second-authority-id" => options.second_authority_id = Some(value), + "--second-extra-trusted-root" => options.second_ca_path = Some(value.into()), + "--unprivileged-admin-token" => options.unprivileged_admin_token = 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" => { + options.dvls_rotation_window_secs = value + .parse() + .context("--dvls-rotation-window-secs must be a positive integer")?; + ensure!( + options.dvls_rotation_window_secs > 0, + "--dvls-rotation-window-secs must be positive" + ); + } + "--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, + _ => anyhow::bail!("unknown flag {flag}"), + } + } + Ok(options) + } + + async fn context(self) -> anyhow::Result { + let kind = self.target.context("--target is required")?; + let target = Target::new( + self.base_url.context("--base-url is required")?, + self.admin_token.context("--admin-token is required")?, + self.ca_path.as_deref(), + self.authority_id.as_deref(), + )?; + let unprivileged_admin_token = if kind == TargetKind::Mock { + Some(format!("{}-unprivileged", target.admin_token)) + } else { + self.unprivileged_admin_token + }; + let second = match (self.second_base_url, self.second_admin_token) { + (Some(url), Some(token)) => Some(Target::new( + url, + token, + self.second_ca_path.as_deref(), + self.second_authority_id.as_deref(), + )?), + (None, None) => None, + _ => anyhow::bail!("second base URL and admin token must be supplied together"), + }; + ensure!( + second.is_some() || self.second_authority_id.is_none(), + "--second-authority-id requires a second target" + ); + if kind == TargetKind::Mock { + target.reset().await?; + if let Some(other) = &second { + other.reset().await?; + } + } + let channel_available = if self.expect_channel { + true + } else { + target.channel_available().await? + }; + tokio::fs::create_dir_all(&self.work_dir).await?; + Ok(Context { + target, + second, + target_kind: kind, + disposable_dvls_target: self.disposable_dvls_target, + dvls_rotation_window_secs: self.dvls_rotation_window_secs, + leaf_lifetime_secs: self.leaf_lifetime_secs, + expect_channel: self.expect_channel, + channel_available, + key_name_prefix: format!("conformance-{}", uuid::Uuid::new_v4().simple()), + agent_version: self.agent_version, + unprivileged_admin_token, + agent_bin: self.agent_bin.context("--agent-bin is required")?, + key_backend: self.key_backend, + work_dir: self.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()?; + 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 context = tokio::time::timeout(RUN_DEADLINE, options.context()) + .await + .context("conformance setup exceeded the 14-minute-30-second deadline")??; + println!("KEY NAME PREFIX {}", context.key_name_prefix); + 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.mock_only && !context.mock() { + Outcome::NotApplicable("requires the mock-only control API") + } else 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 test.needs_channel && !context.expect_channel && !context.channel_available { + Outcome::NotApplicable("config.agent_channel_url absent and --expect-channel=false") + } else if test.needs_no_channel && !context.mock() && context.channel_available { + Outcome::NotApplicable("requires a naturally channelless DVLS target") + } else if context.target_kind == TargetKind::Dvls + && !context.disposable_dvls_target + && (test.name.starts_with("p_rotation_") || test.name.starts_with("a_rotation_")) + { + Outcome::Skip("rotation requires --disposable-dvls-target") + } else if test.name == "p_stream_closes_at_not_after" && !context.mock() && context.leaf_lifetime_secs > 30 { + Outcome::Skip("real-target stream expiry requires --leaf-lifetime-secs <= 30") + } else if test.name == "p_admin_permission_denied" && context.unprivileged_admin_token.is_none() { + Outcome::Skip("requires --unprivileged-admin-token") + } else if test.needs_second && context.second.is_none() { + Outcome::Skip("requires a second authority (--second-base-url and --second-admin-token)") + } else if test.name == "a_key_non_exportable" && context.key_backend != KeyBackend::WindowsSoftwareProvider { + Outcome::Skip("requires --key-backend windows-software-provider") + } else if test.name == "a_file_backend_permissions" && context.key_backend != KeyBackend::File { + Outcome::Skip("requires --key-backend file") + } else { + let result = async { + if context.mock() { + context.target.reset().await?; + if test.needs_second { + context + .second + .as_ref() + .context("missing 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 { + Duration::from_secs(90) + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + let allowed = timeout.min(remaining); + match tokio::time::timeout(allowed, result).await { + Ok(Ok(())) => Outcome::Pass, + Ok(Err(error)) => { + 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:#}")) + } + } + Err(_) if allowed == remaining => { + deadline_exceeded = true; + Outcome::Fail("global 14-minute-30-second deadline exceeded".to_owned()) + } + Err(_) => Outcome::Fail("timed out".to_owned()), + } + }; + 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}"); + } + 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(()) +} + +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_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_device_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_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_no_hello_timeout).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(), + 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(), + 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_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_device_migrates, mock_only), + p!(p_rotation_completes_immediately_without_old_root_devices, 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_admin_tokens_crud_and_states), + p!(p_admin_requires_auth), + p!(p_admin_permission_denied), + p!(p_rotation_admin_write_requires_auth), + p!(p_error_body_shape), + 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_device_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), + a!(a_cli_identity_enroll_stdin), + a!(a_renewal_happy_path).channel(), + 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(), + a!(a_request_renewal_while_offline).channel(), + 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_device_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_multi_authority).second().channel(), + a!(a_key_non_exportable), + a!(a_file_backend_permissions), + a!(a_rotation_migrates_connected_agent).channel(), + a!(a_rotation_migrates_through_check_in, mock_only), + a!(a_rotation_migrates_on_schedule, mock_only), +]; diff --git a/crates/agent-identity-conformance/src/protocol.rs b/crates/agent-identity-conformance/src/protocol.rs new file mode 100644 index 0000000000..a798e1ce28 --- /dev/null +++ b/crates/agent-identity-conformance/src/protocol.rs @@ -0,0 +1,5953 @@ +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::{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::{BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAltName}; + +use crate::client::{Identity, Reply, Target, Token, count, decoded_certificate, expect_error, expect_status, field}; +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: u32) -> anyhow::Result { + ctx.target.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 { + let status = target.admin(Method::GET, "/ca/rotation", None).await?; + expect_status(&status, 200)?; + if status.body["phase"] == "idle" { + 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.rotate(None).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)?)) + .format(&Rfc3339)?; + ctx.target.rotate(Some(&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(()) +} + +pub(crate) fn has_certificate(device: &Value, thumbprint: &str, status: &str) -> bool { + device["certificates"].as_array().is_some_and(|certs| { + certs + .iter() + .any(|cert| cert["thumbprint"] == thumbprint && cert["status"] == status) + }) +} + +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 device(target: &Target, id: &str) -> anyhow::Result { + let reply = target.device(id).await?; + expect_status(&reply, 200)?; + Ok(reply.body) +} + +async fn assert_device_metadata_and_certificates_unchanged( + target: &Target, + id: &str, + before: &Value, +) -> anyhow::Result<()> { + let after = device(target, id).await?; + ensure!( + after["metadata"] == before["metadata"] && after["certificates"] == before["certificates"], + "rejected key reuse changed device metadata or certificate identities/statuses" + ); + Ok(()) +} + +async fn active_old_root_ids(ctx: &Context, issuer: &str) -> anyhow::Result> { + let now = chain_evaluation_time(ctx).await?; + let mut page = 1; + let mut ids = HashSet::new(); + let mut seen = HashSet::new(); + loop { + let reply = ctx + .target + .admin( + Method::GET, + &format!("/devices?view=full&pageSize=100&pageNumber={page}"), + None, + ) + .await?; + expect_status(&reply, 200)?; + let rows = reply.body["data"].as_array().context("old-root device page")?; + for row in rows { + let id = field(row, "id")?.to_owned(); + ensure!(seen.insert(id.clone()), "old-root listing repeated a device ID"); + if row["status"] != "revoked" { + let certificates = row["certificates"] + .as_array() + .context("full device has no certificates")?; + for cert in certificates { + if matches!(field(cert, "status")?, "current" | "pending") + && field(cert, "issuer")? == issuer + && time::OffsetDateTime::parse(field(cert, "notAfter")?, &Rfc3339)?.unix_timestamp() > now + { + ids.insert(id); + break; + } + } + } + } + if page >= count(&reply.body, "totalPages")? { + ensure!( + seen.len() as u64 == count(&reply.body, "totalCount")?, + "old-root listing count disagrees with all pages" + ); + return Ok(ids); + } + page += 1; + } +} + +fn require_isolated_old_root_fixture(active: &HashSet, expected: &[&str], mock: bool) -> anyhow::Result<()> { + ensure!( + expected.iter().all(|id| active.contains(*id)), + "newly enrolled device 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 devices").into()); + } + anyhow::bail!("mock reset left unrelated active old-root devices"); + } + 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)?; + let (_, basic) = cert + .tbs_certificate() + .get_extension::()? + .context("root lacks basic constraints")?; + ensure!(basic.ca, "trust anchor is not a CA"); + let (_, usage) = cert + .tbs_certificate() + .get_extension::()? + .context("root lacks key usage")?; + ensure!(usage.key_cert_sign(), "trust anchor lacks keyCertSign"); + let key = VerifyingKey::from_public_key_der(&cert.tbs_certificate().subject_public_key_info().to_der()?)?; + let signature = Signature::from_der(cert.signature().as_bytes().context("root signature")?)?; + key.verify(&cert.tbs_certificate().to_der()?, &signature)?; + ensure!( + cert.tbs_certificate().issuer() == cert.tbs_certificate().subject(), + "root is not self-issued" + ); + let subject = cert.tbs_certificate().subject().to_string(); + let name = subject.strip_prefix("CN=").context("root subject is not a CN")?; + let (product, generation) = name + .rsplit_once(" Agent Identity Root ") + .context("root subject does not match the identity-root profile")?; + ensure!( + !product.is_empty() && generation.parse::().is_ok(), + "invalid identity-root CN" + ); + 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" + ); + let root_lifetime = cert_after - cert_before; + ensure!( + (10 * 365 * 24 * 3600..=10 * 366 * 24 * 3600).contains(&root_lifetime), + "root certificate lifetime is not ten years" + ); + Ok(()) +} + +fn assert_leaf_profile( + leaf: &str, + root: &str, + device_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={device_id}").parse::()?; + ensure!( + cert.tbs_certificate().subject() == &expected, + "leaf subject does not match device 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:{device_id}"); + ensure!( + san.0 + .iter() + .any(|name| matches!(name, GeneralName::UniformResourceIdentifier(value) if value.as_str() == uri)), + "leaf SAN lacks the device 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"); + 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(()) +} + +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()) +} + +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 issuer in &certs[1..] { + let (_, basic) = issuer + .tbs_certificate() + .get_extension::()? + .context("chain issuer lacks basic constraints")?; + ensure!(basic.ca, "chain issuer is not a CA"); + let (_, usage) = issuer + .tbs_certificate() + .get_extension::()? + .context("chain issuer lacks key usage")?; + ensure!(usage.key_cert_sign(), "chain issuer 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 ec = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); + ensure!( + issuer_key.algorithm.oid == ec, + "unsupported certificate issuer key algorithm" + ); + let curve = issuer_key + .algorithm + .parameters + .as_ref() + .context("certificate issuer has no curve")? + .decode_as::()?; + let signed_bytes = pair[0].tbs_certificate().to_der()?; + let signature = pair[0].signature().as_bytes().context("certificate signature")?; + if curve == ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7") { + ensure!( + pair[0].signature_algorithm().oid == ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"), + "P-256 certificate used an unsupported signature algorithm" + ); + let key = VerifyingKey::from_public_key_der(&issuer_key.to_der()?)?; + key.verify(&signed_bytes, &Signature::from_der(signature)?)?; + } else if curve == ObjectIdentifier::new_unwrap("1.3.132.0.34") { + ensure!( + pair[0].signature_algorithm().oid == ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.3"), + "P-384 certificate used an unsupported signature algorithm" + ); + let key = VerifyingKey384::from_public_key_der(&issuer_key.to_der()?)?; + key.verify(&signed_bytes, &Signature384::from_der(signature)?)?; + } else { + anyhow::bail!("unsupported certificate issuer curve {curve}"); + } + } + Ok(()) +} + +pub(crate) async fn p_trust_anchor_lists_roots(ctx: Context) -> anyhow::Result<()> { + 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.device_id).is_ok(), "invalid device ID"); + assert_leaf_profile( + &identity.certificate_chain[0], + identity.certificate_chain.last().context("missing issuer")?, + &identity.device_id, + &identity.key, + ctx.leaf_lifetime_secs, + )?; + Ok(()) +} + +pub(crate) async fn p_channel_url_expectation(ctx: Context) -> anyhow::Result<()> { + 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<()> { + 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<()> { + 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.token_record(&token.id).await?; + expect_status(&record, 200)?; + ensure!( + count(&record.body, "usedCount")? == 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<()> { + 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, "device_id")?; + ensure!( + ids.insert(id.to_owned()), + "concurrent enrollments returned duplicate device 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.token_record(&token.id).await?; + ensure!( + count(&record.body, "usedCount")? == 5, + "concurrent use count is incorrect" + ); + let devices = ctx.target.devices_for(&token, "").await?; + ensure!( + count(&devices.body, "totalCount")? == 5, + "concurrent enrollment created unexpected devices" + ); + Ok(()) +} + +pub(crate) async fn p_concurrent_same_key_enroll_replays_winner(ctx: Context) -> anyhow::Result<()> { + 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["device_id"] == replies[1].body["device_id"] + && replies[0].body["certificate_chain"] == replies[1].body["certificate_chain"] + && count(&ctx.target.token_record(&token.id).await?.body, "usedCount")? == 1 + && count(&ctx.target.devices_for(&token, "").await?.body, "totalCount")? == 1, + "concurrent same-key enrolls did not replay one winning device and consume one use" + ); + Ok(()) +} + +pub(crate) async fn p_concurrent_same_key_renew_replays_winner(ctx: Context) -> anyhow::Result<()> { + 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + replies.len() == 2 + && replies[0].body["certificate_chain"] == replies[1].body["certificate_chain"] + && record["certificates"].as_array().is_some_and(|certs| certs + .iter() + .filter(|cert| cert["status"] == "pending") + .count() + == 1) + && count(&ctx.target.token_record(&token.id).await?.body, "usedCount")? == 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<()> { + 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.token_record(&token.id).await?; + ensure!( + count(&record.body, "usedCount")? == 0, + "invalid enrollment consumed a use" + ); + Ok(()) +} + +pub(crate) async fn p_token_errors_distinct(ctx: Context) -> anyhow::Result<()> { + 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?; + expect_status( + &ctx.target + .admin(Method::DELETE, &format!("/enrollment-tokens/{}", other.id), None) + .await?, + 204, + )?; + 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<()> { + let token = ctx.target.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.token_record(&token.id).await?; + ensure!(record.body["state"] == "expired", "token not marked expired"); + ensure!(count(&record.body, "usedCount")? == 0, "expired token consumed a use"); + Ok(()) +} + +pub(crate) async fn p_enroll_idempotent_same_key(ctx: Context) -> anyhow::Result<()> { + 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 = device(&ctx.target, field(&first.body, "device_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["device_id"] == replay.body["device_id"] + && first.body["certificate_chain"] == replay.body["certificate_chain"], + "same-key replay was not idempotent" + ); + let record = ctx.target.token_record(&token.id).await?; + ensure!( + count(&record.body, "usedCount")? == 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_device = device(&ctx.target, field(&first.body, "device_id")?).await?; + ensure!( + replayed_device["metadata"] == before["metadata"] && replayed_device["lastSeenAt"] == before["lastSeenAt"], + "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["device_id"] == first.body["device_id"] + && expired_replay.body["certificate_chain"] == first.body["certificate_chain"] + && count(&ctx.target.token_record(&token.id).await?.body, "usedCount")? == 1 + && device(&ctx.target, field(&first.body, "device_id")?).await?["metadata"] == before["metadata"], + "expired-token current-key replay created, consumed or changed a device" + ); + } + Ok(()) +} + +pub(crate) async fn p_token_config_contributes_to_effective_config(ctx: Context) -> anyhow::Result<()> { + let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(1)).format(&Rfc3339)?; + let created = ctx + .target + .admin( + Method::POST, + "/enrollment-tokens", + Some(&json!({ + "name": format!("conformance-{}", uuid::Uuid::new_v4()), + "maxUses": 1, + "expiresAt": expires, + "config": { "psu_channel_url": "https://psu.example" } + })), + ) + .await?; + expect_status(&created, 201)?; + let token = Token { + text: field(&created.body, "token")?.to_owned(), + id: field(&created.body["record"], "id")?.to_owned(), + }; + 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<()> { + 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 device_id = field(&first.body, "device_id")?; + let before = device(&ctx.target, device_id).await?; + let foreign = identity(&ctx.target, &foreign_token, "foreign").await?; + expect_status( + &ctx.target + .admin(Method::DELETE, &format!("/enrollment-tokens/{}", token.id), None) + .await?, + 204, + )?; + expect_status(&ctx.target.token_record(&token.id).await?, 404)?; + let replay = ctx + .target + .enroll(&token.text, &key, &json!({ "hostname": "must-not-replace" })) + .await?; + expect_status(&replay, 200)?; + ensure!( + replay.body["device_id"] == first.body["device_id"] + && replay.body["certificate_chain"] == first.body["certificate_chain"], + "deleted token could not recover its own committed enrollment" + ); + let after = device(&ctx.target, device_id).await?; + ensure!( + after["metadata"] == before["metadata"] && after["lastSeenAt"] == before["lastSeenAt"], + "deleted-token replay changed device 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.revoke(&foreign.device_id).await?; + expect_error( + &ctx.target.enroll(&token.text, &foreign.key, &json!({})).await?, + 401, + "token_invalid", + )?; + expect_status( + &ctx.target + .admin(Method::DELETE, &format!("/devices/{}", foreign.device_id), None) + .await?, + 204, + )?; + 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!( + count(&ctx.target.devices_for(&token, "").await?.body, "totalCount")? == 1 + && device(&ctx.target, device_id).await? == before, + "deleted token issued another device or changed its own replayed device" + ); + Ok(()) +} + +pub(crate) async fn p_enroll_revoked_same_key_rejected(ctx: Context) -> anyhow::Result<()> { + 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.revoke(&identity.device_id).await?; + let refused = ctx.target.enroll(&token.text, &identity.key, &json!({})).await?; + expect_error(&refused, 403, "device_revoked")?; + let record = ctx.target.token_record(&token.id).await?; + ensure!(count(&record.body, "usedCount")? == 1, "revoked replay consumed a use"); + Ok(()) +} + +pub(crate) async fn p_enroll_certificate_key_reuse_rules(ctx: Context) -> anyhow::Result<()> { + 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_device = device(&ctx.target, &identity.device_id).await?; + let original_name = field(&original_device, "friendlyName")?.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["device_id"] == identity.device_id + && replay.body["certificate_chain"] == first.body["certificate_chain"], + "current-key enrollment did not replay the existing device and current chain" + ); + let replayed_device = device(&ctx.target, &identity.device_id).await?; + ensure!( + replayed_device["metadata"] == original_device["metadata"] + && replayed_device["lastSeenAt"] == original_device["lastSeenAt"] + && replayed_device["friendlyName"] == original_name, + "current-key replay changed device 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 = device(&ctx.target, &identity.device_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_device_metadata_and_certificates_unchanged(&ctx.target, &identity.device_id, &pending_before).await?; + let second_pending = KeyPair::generate()?; + expect_status(&ctx.target.renew(&identity, &second_pending, &json!({})).await?, 200)?; + let retired_before = device(&ctx.target, &identity.device_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_device_metadata_and_certificates_unchanged(&ctx.target, &identity.device_id, &retired_before).await?; + ctx.target.revoke(&identity.device_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", + )?; + } + expect_status( + &ctx.target + .admin(Method::DELETE, &format!("/devices/{}", identity.device_id), None) + .await?, + 204, + )?; + for key in [&identity.key, &first_pending, &second_pending] { + expect_error( + &ctx.target.enroll(&other_token.text, key, &json!({})).await?, + 400, + "invalid_request", + )?; + expect_error( + &ctx.target.enroll(&original_token.text, key, &json!({})).await?, + 403, + "device_revoked", + )?; + } + ensure!( + count(&ctx.target.token_record(&other_token.id).await?.body, "usedCount")? == 0 + && count(&ctx.target.token_record(&original_token.id).await?.body, "usedCount")? == 1 + && count(&ctx.target.devices_for(&original_token, "").await?.body, "totalCount")? == 0, + "key-reuse attempts consumed a token use or recreated a deleted device" + ); + Ok(()) +} + +pub(crate) async fn p_renew_certificate_key_reuse_rules(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "renew-a").await?; + let (_, other) = issued(&ctx, "renew-b").await?; + let initial = device(&ctx.target, &identity.device_id).await?; + for key in [&identity.key, &other.key] { + expect_error( + &ctx.target.renew(&identity, key, &json!({})).await?, + 400, + "invalid_request", + )?; + assert_device_metadata_and_certificates_unchanged(&ctx.target, &identity.device_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 = device(&ctx.target, &identity.device_id).await?; + expect_error( + &ctx.target.renew(&identity, &pending, &json!({})).await?, + 400, + "invalid_request", + )?; + assert_device_metadata_and_certificates_unchanged(&ctx.target, &identity.device_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_device_metadata_and_certificates_unchanged(&ctx.target, &identity.device_id, &renewed).await?; + } + ctx.target.revoke(&other.device_id).await?; + expect_status( + &ctx.target + .admin(Method::DELETE, &format!("/devices/{}", other.device_id), None) + .await?, + 204, + )?; + for key in [&other.key, &other_pending, &other_replacement] { + expect_error( + &ctx.target.renew(&identity, key, &json!({})).await?, + 400, + "invalid_request", + )?; + assert_device_metadata_and_certificates_unchanged(&ctx.target, &identity.device_id, &renewed).await?; + } + ensure!( + renewed["certificates"].as_array().is_some_and( + |certs| certs.len() == 3 && certs.iter().filter(|cert| cert["status"] == "pending").count() == 1 + ), + "renewal fixture did not have exactly one pending certificate" + ); + Ok(()) +} + +pub(crate) async fn p_metadata_limits(ctx: Context) -> anyhow::Result<()> { + 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.token_record(&token.id).await?; + ensure!( + count(&record.body, "usedCount")? == 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 device = device(&ctx.target, &identity.device_id).await?; + ensure!( + device["metadata"]["custom_key"] == "kept", + "unknown metadata key was not retained" + ); + Ok(()) +} + +pub(crate) async fn p_friendly_name_format(ctx: Context) -> anyhow::Result<()> { + let token = ctx + .target + .create_token(2, TOKEN_LIFETIME, Some(" {hostname}-{{ok}}-{token_name}-{fqdn} ")) + .await?; + let record = ctx.target.token_record(&token.id).await?; + let name = field(&record.body, "name")?; + let first_identity = identity(&ctx.target, &token, "hello").await?; + let first_name = field(&device(&ctx.target, &first_identity.device_id).await?, "friendlyName")?.to_owned(); + ensure!( + first_name == format!("hello-{{ok}}-{name}-"), + "friendly name formatting incorrect: {first_name}" + ); + let empty_token = ctx.target.create_token(1, TOKEN_LIFETIME, Some("{hostname}")).await?; + let empty = identity(&ctx.target, &empty_token, "").await?; + let empty_name = field(&device(&ctx.target, &empty.device_id).await?, "friendlyName")?.to_owned(); + ensure!( + empty_name == empty.device_id, + "empty friendly name did not fall back to device ID" + ); + let long_token = ctx.target.create_token(1, TOKEN_LIFETIME, Some("{hostname}")).await?; + let long = identity(&ctx.target, &long_token, &"é".repeat(260)).await?; + let long_name = field(&device(&ctx.target, &long.device_id).await?, "friendlyName")?.to_owned(); + ensure!( + long_name.chars().count() == 255, + "friendly name not limited to 255 characters" + ); + let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(1)).format(&Rfc3339)?; + let bad = ctx + .target + .admin( + Method::POST, + "/enrollment-tokens", + Some(&json!({ + "name": "bad-format", + "maxUses": 1, + "expiresAt": expires, + "friendlyNameFormat": "{not_a_known_key}" + })), + ) + .await?; + expect_status(&bad, 400)?; + let lone_brace = ctx + .target + .admin( + Method::POST, + "/enrollment-tokens", + Some(&json!({ + "name": "bad-brace", + "maxUses": 1, + "expiresAt": expires, + "friendlyNameFormat": "prefix}" + })), + ) + .await?; + expect_status(&lone_brace, 400)?; + Ok(()) +} + +pub(crate) async fn p_device_cannot_impersonate_another(ctx: Context) -> anyhow::Result<()> { + let (_, a) = issued(&ctx, "alpha").await?; + let (b_token, b) = issued(&ctx, "bravo").await?; + let b_before = device(&ctx.target, &b.device_id).await?; + let new_key = KeyPair::generate()?; + let body = serde_json::to_vec(&json!({ + "csr": new_key.csr, + "device_id": b.device_id, + "metadata": { "hostname": "changed", "device_id": b.device_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 = device(&ctx.target, &a.device_id).await?; + let b_record = device(&ctx.target, &b.device_id).await?; + ensure!( + a_record["metadata"]["hostname"] == "changed", + "A's renewal did not update A" + ); + ensure!(b_record["metadata"]["hostname"] == "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<()> { + let (_, a) = issued(&ctx, "alpha").await?; + let (_, b) = issued(&ctx, "bravo").await?; + let b_before = device(&ctx.target, &b.device_id).await?; + let mut stream = channel::open(&ctx.target, &a).await?; + stream + .hello(&a, &[("hostname", "channel-a"), ("device_id", &b.device_id)]) + .await?; + let a_after_hello = device(&ctx.target, &a.device_id).await?; + ensure!( + a_after_hello["metadata"]["hostname"] == "channel-a" && a_after_hello["metadata"]["device_id"] == b.device_id, + "A's Hello did not update A's metadata" + ); + let b_after_hello = device(&ctx.target, &b.device_id).await?; + ensure!( + b_after_hello["metadata"] == b_before["metadata"] + && b_after_hello["certificates"] == b_before["certificates"] + && b_after_hello["lastSeenAt"] == b_before["lastSeenAt"] + && b_after_hello["connected"] == b_before["connected"], + "A's Hello modified B's device" + ); + Ok(()) +} + +pub(crate) async fn p_renew_digest_integrity(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "original").await?; + let before = device(&ctx.target, &identity.device_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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + after["metadata"] == before["metadata"] + && after["certificates"] == before["certificates"] + && after["lastSeenAt"] == before["lastSeenAt"], + "{variant} tampering changed device state" + ); + ensure!( + after["certificates"] + .as_array() + .is_some_and(|certs| certs.iter().all(|cert| cert["status"] != "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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&renewed, &identity.thumbprint, "pending") + && renewed["metadata"]["hostname"] == "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<()> { + renew_in_alternate_parameter_order(&ctx).await?; + Ok(()) +} + +pub(crate) async fn p_signature_parameter_order_accepted_connect(ctx: Context) -> anyhow::Result<()> { + 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<()> { + let (_, identity) = issued(&ctx, "original").await?; + let before = device(&ctx.target, &identity.device_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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + before["metadata"] == after["metadata"] + && before["certificates"] == after["certificates"] + && after["certificates"] + .as_array() + .is_some_and(|certs| certs.iter().all(|cert| cert["status"] != "pending")), + "SHA-384 CSR changed metadata or created a pending certificate" + ); + Ok(()) +} + +pub(crate) async fn p_replay_nonce_rejected(ctx: Context) -> anyhow::Result<()> { + 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<()> { + 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<()> { + 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<()> { + 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_status( + 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<()> { + let (_, identity) = issued(&ctx, "duplicate-channel-signature").await?; + let before = device(&ctx.target, &identity.device_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_status( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + } + let after = device(&ctx.target, &identity.device_id).await?; + ensure!( + after["connected"] == false + && after["metadata"] == before["metadata"] + && after["lastSeenAt"] == before["lastSeenAt"], + "duplicate signed channel metadata authenticated or changed device state" + ); + Ok(()) +} + +pub(crate) async fn p_confirm_cross_tag_signatures_rejected(ctx: Context) -> anyhow::Result<()> { + 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&unchanged, &old_thumb, "current") + && has_certificate(&unchanged, &identity.thumbprint, "pending"), + "cross-tag probes changed certificate statuses" + ); + Ok(()) +} + +pub(crate) async fn p_confirm_signature_rejected_on_connect(ctx: Context) -> anyhow::Result<()> { + 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_status( + 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!( + has_certificate( + &device(&ctx.target, &identity.device_id).await?, + &identity.thumbprint, + "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_status( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + let unchanged = device(&ctx.target, &identity.device_id).await?; + ensure!( + unchanged["connected"] == false + && has_certificate(&unchanged, &old_thumb, "current") + && has_certificate(&unchanged, &identity.thumbprint, "pending"), + "confirm-tagged channel probe changed certificate status or connected the device" + ); + 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(()) + } +} + +fn last_seen_at(device: &Value) -> anyhow::Result> { + match device.get("lastSeenAt") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(timestamp)) => Ok(Some( + time::OffsetDateTime::parse(timestamp, &Rfc3339).context("invalid lastSeenAt")?, + )), + Some(_) => anyhow::bail!("lastSeenAt must be an RFC 3339 string when present"), + } +} + +pub(crate) async fn p_check_in_current_and_pending(ctx: Context) -> anyhow::Result<()> { + let (_, mut identity) = issued(&ctx, "check-in").await?; + let enrolled = identity.config.clone(); + let before = device(&ctx.target, &identity.device_id).await?; + let initial_seen = last_seen_at(&before)?.context("new device has no lastSeenAt")?; + let created_at = time::OffsetDateTime::parse(field(&before, "createdAt")?, &Rfc3339)?; + ensure!( + enrolled["revision"].as_u64().is_some() && created_at <= initial_seen, + "enrollment omitted a config revision or the device's initial lastSeenAt" + ); + 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 = device(&ctx.target, &identity.device_id).await?; + let current_seen = last_seen_at(&after)?.context("current check-in did not set lastSeenAt")?; + ensure!( + current.body["config"] == enrolled + && current.body["renewal_requested"] == false + && after["metadata"]["hostname"] == "current-check-in" + && initial_seen < current_seen, + "current-certificate check-in did not return config or refresh metadata and lastSeenAt" + ); + expect_status( + &ctx.target + .admin( + Method::POST, + &format!("/devices/{}/request-renewal", identity.device_id), + None, + ) + .await?, + 202, + )?; + 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 = device(&ctx.target, &identity.device_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 = device(&ctx.target, &identity.device_id).await?; + let pending_seen = last_seen_at(&after_pending)?.context("pending check-in did not set lastSeenAt")?; + ensure!( + pending.body["config"] == enrolled + && pending.body["renewal_requested"] == true + && has_certificate(&after_pending, &identity.thumbprint, "pending") + && after_pending["metadata"]["hostname"] == "pending-check-in" + && last_seen_at(&before_pending)?.is_none_or(|previous| pending_seen > previous), + "pending-certificate check-in did not return config or refresh metadata and lastSeenAt" + ); + 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + after_invalid["metadata"] == before_invalid["metadata"] + && after_invalid["lastSeenAt"] == before_invalid["lastSeenAt"], + "invalid check-in changed metadata or lastSeenAt" + ); + 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<()> { + 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!( + device(&ctx.target, &identity.device_id).await?["certificates"] + .as_array() + .is_some_and(|certs| certs.len() == 1 && certs[0]["status"] == "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<()> { + 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_status( + 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<()> { + let (_, identity) = issued(&ctx, "same-tag-components").await?; + let before = device(&ctx.target, &identity.device_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!( + device(&ctx.target, &identity.device_id).await? == before, + "{variant} {tag} components changed device state" + ); + } + } + Ok(()) +} + +pub(crate) async fn p_same_tag_connect_components_rejected(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "same-tag-connect-components").await?; + let before = device(&ctx.target, &identity.device_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_status( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + ensure!( + device(&ctx.target, &identity.device_id).await? == before, + "{variant} connect components changed device state" + ); + } + Ok(()) +} + +pub(crate) async fn p_extra_signature_parameters_rejected_renew(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "extra-renew-parameter").await?; + let before = device(&ctx.target, &identity.device_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!( + device(&ctx.target, &identity.device_id).await? == before, + "extra {parameter} renew signature parameter changed device state" + ); + } + Ok(()) +} + +pub(crate) async fn p_extra_signature_parameters_rejected_connect(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "extra-connect-parameter").await?; + let before = device(&ctx.target, &identity.device_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_status( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + ensure!( + device(&ctx.target, &identity.device_id).await? == before, + "extra {parameter} connect signature parameter changed device state" + ); + } + Ok(()) +} + +pub(crate) async fn p_signature_parser_wire_negatives(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "wire-negatives").await?; + let original = device(&ctx.target, &identity.device_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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + after["metadata"] == original["metadata"] + && after["certificates"] == original["certificates"] + && after["lastSeenAt"] == original["lastSeenAt"], + "wire-negative variant {variant} changed device 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!( + device(&ctx.target, &identity.device_id).await? == original, + "correctly signed check-in with two Content-Digest members changed device 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!( + device(&ctx.target, &identity.device_id).await? == original, + "duplicate {repeated} headers on {tag} changed device state" + ); + } + } + Ok(()) +} + +pub(crate) async fn p_renew_happy_path(ctx: Context) -> anyhow::Result<()> { + let (_, mut identity) = issued(&ctx, "unchanged").await?; + let original_name = field(&device(&ctx.target, &identity.device_id).await?, "friendlyName")?.to_owned(); + 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.device_id, + &new_key, + ctx.leaf_lifetime_secs, + )?; + identity.adopt_certificate(&reply.body["certificate_chain"], new_key)?; + let pending = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&pending, &identity.thumbprint, "pending"), + "new certificate not pending" + ); + ensure!( + pending["friendlyName"] == original_name, + "friendly name changed on renewal" + ); + ensure!( + pending["metadata"]["hostname"] == "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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&replayed, &identity.thumbprint, "pending") + && replayed["metadata"]["hostname"] == "pending-replay", + "replayed renew did not update metadata or promoted the pending certificate" + ); + if ctx.mock() { + ensure!( + last_seen_at(&replayed)? > last_seen_at(&pending)?, + "replayed renew did not refresh lastSeenAt" + ); + } + ensure!( + replayed["friendlyName"] == original_name, + "replayed renew changed friendly name" + ); + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + ensure!( + has_certificate( + &device(&ctx.target, &identity.device_id).await?, + &identity.thumbprint, + "current" + ), + "confirm did not promote the pending certificate" + ); + Ok(()) +} + +pub(crate) async fn p_confirm_promotes_and_is_idempotent(ctx: Context) -> anyhow::Result<()> { + let (_, mut identity) = issued(&ctx, "confirm-idempotent").await?; + let old_thumb = identity.thumbprint.clone(); + let original = device(&ctx.target, &identity.device_id).await?; + let initial_seen = last_seen_at(&original)?.context("new device has no lastSeenAt")?; + let original_events = if ctx.mock() { + Some(ctx.target.events(&identity.device_id).await?) + } else { + None + }; + next_last_seen_second(&ctx).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let already_current = device(&ctx.target, &identity.device_id).await?; + ensure!( + already_current["certificates"] == original["certificates"] + && initial_seen < last_seen_at(&already_current)?.context("current confirm omitted lastSeenAt")?, + "confirm with an already current certificate changed status or did not refresh lastSeenAt" + ); + if let Some(events) = &original_events { + ensure!( + ctx.target.events(&identity.device_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 = device(&ctx.target, &identity.device_id).await?; + next_last_seen_second(&ctx).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let promoted = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&promoted, &old_thumb, "retired") + && has_certificate(&promoted, &identity.thumbprint, "current") + && last_seen_at(&before_promotion)?.context("renew omitted lastSeenAt")? + < last_seen_at(&promoted)?.context("pending confirm omitted lastSeenAt")?, + "confirm did not promote pending, retire current or refresh lastSeenAt" + ); + let events = if ctx.mock() { + Some(ctx.target.events(&identity.device_id).await?) + } else { + None + }; + next_last_seen_second(&ctx).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let replayed = device(&ctx.target, &identity.device_id).await?; + ensure!( + replayed["certificates"] == promoted["certificates"] + && last_seen_at(&promoted)?.context("confirmed device omitted lastSeenAt")? + < last_seen_at(&replayed)?.context("confirm retry omitted lastSeenAt")?, + "idempotent confirm retry changed certificate statuses or failed to refresh lastSeenAt" + ); + if let Some(events) = events { + ensure!( + ctx.target.events(&identity.device_id).await? == events, + "idempotent confirm retry emitted another promotion" + ); + } + Ok(()) +} + +pub(crate) async fn p_confirm_retired_certificate_rejected(ctx: Context) -> anyhow::Result<()> { + 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&before, &first_thumb, "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, "device_unknown")?; + assert_device_metadata_and_certificates_unchanged(&ctx.target, &identity.device_id, &before).await?; + Ok(()) +} + +pub(crate) async fn p_confirm_expired_pending_certificate_rejected(ctx: Context) -> anyhow::Result<()> { + 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 = device(&ctx.target, &identity.device_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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + after["certificates"] == before["certificates"] + && after["metadata"] == before["metadata"] + && after["lastSeenAt"] == before["lastSeenAt"] + && has_certificate(&after, &old_thumb, "current") + && has_certificate(&after, &identity.thumbprint, "pending"), + "expired current or pending check-in/confirm changed device state" + ); + Ok(()) +} + +pub(crate) async fn p_confirm_nonempty_body_rejected(ctx: Context) -> anyhow::Result<()> { + 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 = device(&ctx.target, &identity.device_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_device_metadata_and_certificates_unchanged(&ctx.target, &identity.device_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<()> { + 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 = device(&ctx.target, &identity.device_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_device_metadata_and_certificates_unchanged(&ctx.target, &identity.device_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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&promoted, &old_thumb, "retired") + && has_certificate(&promoted, &identity.thumbprint, "current"), + "dropped confirm did not commit the promotion" + ); + let events = ctx.target.events(&identity.device_id).await?; + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let requests = ctx.target.requests(None).await?; + ensure!( + device(&ctx.target, &identity.device_id).await?["certificates"] == promoted["certificates"] + && ctx.target.events(&identity.device_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<()> { + 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 = device(&ctx.target, &identity.device_id).await?; + let pending_certs = pending["certificates"] + .as_array() + .context("missing certificates")? + .iter() + .filter(|cert| cert["status"] == "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<()> { + 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.devices_for(&token, "").await?; + expect_status(&listing, 200)?; + ensure!( + count(&listing.body, "totalCount")? == 1 + && count(&ctx.target.token_record(&token.id).await?.body, "usedCount")? == 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["device_id"] == listing.body["data"][0]["id"] + && count(&ctx.target.token_record(&token.id).await?.body, "usedCount")? == 1, + "replay created a second device or consumed another use" + ); + ctx.target.revoke(field(&replay.body, "device_id")?).await?; + expect_error( + &ctx.target.enroll(&token.text, &key, &json!({})).await?, + 403, + "device_revoked", + )?; + ensure!( + count(&ctx.target.requests(Some(&token)).await?, "enroll_device_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<()> { + 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(¤t, &first_thumb, "retired"), + "first pending certificate was not retired" + ); + ensure!( + has_certificate(¤t, &second_thumb, "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, + "device_unknown", + )?; + Ok(()) +} + +pub(crate) async fn p_retired_pending_certificate_cannot_connect(ctx: Context) -> anyhow::Result<()> { + 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_status(status, Code::Unauthenticated, "device_unknown")?; + Ok(()) +} + +pub(crate) async fn p_renew_within_grace(ctx: Context) -> anyhow::Result<()> { + 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.device_id, + &key, + 4, + )?; + Ok(()) +} + +pub(crate) async fn p_renew_beyond_grace(ctx: Context) -> anyhow::Result<()> { + 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<()> { + let (_, identity) = issued(&ctx, "revoke").await?; + ctx.target.revoke(&identity.device_id).await?; + let before = device(&ctx.target, &identity.device_id).await?; + let reply = ctx.target.renew(&identity, &KeyPair::generate()?, &json!({})).await?; + expect_error(&reply, 403, "device_revoked")?; + expect_error(&ctx.target.confirm(&identity).await?, 403, "device_revoked")?; + expect_error( + &ctx.target + .check_in(&identity, &json!({ "hostname": "should-not-update" })) + .await?, + 403, + "device_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, + "device_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, + "device_revoked", + )?; + ensure!( + device(&ctx.target, &identity.device_id).await? == before, + "revoked renew, confirm or check-in changed device state" + ); + Ok(()) +} + +pub(crate) async fn p_revocation_blocks_connect(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "revoked-connect").await?; + ctx.target.revoke(&identity.device_id).await?; + let status = channel::open(&ctx.target, &identity) + .await + .err() + .context("revoked cert connected")?; + channel::expect_status( + status + .downcast_ref::() + .context("revoked open did not yield gRPC status")?, + Code::PermissionDenied, + "device_revoked", + )?; + Ok(()) +} + +pub(crate) async fn p_delete_only_when_revoked_then_unknown(ctx: Context) -> anyhow::Result<()> { + let (token, identity) = issued(&ctx, "delete").await?; + let path = format!("/devices/{}", identity.device_id); + expect_status(&ctx.target.admin(Method::DELETE, &path, None).await?, 409)?; + ctx.target.revoke(&identity.device_id).await?; + expect_status(&ctx.target.admin(Method::DELETE, &path, None).await?, 204)?; + let reply = ctx.target.renew(&identity, &KeyPair::generate()?, &json!({})).await?; + expect_error(&reply, 401, "device_unknown")?; + expect_error(&ctx.target.confirm(&identity).await?, 401, "device_unknown")?; + expect_error( + &ctx.target + .check_in(&identity, &json!({ "hostname": "should-not-update" })) + .await?, + 401, + "device_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, + "device_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, + "device_unknown", + )?; + ensure!( + ctx.target.device(&identity.device_id).await?.status == 404 + && count(&ctx.target.devices_for(&token, "").await?.body, "totalCount")? == 0 + && count(&ctx.target.token_record(&token.id).await?.body, "usedCount")? == 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<()> { + let (_, identity) = issued(&ctx, "deleted-connect").await?; + ctx.target.revoke(&identity.device_id).await?; + expect_status( + &ctx.target + .admin(Method::DELETE, &format!("/devices/{}", identity.device_id), None) + .await?, + 204, + )?; + let status = channel::open(&ctx.target, &identity) + .await + .err() + .context("deleted cert connected")?; + channel::expect_status( + status + .downcast_ref::() + .context("deleted open did not yield gRPC status")?, + Code::Unauthenticated, + "device_unknown", + )?; + Ok(()) +} + +pub(crate) async fn p_channel_hello_updates_metadata_and_connected(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "initial").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + let challenge = stream.challenge().await?; + let before = device(&ctx.target, &identity.device_id).await?; + ensure!(before["connected"] == false, "device connected before Hello"); + ensure!( + before["metadata"]["hostname"] == "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 = device(&ctx.target, &identity.device_id).await?; + ensure!(after["connected"] == true, "device not connected after Hello"); + ensure!( + after["metadata"]["hostname"] == "channel" && after["metadata"]["arch"] == "x86_64", + "Hello metadata not stored" + ); + let after_time = time::OffsetDateTime::parse(field(&after, "lastSeenAt")?, &Rfc3339)?.unix_timestamp(); + if let Some(before_time) = before["lastSeenAt"].as_str() { + ensure!( + after_time > time::OffsetDateTime::parse(before_time, &Rfc3339)?.unix_timestamp(), + "Hello did not advance lastSeenAt" + ); + } + ensure!( + server_time.seconds.abs_diff(after_time) <= 2, + "Welcome server_time disagrees with lastSeenAt" + ); + Ok(()) +} + +pub(crate) async fn p_channel_agent_message_refreshes_last_seen(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "agent-message").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + let before = device(&ctx.target, &identity.device_id).await?; + let seen_before = last_seen_at(&before)?.context("authenticated Hello omitted lastSeenAt")?; + next_last_seen_second(&ctx).await?; + expect_status( + &ctx.target + .admin( + Method::POST, + &format!("/devices/{}/request-renewal", identity.device_id), + None, + ) + .await?, + 202, + )?; + 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!( + last_seen_at(&device(&ctx.target, &identity.device_id).await?)?.context("admin request omitted lastSeenAt")? + == seen_before, + "admin request or server push refreshed lastSeenAt before AgentMessage" + ); + stream.ack(&request).await?; + let started = Instant::now(); + loop { + let after = device(&ctx.target, &identity.device_id).await?; + if seen_before < last_seen_at(&after)?.context("AgentMessage omitted lastSeenAt")? { + break; + } + ensure!( + started.elapsed() < Duration::from_secs(5), + "authenticated AgentMessage did not refresh lastSeenAt" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + Ok(()) +} + +pub(crate) async fn p_handshake_barrier_holds_authentication(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "barrier").await?; + let before = device(&ctx.target, &identity.device_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.device_id) + .await? + .iter() + .any(|event| { event["type"] == "stream_opened" && event["stream_id"] == paused[0] }), + "mock handshake did not identify the held stream" + ); + let waiting = device(&ctx.target, &identity.device_id).await?; + ensure!( + waiting["connected"] == false + && waiting["metadata"] == before["metadata"] + && waiting["lastSeenAt"] == before["lastSeenAt"] + && ctx + .target + .events(&identity.device_id) + .await? + .iter() + .all(|event| event["type"] != "stream_authenticated"), + "held Hello authenticated or changed device 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!( + device(&ctx.target, &identity.device_id).await?["metadata"]["hostname"] == "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<()> { + 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 = device(&ctx.target, &identity.device_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 + .sender + .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 = device(&ctx.target, &identity.device_id).await?; + ensure!(record["connected"] == false, "{variant} connected the device"); + ensure!( + record["lastSeenAt"] == initial["lastSeenAt"], + "{variant} updated lastSeenAt" + ); + 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_status( + replayed + .downcast_ref::() + .context("missing replay gRPC status")?, + Code::Unauthenticated, + "signature_invalid", + )?; + Ok(()) +} + +pub(crate) async fn p_channel_revoked_before_hello(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "before-revocation").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + let challenge = stream.challenge().await?; + let before = device(&ctx.target, &identity.device_id).await?; + ensure!(before["connected"] == false, "device connected before Hello"); + let authenticated = if ctx.mock() { + Some(count(&ctx.target.requests(None).await?, "authenticated_connects")?) + } else { + None + }; + ctx.target.revoke(&identity.device_id).await?; + let revoked = device(&ctx.target, &identity.device_id).await?; + stream + .send_hello(&identity, &challenge, &[("hostname", "after-revocation")], None) + .await?; + let rejected = stream.closing_status(Duration::from_secs(3)).await?; + channel::expect_status(&rejected, Code::PermissionDenied, "device_revoked")?; + let after = device(&ctx.target, &identity.device_id).await?; + ensure!( + after["connected"] == false + && after["metadata"] == revoked["metadata"] + && after["certificates"] == revoked["certificates"] + && after["lastSeenAt"] == before["lastSeenAt"], + "revoked stream changed device 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(()) +} + +pub(crate) async fn p_channel_no_hello_timeout(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "no-hello").await?; + let initial = device(&ctx.target, &identity.device_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 = device(&ctx.target, &identity.device_id).await?; + ensure!(record["connected"] == false, "device connected without Hello"); + ensure!( + record["lastSeenAt"] == initial["lastSeenAt"], + "lastSeenAt updated without Hello" + ); + Ok(()) +} + +pub(crate) async fn p_channel_unavailable_no_channel_url(ctx: Context) -> anyhow::Result<()> { + 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<()> { + 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<()> { + 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 && count(&initial, "revision")? == 1, + "enrollment did not start at config revision 1" + ); + 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?; + ensure!( + count(&disabled, "revision")? == 2 + && disabled.get("agent_channel_url").is_none() + && count(&check_in_config(&ctx.target, &other).await?, "revision")? == 2, + "disabling the channel did not raise every device'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")? == 3 && 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["updatedDevices"] == 2, + "config change did not update both devices" + ); + let extended = check_in_config(&ctx.target, &identity).await?; + ensure!( + count(&extended, "revision")? == 4 && 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["updatedDevices"] == 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 device" + ); + 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")? == 5 + && final_config["conformance_flag"] == false + && count(&check_in_config(&ctx.target, &other).await?, "revision")? == 5, + "replacing a field did not advance both device 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!({ "device_id": identity.device_id })) + .await?, + 202, + )?; + ensure!( + check_in_config(&ctx.target, &identity).await? == final_config, + "renew, confirm or reconnect changed an unaffected config revision" + ); + Ok(()) +} + +pub(crate) async fn p_config_hello_reconciles_stale_revision(ctx: Context) -> anyhow::Result<()> { + if !ctx.mock() { + return Err(crate::Incomplete("DVLS has no config-change fixture for a stale Hello").into()); + } + let (_, mut identity) = issued(&ctx, "stale-config").await?; + 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; + } + let current_revision = count(&identity.config, "revision")?; + let announced_revision = current_revision - 1; + 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.device_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<()> { + 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?; + ensure!( + count(&config, "revision")? == count(&identity.config, "revision")? + 1 + && config["conformance_note"] == "updated" + && config == check_in_config(&ctx.target, &identity).await?, + "config edit did not push the next effective revision" + ); + stream.ack(&message).await?; + let stale = ctx + .target + .control( + "config/stale", + &json!({ "device_id": identity.device_id, "revision": 1 }), + ) + .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")? == 1 + && 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?; + ensure!( + count(&config, "revision")? == count(&identity.config, "revision")? + 2 + && 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.device_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 == [2, 3] && 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<()> { + let (_, identity) = issued(&ctx, "push").await?; + let mut connected = channel::open(&ctx.target, &identity).await?; + connected.hello(&identity, &[("hostname", "push")]).await?; + expect_status( + &ctx.target + .admin( + Method::POST, + &format!("/devices/{}/request-renewal", identity.device_id), + None, + ) + .await?, + 202, + )?; + 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!( + device(&ctx.target, &identity.device_id).await?["renewalRequested"] == true, + "request flag cleared too early" + ); + Ok(()) +} + +pub(crate) async fn p_reconnect_push_and_handoff(ctx: Context) -> anyhow::Result<()> { + 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!({ "device_id": identity.device_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!( + device(&ctx.target, &identity.device_id).await?["connected"] == true, + "replacement did not preserve connection" + ); + let events = ctx.target.events(&identity.device_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<()> { + let (_, mut identity) = issued(&ctx, "offline-flag").await?; + expect_status( + &ctx.target + .admin( + Method::POST, + &format!("/devices/{}/request-renewal", identity.device_id), + None, + ) + .await?, + 202, + )?; + ensure!( + device(&ctx.target, &identity.device_id).await?["renewalRequested"] == true, + "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!( + device(&ctx.target, &identity.device_id).await?["renewalRequested"] == true, + "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!( + device(&ctx.target, &identity.device_id).await?["renewalRequested"] == true, + "pending-signed renew cleared the request flag" + ); + expect_status(&ctx.target.confirm(&identity).await?, 204)?; + let current = device(&ctx.target, &identity.device_id).await?; + ensure!( + current["renewalRequested"] == false && has_certificate(¤t, &identity.thumbprint, "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<()> { + let (_, mut identity) = issued(&ctx, "clear-flag").await?; + let mut old = channel::open(&ctx.target, &identity).await?; + old.hello(&identity, &[]).await?; + expect_status( + &ctx.target + .admin( + Method::POST, + &format!("/devices/{}/request-renewal", identity.device_id), + None, + ) + .await?, + 202, + )?; + 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + pending["renewalRequested"] == true && has_certificate(&pending, &identity.thumbprint, "pending"), + "renewal request cleared before confirmation" + ); + let mut new_stream = channel::open(&ctx.target, &identity).await?; + let challenge = new_stream.challenge().await?; + ensure!( + device(&ctx.target, &identity.device_id).await?["renewalRequested"] == true, + "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!( + device(&ctx.target, &identity.device_id).await?["renewalRequested"] == true, + "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!( + device(&ctx.target, &identity.device_id).await?["renewalRequested"] == false, + "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<()> { + let (_, identity) = issued(&ctx, "live-revoke").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + ctx.target.revoke(&identity.device_id).await?; + let status = stream.closing_status(Duration::from_secs(3)).await?; + channel::expect_status(&status, Code::PermissionDenied, "device_revoked")?; + ensure!( + device(&ctx.target, &identity.device_id).await?["connected"] == false, + "revoked stream still connected" + ); + Ok(()) +} + +pub(crate) async fn p_stream_closes_at_not_after(ctx: Context) -> anyhow::Result<()> { + if ctx.mock() { + ctx.target.faults(&json!({ "leaf_lifetime_secs": 20 })).await?; + } + let (_, identity) = issued(&ctx, "expires").await?; + let not_after = time::OffsetDateTime::parse( + field( + &device(&ctx.target, &identity.device_id).await?["certificate"], + "notAfter", + )?, + &Rfc3339, + )? + .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!( + device(&ctx.target, &identity.device_id).await?["connected"] == true, + "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.device_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!( + device(&ctx.target, &identity.device_id).await?["connected"] == false, + "expired stream still connected" + ); + Ok(()) +} + +pub(crate) async fn p_expired_certificate_refused_at_stream_open(ctx: Context) -> anyhow::Result<()> { + ctx.target.faults(&json!({ "leaf_lifetime_secs": 20 })).await?; + let (_, identity) = issued(&ctx, "expired-open").await?; + let before = device(&ctx.target, &identity.device_id).await?; + let not_after = time::OffsetDateTime::parse(field(&before["certificate"], "notAfter")?, &Rfc3339)?.unix_timestamp(); + ctx.target + .advance(not_after - chain_evaluation_time(&ctx).await?) + .await?; + let failure = channel::open(&ctx.target, &identity) + .await + .err() + .context("expired certificate opened a stream")?; + channel::expect_status( + failure.downcast_ref::().context("missing gRPC status")?, + Code::Unauthenticated, + "certificate_expired", + )?; + let after = device(&ctx.target, &identity.device_id).await?; + ensure!( + after["connected"] == false + && after["metadata"] == before["metadata"] + && after["lastSeenAt"] == before["lastSeenAt"] + && ctx + .target + .events(&identity.device_id) + .await? + .iter() + .all(|event| event["type"] != "stream_authenticated"), + "expired opening authenticated a stream or changed device state" + ); + Ok(()) +} + +pub(crate) async fn p_challenged_stream_expires_on_advance(ctx: Context) -> anyhow::Result<()> { + ctx.target.faults(&json!({ "leaf_lifetime_secs": 20 })).await?; + let (_, identity) = issued(&ctx, "challenge-expiry").await?; + let before = device(&ctx.target, &identity.device_id).await?; + let not_after = time::OffsetDateTime::parse(field(&before["certificate"], "notAfter")?, &Rfc3339)?.unix_timestamp(); + let mut stream = channel::open(&ctx.target, &identity).await?; + let _ = stream.challenge().await?; + ensure!( + device(&ctx.target, &identity.device_id).await?["connected"] == false, + "Challenge alone authenticated a device" + ); + 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")?; + let after = device(&ctx.target, &identity.device_id).await?; + ensure!( + after["connected"] == false + && after["metadata"] == before["metadata"] + && after["lastSeenAt"] == before["lastSeenAt"], + "expired unproven stream changed device state" + ); + let events = ctx.target.events(&identity.device_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<()> { + 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.device_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.device_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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&before, &old_thumb, "current") + && has_certificate(&before, &identity.thumbprint, "pending") + && before["connected"] == true, + "renewal promoted the pending certificate before confirmation" + ); + let mut unauthenticated = channel::open(&ctx.target, &identity).await?; + let challenge = unauthenticated.challenge().await?; + let opened = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&opened, &old_thumb, "current") && has_certificate(&opened, &identity.thumbprint, "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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&rejected, &old_thumb, "current") + && has_certificate(&rejected, &identity.thumbprint, "pending") + && rejected["connected"] == true, + "bad proof promoted the pending certificate" + ); + ensure!( + rejected["metadata"] == before["metadata"], + "bad proof changed device 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&after_hello, &old_thumb, "current") + && has_certificate(&after_hello, &identity.thumbprint, "pending") + && after_hello["connected"] == true, + "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.device_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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&confirmed, &old_thumb, "retired") + && has_certificate(&confirmed, &identity.thumbprint, "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<()> { + 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&record, &old_thumb, "retired") + && has_certificate(&record, &identity.thumbprint, "current") + && record["connected"] == true, + "confirm did not promote while keeping the replacement stream connected" + ); + let events = ctx.target.events(&identity.device_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.device_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.device_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")? + && device(&ctx.target, &identity.device_id).await?["connected"] == true, + "retired stream closed before reconnect or replacement authentication" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_publishes_both_roots_and_issues_from_new(ctx: Context) -> anyhow::Result<()> { + 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?; + expect_status(&begin_rotation(&ctx).await?, 202)?; + 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + record["certificate"]["issuer"] == new_root, + "device issuer did not switch to new root" + ); + finish_rotation(&ctx).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_status_counts(ctx: Context) -> anyhow::Result<()> { + let (_, mut a) = issued(&ctx, "old-a").await?; + let (_, b) = issued(&ctx, "old-b").await?; + let old_root = field(&device(&ctx.target, &a.device_id).await?["certificate"], "issuer")?.to_owned(); + let expected = active_old_root_ids(&ctx, &old_root).await?; + ensure!( + expected.contains(&a.device_id) && expected.contains(&b.device_id), + "new devices are missing from the full old-root listing" + ); + let started = begin_rotation(&ctx).await?; + expect_status(&started, 202)?; + ensure!(started.body["phase"] == "rotating", "rotation not in progress"); + ensure!( + count(&started.body, "activeDevicesOnOldRoot")? == expected.len() as u64, + "rotation did not count all active old-root devices" + ); + ensure!( + device(&ctx.target, &a.device_id).await?["renewalRequested"] == true + && device(&ctx.target, &b.device_id).await?["renewalRequested"] == true, + "rotation did not flag offline old-root devices" + ); + 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!( + device(&ctx.target, &a.device_id).await?["renewalRequested"] == false, + "confirm did not clear the rotation flag" + ); + let status = ctx.target.admin(Method::GET, "/ca/rotation", None).await?; + ensure!( + count(&status.body, "activeDevicesOnOldRoot")? == active_old_root_ids(&ctx, &old_root).await?.len() as u64, + "migration old-root count disagrees with all listing pages" + ); + ctx.target.revoke(&b.device_id).await?; + let status = ctx.target.admin(Method::GET, "/ca/rotation", None).await?; + ensure!( + count(&status.body, "activeDevicesOnOldRoot")? == active_old_root_ids(&ctx, &old_root).await?.len() as u64, + "revoked device remained in the old-root count" + ); + finish_rotation(&ctx).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_conflict_409(ctx: Context) -> anyhow::Result<()> { + let _ = issued(&ctx, "rotation").await?; + expect_status(&begin_rotation(&ctx).await?, 202)?; + expect_status(&ctx.target.rotate(None).await?, 409)?; + finish_rotation(&ctx).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_completes_early_when_last_device_migrates(ctx: Context) -> anyhow::Result<()> { + 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, + "devices were not enrolled under the same old root" + ); + + let started = ctx.target.rotate(None).await?; + expect_status(&started, 202)?; + ensure!( + started.body["phase"] == "rotating" && count(&started.body, "activeDevicesOnOldRoot")? == 2, + "rotation did not start with both old-root devices" + ); + let deadline = time::OffsetDateTime::parse(field(&started.body, "deadline")?, &Rfc3339)?.unix_timestamp(); + ensure!( + deadline - chain_evaluation_time(&ctx).await? > 60, + "rotation deadline is not far enough away to test early completion" + ); + let new_root = field(&started.body["newRoot"], "thumbprint")?.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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&pending, &identity.thumbprint, "pending") && pending["certificate"]["issuer"] == old_root, + "renewal migrated the device before confirmation" + ); + let before_auth = ctx.target.admin(Method::GET, "/ca/rotation", None).await?; + ensure!( + before_auth.body["phase"] == "rotating" + && count(&before_auth.body, "activeDevicesOnOldRoot")? == remaining + 1, + "rotation completed before the old-root certificate was replaced" + ); + + expect_status(&ctx.target.confirm(identity).await?, 204)?; + let migrated = device(&ctx.target, &identity.device_id).await?; + ensure!( + has_certificate(&migrated, &identity.thumbprint, "current") + && migrated["certificate"]["issuer"] == new_root, + "confirm did not migrate the device to the new root" + ); + let status = ctx.target.admin(Method::GET, "/ca/rotation", None).await?; + let roots = ctx.target.trust_anchor().await?; + let published = roots["roots"].as_array().context("published roots")?; + ensure!( + count(&status.body, "activeDevicesOnOldRoot")? == remaining, + "migration did not update the old-root device count" + ); + if remaining == 1 { + ensure!( + status.body["phase"] == "rotating" + && published.len() == 2 + && published.iter().any(|root| root["thumbprint"] == old_root), + "rotation stopped while an old-root device was still active" + ); + } else { + ensure!( + status.body["phase"] == "idle" + && 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_devices(ctx: Context) -> anyhow::Result<()> { + 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.rotate(None).await?; + expect_status(&started, 202)?; + ensure!( + started.body["phase"] == "idle" + && count(&started.body, "activeDevicesOnOldRoot")? == 0 + && started.body.get("deadline").is_none() + && started.body.get("oldRoot").is_none() + && started.body.get("newRoot").is_none(), + "rotation without old-root devices did not complete in the POST response" + ); + let status = ctx.target.admin(Method::GET, "/ca/rotation", None).await?; + expect_status(&status, 200)?; + ensure!( + rotation_snapshot(&status.body)? == rotation_snapshot(&started.body)?, + "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 devices did not replace the published root" + ); + assert_root(&published[0])?; + Ok(()) +} + +pub(crate) async fn p_rotation_deadline_bound(ctx: Context) -> anyhow::Result<()> { + 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.revoke(&revoked.device_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 = device(&ctx.target, &expired.device_id).await?; + ensure!( + expired_record["status"] == "expired", + "fixture certificate did not expire" + ); + let pending_record = device(&ctx.target, &pending_only.device_id).await?; + ensure!( + pending_record["status"] == "expired" + && pending_record["certificates"] + .as_array() + .is_some_and(|certs| certs.iter().any(|cert| cert["status"] == "pending")), + "fixture lacks a live pending old-root certificate with an expired current certificate" + ); + let both_record = device(&ctx.target, &both.device_id).await?; + ensure!( + both_record["status"] == "active" + && both_record["certificates"] + .as_array() + .is_some_and(|certs| certs.iter().any(|cert| cert["status"] == "pending")), + "fixture lacks both current and pending old-root certificates" + ); + let pending_cert = pending_record["certificates"] + .as_array() + .context("pending device certificates")? + .iter() + .find(|cert| cert["status"] == "pending") + .context("pending old-root certificate")?; + let maximum = time::OffsetDateTime::parse(field(pending_cert, "notAfter")?, &Rfc3339)?; + ensure!( + time::OffsetDateTime::parse( + field( + &device(&ctx.target, &active.device_id).await?["certificate"], + "notAfter" + )?, + &Rfc3339 + )? < maximum, + "pending certificate does not determine the rotation maximum" + ); + let too_late = (maximum + time::Duration::seconds(1)).format(&Rfc3339)?; + expect_status(&ctx.target.rotate(Some(&too_late)).await?, 400)?; + ensure!( + ctx.target.admin(Method::GET, "/ca/rotation", None).await?.body["phase"] == "idle" + && 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.rotate(None).await?; + expect_status(&started, 202)?; + ensure!( + started.body["deadline"] == maximum.format(&Rfc3339)? + && count(&started.body, "activeDevicesOnOldRoot")? == 3 + && active_old_root_ids(&ctx, field(&started.body["oldRoot"], "thumbprint")?) + .await? + .len() + == 3, + "rotation maximum or device count excluded a pending certificate or counted one device twice" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_disposable_deadline_above_own_max_rejected(ctx: Context) -> anyhow::Result<()> { + 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.device_id, &second.device_id]).await { + for id in [&first.device_id, &second.device_id] { + let _ = ctx.target.revoke(id).await; + } + return Err(error); + } + let now = chain_evaluation_time(&ctx).await?; + let mut candidate_dates = Vec::new(); + for id in [&first.device_id, &second.device_id] { + let record = device(&ctx.target, id).await?; + for cert in record["certificates"] + .as_array() + .context("own certificates are missing")? + { + let not_after = time::OffsetDateTime::parse(field(cert, "notAfter")?, &Rfc3339)?; + if matches!(field(cert, "status")?, "current" | "pending") + && field(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 = (maximum + time::Duration::days(1)).format(&Rfc3339)?; + expect_status(&ctx.target.rotate(Some(&too_late)).await?, 400)?; + let status = ctx.target.admin(Method::GET, "/ca/rotation", None).await?; + expect_status(&status, 200)?; + ensure!( + status.body["phase"] == "idle" + && 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.device_id, &second.device_id] { + ctx.target.revoke(id).await?; + } + Ok(()) +} + +pub(crate) async fn p_rotation_disposable_completes_before_future_deadline(ctx: Context) -> anyhow::Result<()> { + 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 = device(&ctx.target, &identity.device_id).await?; + ensure!( + field(&original["certificate"], "issuer")? == old_root, + "disposable fixture enrolled under a different root" + ); + if let Err(error) = isolated_old_root_fixture(&ctx, &old_root, &[&identity.device_id]).await { + let _ = ctx.target.revoke(&identity.device_id).await; + return Err(error); + } + let maximum = time::OffsetDateTime::parse(field(&original["certificate"], "notAfter")?, &Rfc3339)?; + 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.rotate(Some(&deadline.format(&Rfc3339)?)).await?; + expect_status(&started, 202)?; + ensure!( + started.body["phase"] == "rotating" && count(&started.body, "activeDevicesOnOldRoot")? == 1, + "disposable target does not have exactly one active old-root device" + ); + let new_root = field(&started.body["newRoot"], "thumbprint")?.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.admin(Method::GET, "/ca/rotation", None).await?; + expect_status(&status, 200)?; + let published = ctx.target.trust_anchor().await?; + ensure!( + old_root != new_root + && status.body["phase"] == "idle" + && count(&status.body, "activeDevicesOnOldRoot")? == 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) + && device(&ctx.target, &identity.device_id).await?["certificate"]["issuer"] == new_root, + "rotation did not complete when the last old-root device migrated before its deadline" + ); + ctx.target.revoke(&identity.device_id).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_grace_renewal_after_early_completion(ctx: Context) -> anyhow::Result<()> { + 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 = time::OffsetDateTime::parse( + field( + &device(&ctx.target, &expired.device_id).await?["certificate"], + "notAfter", + )?, + &Rfc3339, + )? + .unix_timestamp(); + ctx.target.faults(&json!({ "leaf_lifetime_secs": 600 })).await?; + let (_, revoked) = issued(&ctx, "long-lived-old-root").await?; + + let started = ctx.target.rotate(None).await?; + expect_status(&started, 202)?; + ensure!( + started.body["phase"] == "rotating" && count(&started.body, "activeDevicesOnOldRoot")? == 2, + "rotation did not count both old-root devices" + ); + let deadline = time::OffsetDateTime::parse(field(&started.body, "deadline")?, &Rfc3339)?.unix_timestamp(); + let new_root = field(&started.body["newRoot"], "thumbprint")?; + ctx.target.revoke(&revoked.device_id).await?; + let remaining = ctx.target.admin(Method::GET, "/ca/rotation", None).await?; + ensure!( + remaining.body["phase"] == "rotating" + && count(&remaining.body, "activeDevicesOnOldRoot")? == 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" + ); + ctx.target.advance(until_expiry).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.admin(Method::GET, "/ca/rotation", None).await?; + let roots = ctx.target.trust_anchor().await?; + ensure!( + completed.body["phase"] == "idle" + && count(&completed.body, "activeDevicesOnOldRoot")? == 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" + ); + ensure!( + device(&ctx.target, &expired.device_id).await?["status"] == "expired", + "old-root certificate was not expired before grace renewal" + ); + + 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.admin(Method::GET, "/ca/rotation", None).await?.body["phase"] == "idle", + "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<()> { + ctx.target.faults(&json!({ "leaf_lifetime_secs": 30 })).await?; + let (_, short) = issued(&ctx, "freeze-short-old-root").await?; + let expiry = time::OffsetDateTime::parse( + field(&device(&ctx.target, &short.device_id).await?["certificate"], "notAfter")?, + &Rfc3339, + )? + .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.rotate(None).await?; + expect_status(&started, 202)?; + ensure!( + started.body["phase"] == "rotating" && count(&started.body, "activeDevicesOnOldRoot")? == 2, + "rotation did not start with two active old-root certificates" + ); + let deadline = time::OffsetDateTime::parse(field(&started.body, "deadline")?, &Rfc3339)?.unix_timestamp(); + ctx.target.revoke(&long.device_id).await?; + + let before = ctx.target.control("time/freeze", &json!({ "now": expiry - 1 })).await?; + expect_status(&before, 200)?; + ensure!( + before.body["published_roots"] + .as_array() + .is_some_and(|roots| roots.len() == 2) + && ctx.target.admin(Method::GET, "/ca/rotation", None).await?.body["activeDevicesOnOldRoot"] == 1, + "old root was removed before the last certificate expired" + ); + let at_expiry = ctx.target.control("time/freeze", &json!({ "now": expiry })).await?; + expect_status(&at_expiry, 200)?; + ensure!( + expiry < deadline + && at_expiry.body["published_roots"] + .as_array() + .is_some_and(|roots| roots.len() == 1 && roots[0] != old_root) + && ctx.target.admin(Method::GET, "/ca/rotation", None).await?.body["phase"] == "idle", + "freezing the clock at certificate expiry did not complete the rotation immediately" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_push_rate_survives_deadline(ctx: Context) -> anyhow::Result<()> { + 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(); + expect_status(&ctx.target.rotate(Some("now")).await?, 202)?; + 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<()> { + 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)?.format(&Rfc3339)?; + ctx.target.rotate(Some(&deadline)).await? + } else { + begin_rotation(&ctx).await? + }; + expect_status(&started, 202)?; + let deadline = time::OffsetDateTime::parse(field(&started.body, "deadline")?, &Rfc3339)?.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<()> { + let (_, old) = issued(&ctx, "still-allowed").await?; + let original_root = thumbprint(old.certificate_chain.last().context("missing old root")?)?; + expect_status(&ctx.target.rotate(Some("now")).await?, 202)?; + 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<()> { + 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_device = device(&ctx.target, &identity.device_id).await?; + ensure!( + old_device["status"] == "active" + && old_device["connected"] == false + && old_device["certificate"]["issuer"] == old_thumb + && has_certificate(&old_device, &identity.thumbprint, "current"), + "emergency rotation fixture lacks an active, unconnected old-root device" + ); + expect_status(&ctx.target.rotate(Some("now")).await?, 202)?; + 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 device was active" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let remaining = device(&ctx.target, &identity.device_id).await?; + ensure!( + remaining["status"] == "active" + && remaining["connected"] == false + && remaining["certificate"]["issuer"] == old_thumb + && has_certificate(&remaining, &identity.thumbprint, "current"), + "emergency rotation changed the old-root device rather than only publishing the new root" + ); + Ok(()) +} + +pub(crate) async fn p_rotation_request_renewal_pushed(ctx: Context) -> anyhow::Result<()> { + let (_, identity) = issued(&ctx, "rotation-push").await?; + let mut stream = channel::open(&ctx.target, &identity).await?; + stream.hello(&identity, &[]).await?; + expect_status(&begin_rotation(&ctx).await?, 202)?; + 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<()> { + 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 = time::OffsetDateTime::parse( + field( + &device(&ctx.target, &identity.device_id).await?["certificate"], + "notAfter", + )?, + &Rfc3339, + )? + .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"); + ctx.target.advance(until_expiry).await?; + let record = device(&ctx.target, &identity.device_id).await?; + ensure!( + record["status"] == "expired" + && record["connected"] == true + && has_certificate(&record, &identity.thumbprint, "pending"), + "pending-only old-root stream was not connected after current expiry" + ); + let started = ctx.target.rotate(None).await?; + expect_status(&started, 202)?; + ensure!( + started.body["phase"] == "rotating" + && count(&started.body, "activeDevicesOnOldRoot")? == 1 + && ctx.target.trust_anchor().await?["roots"] + .as_array() + .is_some_and(|roots| roots.len() == 2), + "rotation omitted the pending-only old-root device" + ); + 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!( + device(&ctx.target, &identity.device_id).await?["renewalRequested"] == true, + "rotation did not persist the request-renewal flag for the pending-only device" + ); + Ok(()) +} + +pub(crate) async fn p_listing_pagination_stable_during_concurrent_enrollment(ctx: Context) -> anyhow::Result<()> { + let token = token(&ctx, 15).await?; + let mut expected = Vec::new(); + for n in 0..4 { + expected.push(identity(&ctx.target, &token, &format!("initial-{n}")).await?.device_id); + } + let first = ctx + .target + .devices_for(&token, "&pageSize=2&pageNumber=1&view=full") + .await?; + expect_status(&first, 200)?; + let first_ids = first.body["data"] + .as_array() + .context("first page")? + .iter() + .map(|row| field(row, "id").map(str::to_owned)) + .collect::>>()?; + ensure!(first_ids == expected[..2], "initial page is not in creation order"); + let mut seen = first_ids.clone(); + let mut timestamps = first.body["data"] + .as_array() + .context("first page")? + .iter() + .map(|row| field(row, "createdAt").map(str::to_owned)) + .collect::>>()?; + for page in 2..=5 { + if page <= 4 { + for n in 0..2 { + expected.push( + identity(&ctx.target, &token, &format!("later-{page}-{n}")) + .await? + .device_id, + ); + } + } + let repeat_first = ctx.target.devices_for(&token, "&pageSize=2&pageNumber=1").await?; + expect_status(&repeat_first, 200)?; + ensure!( + repeat_first.body["data"] + .as_array() + .context("repeated first page")? + .iter() + .map(|row| field(row, "id").map(str::to_owned)) + .collect::>>()? + == first_ids, + "new enrollments shifted the already-fetched first page" + ); + let next = ctx + .target + .devices_for(&token, &format!("&pageSize=2&pageNumber={page}&view=full")) + .await?; + expect_status(&next, 200)?; + ensure!( + count(&next.body, "totalCount")? == expected.len() as u64 && count(&next.body, "currentPage")? == page, + "page {page} has incorrect paging metadata" + ); + let rows = next.body["data"].as_array().context("page rows")?; + ensure!(rows.len() == 2, "page {page} has the wrong number of rows"); + for row in rows { + seen.push(field(row, "id")?.to_owned()); + timestamps.push(field(row, "createdAt")?.to_owned()); + } + } + ensure!( + seen == expected, + "page traversal omitted, duplicated or reordered device IDs" + ); + ensure!( + seen.iter().collect::>().len() == expected.len(), + "page traversal repeated a device ID" + ); + ensure!( + timestamps.windows(2).all(|pair| pair[0] <= pair[1]), + "createdAt decreased in creation order" + ); + let beyond = ctx.target.devices_for(&token, "&pageSize=2&pageNumber=6").await?; + expect_status(&beyond, 200)?; + ensure!( + beyond.body["data"].as_array().is_some_and(Vec::is_empty), + "page beyond the last page is not empty" + ); + Ok(()) +} + +pub(crate) async fn p_listing_views_filters_and_bounds(ctx: Context) -> anyhow::Result<()> { + 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!( + count(&ctx.target.devices_for(&decoy_token, "").await?.body, "totalCount")? == 1, + "decoy token device not listed" + ); + ensure!( + device(&ctx.target, &decoy.device_id).await?["id"] == decoy.device_id, + "decoy device missing" + ); + ctx.target.revoke(&revoked.device_id).await?; + let path = format!("/devices/{}", active.device_id); + let renamed = ctx + .target + .admin( + Method::PATCH, + &path, + Some(&json!({ "friendlyName": "Filter-Host-Renamed" })), + ) + .await?; + expect_status(&renamed, 200)?; + ensure!( + renamed.body["friendlyName"] == "Filter-Host-Renamed", + "device rename was not persisted" + ); + for invalid in ["".to_owned(), "x".repeat(256)] { + expect_status( + &ctx.target + .admin(Method::PATCH, &path, Some(&json!({ "friendlyName": invalid }))) + .await?, + 400, + )?; + } + let summary = ctx + .target + .devices_for(&token, "&view=summary&metadata=hostname,arch&status=active") + .await?; + expect_status(&summary, 200)?; + ensure!( + count(&summary.body, "totalCount")? == 1, + "active filter returned wrong count" + ); + let row = &summary.body["data"][0]; + ensure!(row["id"] == active.device_id, "active filter selected wrong device"); + ensure!( + row["friendlyName"] == "Filter-Host-Renamed", + "summary did not reflect renamed device" + ); + ensure!( + row["metadata"]["hostname"] == "filter-host", + "metadata subset missing hostname" + ); + ensure!( + row["metadata"]["arch"].is_null(), + "metadata subset included an absent key" + ); + ensure!(row["certificates"].is_null(), "summary included full certificates"); + let full = ctx.target.devices_for(&token, "&view=full&status=revoked").await?; + expect_status(&full, 200)?; + ensure!( + count(&full.body, "totalCount")? == 1, + "revoked filter returned wrong count" + ); + ensure!( + full.body["data"][0]["id"] == revoked.device_id, + "revoked filter selected wrong device" + ); + ensure!( + full.body["data"][0]["status"] == "revoked", + "revoked filter reported the wrong status" + ); + ensure!( + full.body["data"][0]["certificates"].is_array(), + "full view has no certificates" + ); + let by_name = ctx.target.devices_for(&token, "&q=FILTER-HOST").await?; + expect_status(&by_name, 200)?; + ensure!( + count(&by_name.body, "totalCount")? == 1 && by_name.body["data"][0]["id"] == active.device_id, + "friendly-name substring filter failed" + ); + let all_metadata = ctx + .target + .devices_for(&token, "&view=full&metadata=hostname&status=active") + .await?; + expect_status(&all_metadata, 200)?; + ensure!( + all_metadata.body["data"][0]["metadata"]["os_name"] == "Linux", + "full view filtered metadata despite requesting all fields" + ); + let issuer = field(&summary.body["data"][0]["certificate"], "issuer")?; + let by_issuer = ctx.target.devices_for(&token, &format!("&issuer={issuer}")).await?; + expect_status(&by_issuer, 200)?; + ensure!( + count(&by_issuer.body, "totalCount")? == 2 + && by_issuer.body["data"].as_array().is_some_and(|rows| { + rows.iter().map(|row| row["id"].as_str()).collect::>() + == [Some(active.device_id.as_str()), Some(revoked.device_id.as_str())] + }), + "issuer filter returned incorrect device IDs" + ); + let paged = ctx.target.devices_for(&token, "&pageSize=1&pageNumber=2").await?; + expect_status(&paged, 200)?; + ensure!( + count(&paged.body, "pageSize")? == 1 + && count(&paged.body, "currentPage")? == 2 + && count(&paged.body, "totalCount")? == 2 + && count(&paged.body, "totalPages")? == 2 + && paged.body["data"][0]["id"] == revoked.device_id, + "DVLS page fields incorrect" + ); + let beyond = ctx.target.devices_for(&token, "&pageSize=1&pageNumber=3").await?; + expect_status(&beyond, 200)?; + ensure!( + beyond.body["data"].as_array().is_some_and(Vec::is_empty), + "page beyond the last page was not empty" + ); + for query in ["&pageSize=0", "&pageSize=101", "&pageNumber=0"] { + expect_status(&ctx.target.devices_for(&token, query).await?, 400)?; + } + if ctx.mock() { + let huge = ctx + .target + .devices_for(&token, "&pageSize=100&pageNumber=18446744073709551615") + .await?; + expect_status(&huge, 200)?; + ensure!( + huge.body["data"].as_array().is_some_and(Vec::is_empty), + "huge pageNumber overflowed instead of returning an empty page" + ); + } + 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 = device(&ctx.target, &active.device_id).await?; + let instant = last_seen_at(&observed)?.context("check-in did not set lastSeenAt for filtering")?; + let after = (instant - time::Duration::seconds(1)).format(&Rfc3339)?; + let before = (instant + time::Duration::seconds(1)).format(&Rfc3339)?; + for (query, expected) in [ + (format!("&status=active&lastSeenAfter={after}"), true), + (format!("&status=active&lastSeenBefore={before}"), true), + (format!("&status=active&lastSeenAfter={before}"), false), + ] { + let result = ctx.target.devices_for(&token, &query).await?; + expect_status(&result, 200)?; + let rows = result.body["data"].as_array().context("lastSeen filter page")?; + ensure!( + count(&result.body, "totalCount")? == u64::from(expected) + && if expected { + rows.len() == 1 && rows[0]["id"] == active.device_id && rows[0]["status"] == "active" + } else { + rows.is_empty() + }, + "lastSeen filter returned the wrong device IDs or statuses for {query}" + ); + } + if ctx.mock() { + ctx.target.advance(91 * 24 * 3600).await?; + let expired = ctx.target.devices_for(&token, "&status=expired").await?; + expect_status(&expired, 200)?; + ensure!( + count(&expired.body, "totalCount")? == 1 + && expired.body["data"][0]["id"] == active.device_id + && expired.body["data"][0]["status"] == "expired", + "expired status filter selected wrong device ID or status" + ); + } + Ok(()) +} + +pub(crate) async fn p_admin_tokens_crud_and_states(ctx: Context) -> anyhow::Result<()> { + let token = token(&ctx, 1).await?; + let record = ctx.target.token_record(&token.id).await?; + expect_status(&record, 200)?; + ensure!( + record.body["state"] == "active" && count(&record.body, "usedCount")? == 0, + "new token not active" + ); + ensure!( + record.body["token"].is_null(), + "token secret leaked in its admin record" + ); + let first_page = ctx + .target + .admin(Method::GET, "/enrollment-tokens?pageNumber=1&pageSize=100", None) + .await?; + expect_status(&first_page, 200)?; + let pages = count(&first_page.body, "totalPages")?; + let mut found = first_page.body["data"] + .as_array() + .context("token page")? + .iter() + .any(|record| record["id"] == token.id); + for page in 2..=pages { + if found { + break; + } + let listed = ctx + .target + .admin( + Method::GET, + &format!("/enrollment-tokens?pageNumber={page}&pageSize=100"), + None, + ) + .await?; + expect_status(&listed, 200)?; + found = listed.body["data"] + .as_array() + .context("token page")? + .iter() + .any(|record| record["id"] == token.id); + } + ensure!(found, "created token not listed"); + let _ = identity(&ctx.target, &token, "exhaust").await?; + let record = ctx.target.token_record(&token.id).await?; + ensure!( + record.body["state"] == "exhausted" && count(&record.body, "usedCount")? == 1, + "exhausted state incorrect" + ); + if ctx.mock() { + let expiring = ctx.target.create_token(1, Duration::from_secs(2), None).await?; + ctx.target.advance(4).await?; + ensure!( + ctx.target.token_record(&expiring.id).await?.body["state"] == "expired", + "expired token did not enter expired state" + ); + } + expect_status( + &ctx.target + .admin(Method::DELETE, &format!("/enrollment-tokens/{}", token.id), None) + .await?, + 204, + )?; + expect_status(&ctx.target.token_record(&token.id).await?, 404)?; + let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(1)).format(&Rfc3339)?; + for uses in [0, 1_000_001] { + let invalid = ctx + .target + .admin( + Method::POST, + "/enrollment-tokens", + Some(&json!({ "name": "invalid", "maxUses": uses, "expiresAt": expires })), + ) + .await?; + expect_status(&invalid, 400)?; + } + let too_late = (time::OffsetDateTime::now_utc() + time::Duration::days(366)).format(&Rfc3339)?; + expect_status( + &ctx.target + .admin( + Method::POST, + "/enrollment-tokens", + Some(&json!({ "name": "too-late", "maxUses": 1, "expiresAt": too_late })), + ) + .await?, + 400, + )?; + Ok(()) +} + +pub(crate) async fn p_admin_requires_auth(ctx: Context) -> anyhow::Result<()> { + assert_admin_routes_denied(&ctx, None, 401).await?; + let invalid = format!("invalid-admin-{}", uuid::Uuid::new_v4()); + assert_admin_routes_denied(&ctx, Some(&invalid), 401).await?; + Ok(()) +} + +async fn assert_token_name_absent(target: &Target, name: &str) -> anyhow::Result<()> { + let mut page = 1; + loop { + let listing = target + .admin( + Method::GET, + &format!("/enrollment-tokens?pageNumber={page}&pageSize=100"), + None, + ) + .await?; + expect_status(&listing, 200)?; + ensure!( + listing.body["data"] + .as_array() + .context("token listing is missing data")? + .iter() + .all(|record| record["name"] != name), + "unauthorized token creation changed server state" + ); + if page >= count(&listing.body, "totalPages")? { + break; + } + page += 1; + } + Ok(()) +} + +fn known_response_fields(value: &Value, required: &[&str], optional: &[&str]) -> anyhow::Result { + let object = value.as_object().context("response is not a JSON object")?; + let mut fields = serde_json::Map::new(); + for &name in required { + let field = object + .get(name) + .with_context(|| format!("response omitted required {name}"))?; + ensure!(!field.is_null(), "required response field {name} is null"); + fields.insert(name.to_owned(), field.clone()); + } + for &name in optional { + if let Some(field) = object.get(name).filter(|field| !field.is_null()) { + fields.insert(name.to_owned(), field.clone()); + } + } + Ok(Value::Object(fields)) +} + +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| { + for name in ["certificate", "thumbprint", "not_before", "not_after"] { + let _ = field(root, name)?; + } + known_response_fields(root, &["certificate", "thumbprint", "not_before", "not_after"], &[]) + }) + .collect::>>()?; + known.sort_by(|left, right| left["thumbprint"].as_str().cmp(&right["thumbprint"].as_str())); + Ok(known) +} + +fn rotation_snapshot(value: &Value) -> anyhow::Result { + let _ = field(value, "phase")?; + let _ = count(value, "activeDevicesOnOldRoot")?; + let mut known = known_response_fields(value, &["phase", "activeDevicesOnOldRoot"], &["deadline"])?; + if known.get("deadline").is_some() { + let _ = field(&known, "deadline")?; + } + for name in ["oldRoot", "newRoot"] { + if let Some(root) = value.get(name).filter(|root| !root.is_null()) { + let _ = field(root, "thumbprint")?; + let _ = field(root, "notAfter")?; + known[name] = known_response_fields(root, &["thumbprint", "notAfter"], &[])?; + } + } + Ok(known) +} + +fn token_record_snapshot(value: &Value) -> anyhow::Result { + known_response_fields( + value, + &[ + "id", + "name", + "maxUses", + "usedCount", + "expiresAt", + "state", + "createdAt", + "createdBy", + ], + &["friendlyNameFormat", "config"], + ) +} + +fn device_snapshot(value: &Value) -> anyhow::Result { + let mut known = known_response_fields( + value, + &[ + "id", + "friendlyName", + "status", + "connected", + "metadata", + "certificates", + "enrollmentToken", + "createdAt", + "renewalRequested", + ], + &["lastSeenAt", "certificate", "revokedAt"], + )?; + if let Some(certificate) = value.get("certificate").filter(|certificate| !certificate.is_null()) { + known["certificate"] = known_response_fields(certificate, &["notAfter", "issuer"], &[])?; + } + if let Some(certificates) = value.get("certificates") { + known["certificates"] = Value::Array( + certificates + .as_array() + .context("device certificates are not an array")? + .iter() + .map(|certificate| { + known_response_fields( + certificate, + &[ + "thumbprint", + "serialNumber", + "notBefore", + "notAfter", + "issuer", + "status", + ], + &[], + ) + }) + .collect::>>()?, + ); + } + if let Some(token) = value.get("enrollmentToken") { + known["enrollmentToken"] = known_response_fields(token, &["id", "name"], &[])?; + } + Ok(known) +} + +async fn assert_admin_routes_denied(ctx: &Context, bearer: Option<&str>, expected: u16) -> 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 before = device_snapshot(&device(&ctx.target, &identity.device_id).await?)?; + let token_record_before = ctx.target.token_record(&token.id).await?; + expect_status(&token_record_before, 200)?; + let token_record_before = token_record_snapshot(&token_record_before.body)?; + let roots_before = trust_anchor_snapshot(&ctx.target.trust_anchor().await?)?; + let rotation_before = ctx.target.admin(Method::GET, "/ca/rotation", None).await?; + expect_status(&rotation_before, 200)?; + let rotation_before = rotation_snapshot(&rotation_before.body)?; + let events_before = if ctx.mock() { + Some(ctx.target.events(&identity.device_id).await?) + } else { + None + }; + let expires = (time::OffsetDateTime::now_utc() + time::Duration::hours(1)).format(&Rfc3339)?; + let unauthorized_name = format!("unauthorized-{}", uuid::Uuid::new_v4()); + let create = serde_json::to_vec(&json!({ + "name": &unauthorized_name, + "maxUses": 1, + "expiresAt": expires, + }))?; + let rename_payload = serde_json::to_vec(&json!({ "friendlyName": "unauthorized" }))?; + let rotation = if ctx.mock() { + serde_json::to_vec(&json!({ "deadline": "now" }))? + } else { + // A malformed request cannot rotate a non-disposable DVLS target even if authorization is broken. + b"{".to_vec() + }; + let token_path = format!("/enrollment-tokens/{}", token.id); + let device_path = format!("/devices/{}", identity.device_id); + let routes = [ + (Method::POST, "/enrollment-tokens".to_owned(), Some(create.as_slice())), + (Method::GET, "/enrollment-tokens".to_owned(), None), + (Method::GET, token_path.clone(), None), + (Method::DELETE, token_path, None), + (Method::GET, format!("/devices?enrollmentTokenId={}", token.id), None), + (Method::GET, device_path.clone(), None), + (Method::PATCH, device_path.clone(), Some(rename_payload.as_slice())), + (Method::DELETE, device_path.clone(), None), + (Method::POST, format!("{device_path}/revoke"), None), + (Method::POST, format!("{device_path}/request-renewal"), None), + (Method::GET, "/ca/rotation".to_owned(), None), + (Method::POST, "/ca/rotation".to_owned(), Some(rotation.as_slice())), + ]; + for (method, route, body) in routes { + let route_path = format!("/api/v3/agent-identity{route}"); + ensure!( + ctx.target + .send(method.clone(), &route_path, body, bearer, None) + .await? + .status + == expected, + "unauthorized {method} {route} was not denied with HTTP {expected}" + ); + let after_token = ctx.target.token_record(&token.id).await?; + expect_status(&after_token, 200)?; + ensure!( + token_record_snapshot(&after_token.body)? == token_record_before, + "unauthorized {method} {route} changed the enrollment token" + ); + if method == Method::POST && route == "/enrollment-tokens" { + assert_token_name_absent(&ctx.target, &unauthorized_name).await?; + } + ensure!( + device_snapshot(&device(&ctx.target, &identity.device_id).await?)? == before, + "unauthorized {method} {route} changed the device" + ); + let after_rotation = ctx.target.admin(Method::GET, "/ca/rotation", None).await?; + expect_status(&after_rotation, 200)?; + ensure!( + rotation_snapshot(&after_rotation.body)? == rotation_before + && trust_anchor_snapshot(&ctx.target.trust_anchor().await?)? == roots_before, + "unauthorized {method} {route} changed CA rotation state" + ); + if let Some(events) = &events_before { + ensure!( + ctx.target.events(&identity.device_id).await? == *events, + "unauthorized {method} {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 admin route pushed a channel message" + ); + } + Ok(()) +} + +pub(crate) async fn p_admin_permission_denied(ctx: Context) -> anyhow::Result<()> { + let unprivileged = ctx + .unprivileged_admin_token + .as_deref() + .context("unprivileged admin fixture missing")?; + assert_admin_routes_denied(&ctx, Some(unprivileged), 403).await?; + Ok(()) +} + +pub(crate) async fn p_rotation_admin_write_requires_auth(ctx: Context) -> anyhow::Result<()> { + 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.admin(Method::GET, "/ca/rotation", None).await?; + expect_status(&rotation, 200)?; + let rotation = rotation_snapshot(&rotation.body)?; + let body = serde_json::to_vec(&json!({ "deadline": "now" }))?; + let invalid = format!("invalid-admin-{}", uuid::Uuid::new_v4()); + for (bearer, expected) in [ + (None, 401), + (Some(invalid.as_str()), 401), + (ctx.unprivileged_admin_token.as_deref(), 403), + ] { + if expected == 403 && bearer.is_none() { + continue; + } + expect_status( + &ctx.target + .send( + Method::POST, + "/api/v3/agent-identity/ca/rotation", + Some(&body), + bearer, + None, + ) + .await?, + expected, + )?; + ensure!( + trust_anchor_snapshot(&ctx.target.trust_anchor().await?)? == before, + "unauthorized rotation changed published roots" + ); + ensure!( + rotation_snapshot(&ctx.target.admin(Method::GET, "/ca/rotation", None).await?.body)? == rotation, + "unauthorized rotation changed state" + ); + } + Ok(()) +} + +pub(crate) async fn p_error_body_shape(ctx: Context) -> anyhow::Result<()> { + 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_mock_fault_response_not_processed(ctx: Context) -> anyhow::Result<()> { + 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 + && count(&ctx.target.token_record(&token.id).await?.body, "usedCount")? == 0 + && count(&ctx.target.devices_for(&token, "").await?.body, "totalCount")? == 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!( + count(&ctx.target.token_record(&token.id).await?.body, "usedCount")? == 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 = device(&ctx.target, &identity.device_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!( + device(&ctx.target, &identity.device_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(()) +} + +#[cfg(test)] +mod tests { + use base64::engine::general_purpose::STANDARD; + + use super::*; + + #[test] + fn last_seen_may_be_missing_before_check_in() -> anyhow::Result<()> { + ensure!( + last_seen_at(&json!({}))?.is_none() && last_seen_at(&json!({ "lastSeenAt": null }))?.is_none(), + "missing and null admin lastSeenAt must both mean unseen" + ); + ensure!( + last_seen_at(&json!({ "lastSeenAt": "2026-09-25T12:00:00Z" }))?.is_some(), + "check-in timestamp was not parsed" + ); + ensure!( + last_seen_at(&json!({ "lastSeenAt": 123 })).is_err() + && last_seen_at(&json!({ "lastSeenAt": "invalid" })).is_err(), + "invalid admin lastSeenAt was accepted" + ); + Ok(()) + } + + #[test] + fn disposable_rotation_requires_an_isolated_old_root() -> anyhow::Result<()> { + 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 device" + ); + require_isolated_old_root_fixture(&HashSet::from(["own".to_owned()]), &["own"], false)?; + require_isolated_old_root_fixture(&HashSet::new(), &[], false)?; + Ok(()) + } + + #[test] + fn authorization_snapshots_ignore_unknown_fields() -> anyhow::Result<()> { + 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" + ); + + let rotation = json!({ "phase": "idle", "activeDevicesOnOldRoot": 0 }); + let mut noisy_rotation = rotation.clone(); + noisy_rotation["request_id"] = json!("different"); + noisy_rotation["deadline"] = Value::Null; + noisy_rotation["oldRoot"] = Value::Null; + noisy_rotation["newRoot"] = Value::Null; + ensure!( + rotation_snapshot(&rotation)? == rotation_snapshot(&noisy_rotation)?, + "unknown or null optional rotation fields changed the no-state-change snapshot" + ); + noisy_rotation["phase"] = Value::Null; + ensure!( + rotation_snapshot(&noisy_rotation).is_err(), + "explicit null required rotation phase was accepted" + ); + let token = json!({ + "id": "token-id", + "name": "test", + "maxUses": 1, + "usedCount": 1, + "expiresAt": "future", + "state": "exhausted", + "createdAt": "now", + "createdBy": "admin" + }); + let mut noisy_token = token.clone(); + noisy_token["request_id"] = json!("different"); + noisy_token["friendlyNameFormat"] = Value::Null; + noisy_token["config"] = Value::Null; + ensure!( + token_record_snapshot(&token)? == token_record_snapshot(&noisy_token)?, + "unknown or null optional token fields changed the no-state-change snapshot" + ); + noisy_token["usedCount"] = Value::Null; + ensure!( + token_record_snapshot(&noisy_token).is_err(), + "explicit null required token use count was accepted" + ); + let device = json!({ + "id": "device-id", + "friendlyName": "test", + "status": "active", + "connected": false, + "metadata": {}, + "certificates": [], + "enrollmentToken": { "id": "token-id", "name": "test" }, + "createdAt": "now", + "renewalRequested": false, + "certificate": { "issuer": "root-id", "notAfter": "end" } + }); + let mut noisy_device = device.clone(); + noisy_device["request_id"] = json!("different"); + noisy_device["certificate"]["request_id"] = json!("different"); + noisy_device["lastSeenAt"] = Value::Null; + noisy_device["revokedAt"] = Value::Null; + ensure!( + device_snapshot(&device)? == device_snapshot(&noisy_device)?, + "unknown or null optional device fields changed the no-state-change snapshot" + ); + let mut no_certificate = device; + no_certificate + .as_object_mut() + .context("device fixture must be an object")? + .remove("certificate"); + let mut null_certificate = no_certificate.clone(); + null_certificate["certificate"] = Value::Null; + ensure!( + device_snapshot(&no_certificate)? == device_snapshot(&null_certificate)?, + "null optional device certificate was not treated as absent" + ); + noisy_device["metadata"] = Value::Null; + ensure!( + device_snapshot(&noisy_device).is_err(), + "explicit null required device metadata was accepted" + ); + Ok(()) + } + + #[test] + fn chain_requires_published_root_validity_and_signed_p256_links() -> anyhow::Result<()> { + 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<()> { + 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!( + format!( + "{:#}", + verify_chain(&unsupported, &roots, time::OffsetDateTime::now_utc().unix_timestamp()) + .expect_err("unsupported issuer curve was accepted") + ) + .contains("unsupported"), + "unsupported issuer curve was not a conformance failure" + ); + Ok(()) + } +} diff --git a/crates/agent-identity-conformance/src/signer.rs b/crates/agent-identity-conformance/src/signer.rs new file mode 100644 index 0000000000..40f14ad8ac --- /dev/null +++ b/crates/agent-identity-conformance/src/signer.rs @@ -0,0 +1,263 @@ +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 fixed_rfc6979_vectors_and_channel_proof() -> anyhow::Result<()> { + 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.signature, case["headers"]["signature"], "{name} signature"); + assert_eq!(signed.digest.as_deref(), case["headers"]["content-digest"].as_str()); + } + 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 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 expected_hex = proof["message_hex"].as_str().context("message hex")?; + assert_eq!( + message.iter().map(|byte| format!("{byte:02x}")).collect::(), + expected_hex + ); + let expected_proof = proof["cases"][0]["proof"].as_str().context("valid proof")?; + assert_eq!(STANDARD.encode(pair.channel_proof(&challenge, nonce)), expected_proof); + 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(()) + } +} 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 0000000000..c556e45ad9 --- /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}; +use crate::Context; +use crate::client::{Target, Token, count, expect_status, field}; +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(), + device_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_device(&self, target: &Target, token: &Token) -> anyhow::Result { + let started = Instant::now(); + loop { + let listing = target.devices_for(token, "&view=full").await?; + expect_status(&listing, 200)?; + let devices = count(&listing.body, "totalCount")?; + ensure!( + devices <= 1, + "SYSTEM agent created multiple devices for a one-use token" + ); + if devices == 1 { + let device = &listing.body["data"][0]; + let id = field(device, "id")?.to_owned(); + self.case.observe_agent_metadata(device)?; + let record = target.token_record(&token.id).await?; + expect_status(&record, 200)?; + ensure!( + count(&record.body, "usedCount")? == 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 response = target.device(id).await?; + expect_status(&response, 200)?; + self.case.observe_agent_metadata(&response.body)?; + if (check_channel && response.body["connected"] == true) + || (!check_channel && response.body["metadata"]["hostname"] == "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_device(&target, &token).await?; + self.signed_traffic(&target, &id).await?; + self.request.device_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.device_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(|target| target.ca_path.as_mut()) { + *path = absolute(path)?; + } + 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:#}"); + } + 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<()> { + 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<()> { + 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(), + device_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 0000000000..3e9872a735 --- /dev/null +++ b/crates/agent-identity-conformance/src/system_probe.rs @@ -0,0 +1,431 @@ +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) device_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, device_id: &str, prefix: &str) -> anyhow::Result> { + if identity["device_id"] != device_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 + .device_id + .as_deref() + .context("SYSTEM probe has no enrolled device 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 device" + ); + 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" + ); + ensure!( + !crate::agent::is_temporary_sibling(&path, &documented, &request.key_name_prefix), + "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 { + 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_device_and_run() -> anyhow::Result<()> { + let prefix = "conformance-4321"; + let name = format!("{prefix}_479e1d51-4038-4d09-b7f8-5bdff4489cad"); + let identity = json!({ "device_id": "device-a", "keys": { "current": { "key_name": name } } }); + assert_eq!(matching_key(&identity, "device-a", prefix)?, Some(name)); + assert_eq!(matching_key(&identity, "device-b", prefix)?, None); + assert!(matching_key(&identity, "device-a", "conformance-432").is_err()); + assert!( + matching_key( + &json!({ "device_id": "device-a", "keys": { "current": { "key_name": format!("{prefix}bad") } } }), + "device-a", + prefix + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn system_identity_audit_rejects_token_fragments_and_private_key_material() -> anyhow::Result<()> { + 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(), + device_id: None, + token_port: None, + token_nonce: None, + cleanup_only: true, + }; + let token = format!("dvaet1.bag.{}", "A".repeat(43)); + audit_identity_tree(&request, &token)?; + 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<()> { + 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(), + device_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 0000000000..df6710c090 --- /dev/null +++ b/crates/agent-identity-conformance/src/windows.rs @@ -0,0 +1,1014 @@ +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() { + 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<()> { + 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<()> { + 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() { + 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() { + 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<()> { + 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() { + 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-conformance/tests/skip_exit.rs b/crates/agent-identity-conformance/tests/skip_exit.rs new file mode 100644 index 0000000000..7f281e8840 --- /dev/null +++ b/crates/agent-identity-conformance/tests/skip_exit.rs @@ -0,0 +1,96 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{Context as _, ensure}; + +#[test] +fn missing_second_authority_requires_allow_incomplete() -> anyhow::Result<()> { + let bin = env!("CARGO_BIN_EXE_agent-identity-conformance"); + let work_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join("agent-identity-skip-tests"); + std::fs::create_dir_all(&work_dir)?; + let work_dir = tempfile::Builder::new().prefix("skip-").tempdir_in(work_dir)?; + let run = |allow_incomplete| { + let mut command = Command::new(bin); + command.args([ + "--target", + "dvls", + "--base-url", + "https://127.0.0.1:1", + "--admin-token", + "unused", + "--agent-bin", + bin, + "--filter", + "a_multi_authority", + "--work-dir", + ]); + command.arg(work_dir.path()); + if allow_incomplete { + command.arg("--allow-incomplete"); + } + command.output().context("run conformance SKIP fixture") + }; + let denied = run(false)?; + let allowed = run(true)?; + let denied_output = std::str::from_utf8(&denied.stdout)?; + let allowed_output = std::str::from_utf8(&allowed.stdout)?; + ensure!( + denied.status.code() == Some(1) && allowed.status.success(), + "SKIP did not follow --allow-incomplete exit policy" + ); + for output in [denied_output, allowed_output] { + ensure!( + output.contains("SKIP a_multi_authority") && output.contains("SUMMARY PASS 0 FAIL 0 SKIP 1 N/A 0"), + "SKIP fixture did not report an incomplete run" + ); + } + Ok(()) +} + +#[test] +fn dvls_stale_hello_without_config_fixture_is_incomplete() -> anyhow::Result<()> { + let bin = env!("CARGO_BIN_EXE_agent-identity-conformance"); + let work_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("target") + .join("agent-identity-skip-tests"); + std::fs::create_dir_all(&work_dir)?; + let work_dir = tempfile::Builder::new().prefix("dvls-").tempdir_in(work_dir)?; + for allow_incomplete in [false, true] { + let mut command = Command::new(bin); + command.args([ + "--target", + "dvls", + "--base-url", + "https://127.0.0.1:1", + "--admin-token", + "unused", + "--agent-bin", + bin, + "--filter", + "p_config_hello_reconciles_stale_revision", + "--work-dir", + ]); + command.arg(work_dir.path()); + if allow_incomplete { + command.arg("--allow-incomplete"); + } + let output = command.output().context("run DVLS stale-Hello fixture")?; + ensure!( + output.status.success() == allow_incomplete, + "DVLS missing config fixture did not follow --allow-incomplete" + ); + let stdout = std::str::from_utf8(&output.stdout)?; + ensure!( + stdout.contains("SKIP p_config_hello_reconciles_stale_revision") + && stdout.contains("SUMMARY PASS 0 FAIL 0 SKIP 1 N/A 0"), + "DVLS stale Hello without a config-change fixture reported N/A instead of SKIP" + ); + } + Ok(()) +} diff --git a/crates/agent-identity-httpsig/Cargo.toml b/crates/agent-identity-httpsig/Cargo.toml new file mode 100644 index 0000000000..e0699da6a1 --- /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 0000000000..56d20dc7a1 --- /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 0000000000..4d2f9f454b --- /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.md](../../docs/agent-identity/CONTRACT.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 0000000000..efc02f773a --- /dev/null +++ b/crates/agent-identity-httpsig/clippy.toml @@ -0,0 +1,7 @@ +semicolon-outside-block-ignore-multiline = true +accept-comment-above-statement = true +accept-comment-above-attributes = true + +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-identity-httpsig/src/lib.rs b/crates/agent-identity-httpsig/src/lib.rs new file mode 100644 index 0000000000..973a81990a --- /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 0000000000..b998792fe3 --- /dev/null +++ b/crates/agent-identity-httpsig/src/tests.rs @@ -0,0 +1,375 @@ +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, + ); + 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 = [ + Case { + name: "valid_renew_alg_before_keyid", + tag: Tag::Renew, + key: "device-a", + nonce_start: 144, + }, + Case { + name: "valid_connect_alg_before_keyid", + tag: Tag::Connect, + key: "device-a", + nonce_start: 160, + }, + Case { + name: "valid_confirm_alg_before_keyid", + tag: Tag::Confirm, + key: "device-g", + nonce_start: 176, + }, + 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 + ); + 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 + ); + 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, + )?; + 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(()) +} + +#[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(()) +} + +#[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, public_key) = vector_signer(&vectors, "device-a")?; + let base_url = url::Url::parse("https://example.test/dvls/")?; + let layer = ChannelLayer::new(signer.clone(), &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] { + assert_eq!(request.uri().path(), format!("/dvls{CONNECT_RPC_PATH}")); + assert!(request.headers().get("signature-input").is_some()); + assert!(request.headers().get("signature").is_some()); + assert!(request.headers().get("content-digest").is_none()); + } + 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"))?, + )? + ); + 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); + + let challenge = [42u8; 32]; + let proof_input = channel_proof_input(&challenge, &first_nonce); + let proof = signer.key.try_sign(&proof_input)?; + public_key.verify(&proof_input, &proof)?; + assert!( + public_key + .verify(&channel_proof_input(&challenge, &second_nonce), &proof) + .is_err() + ); + 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}"))?; + assert_eq!( + request.uri().path(), + format!("/devolutions.agent.channel.v1.AgentChannel{CONNECT_RPC_PATH}") + ); + 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; + 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()?; + assert_eq!( + received_nonce.await?, + nonce( + request + .headers() + .get("signature-input") + .ok_or_else(|| anyhow!("missing signature input"))? + )? + ); + assert!((before..=after).any(|created| signature_input.contains(&format!(";created={created};")))); + Ok(()) +} + +fn channel_proof_input(challenge: &[u8; 32], 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()); + message +} + +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 0000000000..5fce879e6f --- /dev/null +++ b/crates/agent-identity-mock/Cargo.toml @@ -0,0 +1,58 @@ +[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-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 = ["codegen", "prost"] } +tower = { version = "0.5", features = ["util"] } +tracing = "0.1" +uuid = { version = "1", features = ["v4", "serde"] } +x509-cert = { version = "0.3", features = ["builder"] } + 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 0000000000..56d20dc7a1 --- /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 0000000000..8c181d4b32 --- /dev/null +++ b/crates/agent-identity-mock/INTENT.md @@ -0,0 +1,40 @@ +This binary is a reference authority for the conformance tester. + +## What it implements + +- The agent-facing operations and the agent channel, exactly as [CONTRACT.md](../../docs/agent-identity/CONTRACT.md) specifies. +- The DVLS management interface the conformance tester drives, under `{u}/api/v3/agent-identity`, so that the same tests run against the mock and against DVLS: + - 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; + - 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 the management permission, 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. + +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 state; +- 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 0000000000..c7292ed718 --- /dev/null +++ b/crates/agent-identity-mock/README.md @@ -0,0 +1,132 @@ +# 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 admin API][dvls-admin] 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 --admin-token --state-dir +``` + +| Flag | Meaning | +|---|---| +| `--listen ` | TCP bind address; 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 `/`. | +| `--admin-token ` | Bearer token required by the admin API. | +| `--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 serves HTTPS on a single port with ALPN `h2` and `http/1.1` (REST and gRPC share the port). +It then writes `/ready.json` atomically (temp file + rename): + +```json +{ "base_url": "https://127.0.0.1:/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. +`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 `last_seen_at` all use it. +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.md §5), no admin auth + +| Method | Path | Notes | +|---|---|---| +| GET | `/api/agent-identity/v1/trust-anchor` | `{ "roots": [ { "certificate" (base64 DER), "thumbprint", "not_before", "not_after" } ] }`; two roots 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. +Enrollment, renewal and check-in require a `metadata` object, which may be empty. +Metadata is limited to 16 KiB in aggregate, and agent-facing request bodies are limited to 64 KiB. +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 admin device 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 devices; it cannot set `version`, `revision` or `agent_channel_url`. +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. +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 an `error-code` trailer (§7.4). + +### Admin ([DVLS admin API][dvls-admin]), `Authorization: Bearer ` + +Missing or invalid admin auth returns 401 `{ "error", "message" }`; all admin errors use that shape with the matching HTTP status. +For permission tests, `-unprivileged` is authenticated without Agent identity management permission and returns 403. + +| Method | Path | Notes | +|---|---|---| +| POST | `/api/v3/agent-identity/enrollment-tokens` | Body `{ name, maxUses (1..1_000_000), expiresAt (RFC 3339, ≤ now+365 d), friendlyNameFormat?, config? }`; 201 → `{ "token": "", "record": TokenRecord }`; the full token is returned only here. Unknown `friendlyNameFormat` placeholders → 400. | +| GET | `/api/v3/agent-identity/enrollment-tokens` | `pageNumber` (≥ 1, default 1), `pageSize` (1..100, default 25); DVLS page shape `{ data, currentPage, pageSize, totalCount, totalPages }`. | +| GET | `/api/v3/agent-identity/enrollment-tokens/{id}` | `TokenRecord`; 404 when unknown or deleted. | +| DELETE | `/api/v3/agent-identity/enrollment-tokens/{id}` | 204; the token is hidden but can replay its own existing current key without side effects, while a new key gets `token_invalid`. | +| GET | `/api/v3/agent-identity/devices` | Query: `pageNumber`, `pageSize`, `view=summary\|full`, `metadata=k1,k2`, `status=active\|revoked\|expired`, `enrollmentTokenId`, `issuer=`, `lastSeenBefore`, `lastSeenAfter`, `q` (case-insensitive friendly-name substring); ordered by creation. | +| GET | `/api/v3/agent-identity/devices/{id}` | Full view. | +| PATCH | `/api/v3/agent-identity/devices/{id}` | `{ "friendlyName" }` (1..255 chars) → full view. | +| POST | `/api/v3/agent-identity/devices/{id}/revoke` | 204, idempotent; closes live streams PERMISSION_DENIED + `device_revoked`. | +| DELETE | `/api/v3/agent-identity/devices/{id}` | 204, or 409 unless the device is revoked. | +| POST | `/api/v3/agent-identity/devices/{id}/request-renewal` | 202; sets the flag and pushes `RenewRequested{reason:"admin"}` to live streams. | +| POST | `/api/v3/agent-identity/ca/rotation` | Body `{ "deadline"?: "" \| "now" }`; 202 → `Rotation`; 400 if the deadline exceeds the latest unexpired current or pending old-root certificate; 409 while rotating. | +| GET | `/api/v3/agent-identity/ca/rotation` | `{ phase: "idle"\|"rotating", oldRoot?, newRoot?, deadline?, activeDevicesOnOldRoot }`; the count includes each non-revoked device with an unexpired current or pending old-root certificate. | + +A rotation completes immediately when no active old-root device 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 device 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` and `rotation_rate_limit_per_sec`; absent keys retain their value, and `null` clears optional fields. Toggling `channel_available` bumps every device's config revision; `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. | +| POST | `/__mock__/config` | `{ "fields": {...} }` merges non-reserved fields into each device's config; effective changes bump revisions and push `ConfigUpdate`. | +| POST | `/__mock__/config/stale` | `{ "device_id": "", "revision": }` sends a stale `ConfigUpdate` with `mock_stale_marker` without changing server config; returns `{ "sent": }`. | +| POST | `/__mock__/reset` | Clears tokens, devices, nonces, faults, rotation and the clock offset, and creates a fresh root; keeps `authority_id`, the TLS certificate and the admin token; live streams close with `device_unknown`. 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?device_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; 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_device_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` | `{ "device_id": "" }` pushes `Reconnect{reason:"mock"}` to the device'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_device_revoked` counts enroll rejections for a previously issued key of a revoked or deleted device, including retries after a lost response. +Rotation pushes default to 10 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.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 while ignoring the subject and extensions (§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 `AuthenticatedDevice`. +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. + +[contract]: ../../docs/agent-identity/CONTRACT.md +[dvls-admin]: ../../docs/agent-identity/INTENT.DVLS.md#admin-http-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 0000000000..e6a27ca203 --- /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.rs b/crates/agent-identity-mock/src/api_admin.rs new file mode 100644 index 0000000000..3bd1fac183 --- /dev/null +++ b/crates/agent-identity-mock/src/api_admin.rs @@ -0,0 +1,581 @@ +//! This module serves the DVLS admin API ([DVLS intent][dvls-intent], "Admin HTTP API") +//! under `/api/v3/agent-identity`. +//! +//! Follows DVLS conventions: camelCase keys, DVLS paging, `Authorization: Bearer`, and +//! `{ "error", "message" }` error bodies. +//! +//! [dvls-intent]: ../../../docs/agent-identity/INTENT.DVLS.md#admin-http-api + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{Path, Query, Request, State as AxumState}; +use axum::http::StatusCode; +use axum::http::header::AUTHORIZATION; +use axum::middleware::Next; +use axum::response::{IntoResponse as _, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router, middleware}; +use base64::Engine as _; +use serde::Deserialize; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::app::{App, parse_rfc3339, rfc3339}; +use crate::oracle::CertStatus; +use crate::state::{ApiError, Device, State, Token}; + +const MAX_PAGE_SIZE: u64 = 100; +const DEFAULT_PAGE_SIZE: u64 = 25; +const MAX_TOKEN_LIFETIME_SECS: i64 = 365 * 24 * 3600; + +pub(crate) fn router(app: Arc) -> Router { + let authed = Router::new() + .route("/enrollment-tokens", post(create_token).get(list_tokens)) + .route("/enrollment-tokens/{id}", get(get_token).delete(delete_token)) + .route("/devices", get(list_devices)) + .route( + "/devices/{id}", + get(get_device).patch(patch_device).delete(delete_device), + ) + .route("/devices/{id}/revoke", post(revoke_device)) + .route("/devices/{id}/request-renewal", post(request_renewal)) + .route("/ca/rotation", post(start_rotation).get(get_rotation)) + .route_layer(middleware::from_fn_with_state(Arc::clone(&app), admin_auth)); + Router::new().nest("/api/v3/agent-identity", authed).with_state(app) +} + +async fn admin_auth(AxumState(app): AxumState>, request: Request, next: Next) -> Response { + let expected = format!("Bearer {}", app.admin_token); + let ok = request + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == expected); + let unprivileged = request + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == format!("Bearer {}", app.unprivileged_token)); + if unprivileged { + return admin_error(&ApiError::new( + 403, + "forbidden", + "missing Agent identity management permission", + )); + } + if !ok { + return admin_error(&ApiError::new(401, "unauthorized", "missing or invalid bearer token")); + } + next.run(request).await +} + +fn admin_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 parse_page_params(query: &HashMap) -> Result<(u64, u64), ApiError> { + let page_number = match query.get("pageNumber") { + None => 1, + Some(raw) => raw + .parse::() + .ok() + .filter(|&n| n >= 1) + .ok_or_else(|| ApiError::invalid_request("pageNumber must be >= 1"))?, + }; + let page_size = match query.get("pageSize") { + None => DEFAULT_PAGE_SIZE, + Some(raw) => raw + .parse::() + .ok() + .filter(|&n| (1..=MAX_PAGE_SIZE).contains(&n)) + .ok_or_else(|| ApiError::invalid_request("pageSize must be between 1 and 100"))?, + }; + Ok((page_number, page_size)) +} + +/// Paginates a creation-order-sorted list into the DVLS page shape. +fn paginate(items: Vec, page_number: u64, page_size: u64) -> Value { + let total_count = items.len() as u64; + let total_pages = total_count.div_ceil(page_size); + let start = page_number.saturating_sub(1).saturating_mul(page_size).min(total_count); + let end = start.saturating_add(page_size).min(total_count); + let data: Vec = items + .into_iter() + .skip(usize::try_from(start).unwrap_or(usize::MAX)) + .take(usize::try_from(end - start).unwrap_or(usize::MAX)) + .collect(); + json!({ + "data": data, + "currentPage": page_number, + "pageSize": page_size, + "totalCount": total_count, + "totalPages": total_pages, + }) +} + +// ==== DVLS enrollment tokens ==== + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateTokenRequest { + name: String, + max_uses: u64, + expires_at: String, + friendly_name_format: Option, + config: Option, +} + +fn validate_token_config(config: &Value) -> Result<(), ApiError> { + let fields = config + .as_object() + .ok_or_else(|| ApiError::invalid_request("config must be a JSON object"))?; + if fields + .keys() + .any(|name| matches!(name.as_str(), "version" | "revision" | "agent_channel_url")) + { + return Err(ApiError::invalid_request("config cannot replace reserved fields")); + } + Ok(()) +} + +fn token_record(token: &Token, now: i64) -> Value { + let mut record = json!({ + "id": token.id, + "name": token.name, + "maxUses": token.max_uses, + "usedCount": token.used_count, + "expiresAt": rfc3339(token.expires_at), + "state": token.state(now), + "createdAt": rfc3339(token.created_at), + "createdBy": token.created_by, + }); + if let Some(format) = &token.friendly_name_format { + record["friendlyNameFormat"] = json!(format); + } + if let Some(config) = &token.config { + record["config"] = config.clone(); + } + record +} + +async fn create_token(AxumState(app): AxumState>, body: Bytes) -> Response { + app.tick().await; + let request: CreateTokenRequest = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return admin_error(&ApiError::invalid_request("malformed JSON body")), + }; + let now = app.now(); + if !(1..=1_000_000).contains(&request.max_uses) { + return admin_error(&ApiError::invalid_request("maxUses must be between 1 and 1000000")); + } + let Some(expires_at) = parse_rfc3339(&request.expires_at) else { + return admin_error(&ApiError::invalid_request("expiresAt must be RFC 3339")); + }; + if expires_at > now + MAX_TOKEN_LIFETIME_SECS { + return admin_error(&ApiError::invalid_request("expiresAt is more than 365 days out")); + } + if let Some(format) = &request.friendly_name_format + && let Err(message) = crate::name_eval::validate_friendly_name_format(format) + { + return admin_error(&ApiError::invalid_request(message)); + } + if let Some(config) = &request.config + && let Err(error) = validate_token_config(config) + { + return admin_error(&error); + } + + let mut state = app.state.lock().await; + let (id, secret) = state.create_token( + request.name, + request.max_uses, + expires_at, + request.friendly_name_format, + request.config, + now, + ); + let token = &state.tokens[&id]; + let record = token_record(token, now); + drop(state); + + // §2: dvaet1... + let bag = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json!({ "u": app.base_url }).to_string()); + let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret); + let full_token = format!("dvaet1.{bag}.{secret}"); + ( + StatusCode::CREATED, + Json(json!({ "token": full_token, "record": record })), + ) + .into_response() +} + +async fn list_tokens(AxumState(app): AxumState>, Query(query): Query>) -> Response { + app.tick().await; + let (page_number, page_size) = match parse_page_params(&query) { + Ok(p) => p, + Err(err) => return admin_error(&err), + }; + 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(); + tokens.sort_by_key(|t| (t.created_at, t.id)); + let records: Vec = tokens.into_iter().map(|t| token_record(t, now)).collect(); + Json(paginate(records, page_number, page_size)).into_response() +} + +async fn get_token(AxumState(app): AxumState>, Path(id): Path) -> Response { + app.tick().await; + let state = app.state.lock().await; + match state.tokens.get(&id).filter(|token| token.deleted_at.is_none()) { + Some(token) => Json(token_record(token, app.now())).into_response(), + None => admin_error(&ApiError::not_found("unknown token")), + } +} + +async fn delete_token(AxumState(app): AxumState>, Path(id): Path) -> Response { + app.tick().await; + let mut state = app.state.lock().await; + match state.tokens.get_mut(&id) { + Some(token) => { + token.deleted_at.get_or_insert_with(|| app.now()); + StatusCode::NO_CONTENT.into_response() + } + None => admin_error(&ApiError::not_found("unknown token")), + } +} + +// ==== DVLS devices ==== + +fn device_summary(app: &App, state: &State, device: &Device, metadata_keys: Option<&[String]>) -> Value { + let now = app.now(); + let mut view = json!({ + "id": device.id, + "friendlyName": device.friendly_name, + "status": device.status(now), + "connected": state.is_connected(device.id), + }); + if let Some(last_seen_at) = device.last_seen_at { + view["lastSeenAt"] = json!(rfc3339(last_seen_at)); + } + if let Some(current) = device.current_cert() { + view["certificate"] = json!({ + "notAfter": rfc3339(current.not_after), + "issuer": current.issuer, + }); + } + if let Some(keys) = metadata_keys { + view["metadata"] = metadata_subset(device, keys); + } + view +} + +fn metadata_subset(device: &Device, keys: &[String]) -> Value { + let mut map = serde_json::Map::new(); + for key in keys { + if let Some(value) = device.metadata.get(key) { + map.insert(key.clone(), value.clone()); + } + } + Value::Object(map) +} + +fn device_full(app: &App, state: &State, device: &Device) -> Value { + let mut view = device_summary(app, state, device, None); + view["metadata"] = Value::Object(device.metadata.clone()); + view["certificates"] = Value::Array( + device + .certs + .iter() + .map(|c| { + json!({ + "thumbprint": c.thumbprint, + "serialNumber": c.serial, + "notBefore": rfc3339(c.not_before), + "notAfter": rfc3339(c.not_after), + "issuer": c.issuer, + "status": match c.status { + CertStatus::Current => "current", + CertStatus::Pending => "pending", + CertStatus::Retired => "retired", + }, + }) + }) + .collect(), + ); + view["enrollmentToken"] = json!({ "id": device.token_id, "name": device.token_name }); + view["createdAt"] = json!(rfc3339(device.created_at)); + if let Some(revoked_at) = device.revoked_at { + view["revokedAt"] = json!(rfc3339(revoked_at)); + } + view["renewalRequested"] = json!(device.renewal_requested.is_some()); + view +} + +struct DeviceQuery { + view_full: bool, + metadata_keys: Option>, + status: Option, + enrollment_token_id: Option, + issuer: Option, + last_seen_before: Option, + last_seen_after: Option, + q: Option, + page_number: u64, + page_size: u64, +} + +fn parse_device_query(query: &HashMap) -> Result { + let (page_number, page_size) = parse_page_params(query)?; + let view_full = match query.get("view").map(String::as_str) { + None | Some("summary") => false, + Some("full") => true, + Some(_) => return Err(ApiError::invalid_request("view must be summary or full")), + }; + let metadata_keys = query + .get("metadata") + .map(|raw| raw.split(',').map(ToOwned::to_owned).collect::>()); + let status = match query.get("status") { + None => None, + Some(s) if matches!(s.as_str(), "active" | "revoked" | "expired") => Some(s.clone()), + Some(_) => return Err(ApiError::invalid_request("status must be active, revoked or expired")), + }; + let enrollment_token_id = match query.get("enrollmentTokenId") { + None => None, + Some(raw) => Some( + raw.parse::() + .map_err(|_| ApiError::invalid_request("enrollmentTokenId must be a UUID"))?, + ), + }; + let last_seen_before = match query.get("lastSeenBefore") { + None => None, + Some(raw) => { + Some(parse_rfc3339(raw).ok_or_else(|| ApiError::invalid_request("lastSeenBefore must be RFC 3339"))?) + } + }; + let last_seen_after = match query.get("lastSeenAfter") { + None => None, + Some(raw) => { + Some(parse_rfc3339(raw).ok_or_else(|| ApiError::invalid_request("lastSeenAfter must be RFC 3339"))?) + } + }; + Ok(DeviceQuery { + view_full, + metadata_keys, + status, + enrollment_token_id, + issuer: query.get("issuer").cloned(), + last_seen_before, + last_seen_after, + q: query.get("q").cloned(), + page_number, + page_size, + }) +} + +async fn list_devices(AxumState(app): AxumState>, Query(query): Query>) -> Response { + app.tick().await; + let query = match parse_device_query(&query) { + Ok(q) => q, + Err(err) => return admin_error(&err), + }; + let state = app.state.lock().await; + let now = app.now(); + + let mut devices: Vec<&Device> = state + .devices + .values() + .filter(|d| { + query.status.as_ref().is_none_or(|s| d.status(now) == s) + && query.enrollment_token_id.is_none_or(|t| d.token_id == t) + && query + .issuer + .as_ref() + .is_none_or(|i| d.current_cert().is_some_and(|c| c.issuer == *i)) + && query + .last_seen_before + .is_none_or(|t| d.last_seen_at.is_some_and(|l| l < t)) + && query + .last_seen_after + .is_none_or(|t| d.last_seen_at.is_some_and(|l| l > t)) + && query + .q + .as_ref() + .is_none_or(|q| d.friendly_name.to_lowercase().contains(&q.to_lowercase())) + }) + .collect(); + devices.sort_by_key(|device| device.created_seq); + + let views: Vec = devices + .into_iter() + .map(|d| { + if query.view_full { + device_full(&app, &state, d) + } else { + device_summary(&app, &state, d, query.metadata_keys.as_deref()) + } + }) + .collect(); + Json(paginate(views, query.page_number, query.page_size)).into_response() +} + +async fn get_device(AxumState(app): AxumState>, Path(id): Path) -> Response { + app.tick().await; + let state = app.state.lock().await; + match state.devices.get(&id) { + Some(device) => Json(device_full(&app, &state, device)).into_response(), + None => admin_error(&ApiError::not_found("unknown device")), + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PatchDeviceRequest { + friendly_name: String, +} + +async fn patch_device(AxumState(app): AxumState>, Path(id): Path, body: Bytes) -> Response { + app.tick().await; + let request: PatchDeviceRequest = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return admin_error(&ApiError::invalid_request("malformed JSON body")), + }; + let len = request.friendly_name.chars().count(); + if !(1..=crate::name_eval::MAX_FRIENDLY_NAME_CHARS).contains(&len) { + return admin_error(&ApiError::invalid_request("friendlyName must be 1..255 characters")); + } + let mut state = app.state.lock().await; + let Some(device) = state.devices.get_mut(&id) else { + return admin_error(&ApiError::not_found("unknown device")); + }; + device.friendly_name = request.friendly_name; + let device = state.devices.get(&id).expect("device exists"); + Json(device_full(&app, &state, device)).into_response() +} + +async fn revoke_device(AxumState(app): AxumState>, Path(id): Path) -> Response { + app.tick().await; + let now = app.now(); + let mut state = app.state.lock().await; + match state.revoke(id, now) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => admin_error(&err), + } +} + +async fn delete_device(AxumState(app): AxumState>, Path(id): Path) -> Response { + app.tick().await; + let mut state = app.state.lock().await; + match state.delete_device(id, app.now()) { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => admin_error(&err), + } +} + +async fn request_renewal(AxumState(app): AxumState>, Path(id): Path) -> Response { + app.tick().await; + let mut state = app.state.lock().await; + match state.request_renewal(id) { + Ok(()) => StatusCode::ACCEPTED.into_response(), + Err(err) => admin_error(&err), + } +} + +// ==== DVLS rotation ==== + +#[derive(Deserialize)] +struct StartRotationRequest { + deadline: Option, +} + +fn rotation_view(state: &State, now: i64) -> Value { + match &state.rotation { + None => json!({ "phase": "idle", "activeDevicesOnOldRoot": 0 }), + Some(rotation) => { + let root_view = |thumbprint: &str| { + state + .roots + .iter() + .find(|r| r.thumbprint == thumbprint) + .map(|r| json!({ "thumbprint": r.thumbprint, "notAfter": rfc3339(r.not_after) })) + }; + let mut view = json!({ + "phase": "rotating", + "deadline": rfc3339(rotation.deadline), + "activeDevicesOnOldRoot": state.active_devices_on_old_root(now), + }); + if let Some(old) = root_view(&rotation.old_root) { + view["oldRoot"] = old; + } + if let Some(new) = root_view(&rotation.new_root) { + view["newRoot"] = new; + } + view + } + } +} + +async fn start_rotation(AxumState(app): AxumState>, body: Bytes) -> Response { + app.tick().await; + let now = app.now(); + let deadline = if body.is_empty() { + None + } else { + let request: StartRotationRequest = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(_) => return admin_error(&ApiError::invalid_request("malformed JSON body")), + }; + match request.deadline.as_deref() { + None => None, + Some("now") => Some(now), + Some(raw) => match parse_rfc3339(raw) { + Some(d) => Some(d), + None => return admin_error(&ApiError::invalid_request("deadline must be RFC 3339 or \"now\"")), + }, + } + }; + let mut state = app.state.lock().await; + match state.start_rotation(deadline, now) { + Ok(()) => (StatusCode::ACCEPTED, Json(rotation_view(&state, now))).into_response(), + Err(err) => admin_error(&err), + } +} + +async fn get_rotation(AxumState(app): AxumState>) -> Response { + app.tick().await; + let state = app.state.lock().await; + Json(rotation_view(&state, app.now())).into_response() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::validate_token_config; + + #[test] + fn token_config_accepts_only_product_fields() { + assert!(validate_token_config(&json!({ "psu_channel_url": "https://psu.example" })).is_ok()); + + for config in [ + json!(true), + json!({ "version": 2 }), + json!({ "revision": 3 }), + json!({ "agent_channel_url": "https://elsewhere.example" }), + ] { + assert_eq!( + validate_token_config(&config).expect_err("config is invalid").code, + "invalid_request" + ); + } + } +} 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 0000000000..e7d6fafb38 --- /dev/null +++ b/crates/agent-identity-mock/src/api_agent.rs @@ -0,0 +1,431 @@ +//! Agent-facing API (CONTRACT.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 serde_json::{Map, Value, json}; + +use crate::app::{App, rfc3339}; +use crate::oracle::{AuthenticatedDevice, 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..`, 32-byte secret) to even reach the lookup (§5.4 +/// `token_invalid`). +fn parse_bearer(headers: &HeaderMap) -> Result<[u8; 32], ApiError> { + let header = headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(ApiError::token_invalid)?; + let parts: Vec<&str> = header.split('.').collect(); + if parts.len() != 3 || parts[0] != "dvaet1" { + return Err(ApiError::token_invalid()); + } + let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[2]) + .ok() + .and_then(|s| <[u8; 32]>::try_from(s).ok()) + .ok_or_else(ApiError::token_invalid)?; + Ok(secret) +} + +/// Parses an enroll/renew body: `{ "csr": "", "metadata": {...} }`. +fn parse_csr_body(body: &[u8]) -> Result<(Vec, Map), ApiError> { + let value: Value = serde_json::from_slice(body).map_err(|_| ApiError::invalid_request("malformed JSON body"))?; + 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`"))?; + let csr_der = base64::engine::general_purpose::STANDARD + .decode(csr) + .map_err(|_| ApiError::invalid_request("`csr` is not valid 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 == "device_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 + .devices + .get(&outcome.device_id) + .expect("enrolled device exists") + .config + .clone(); + drop(state); + + let body = json!({ + "authority_id": app.authority_id, + "device_id": outcome.device_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.device_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 device = state + .devices + .get_mut(&auth.device_id()) + .expect("authenticated device exists"); + device.metadata = metadata; + device.last_seen_at = Some(now); + let renewal_requested = device.renewal_requested.is_some(); + let config = device.config.clone(); + let revision = device.config_revision(); + state.record_event( + auth.device_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: Value = serde_json::from_slice(body).map_err(|_| ApiError::invalid_request("malformed JSON body"))?; + 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 { + devices, + cert_index, + nonces, + .. + } = state; + let mut lookup = |keyid: &str| crate::state::lookup_cert(devices, cert_index, keyid); + verify_request( + endpoint, + "POST", + signature_input.as_deref(), + signature.as_deref(), + content_digest.as_deref(), + body, + now, + &mut lookup, + nonces, + ) +} 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 0000000000..3f7e412b74 --- /dev/null +++ b/crates/agent-identity-mock/src/api_mock.rs @@ -0,0 +1,423 @@ +//! 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_device_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_device_revoked": enroll_device_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 { + device_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.device_id == query.device_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 { + device_id: Uuid, +} + +async fn reconnect(AxumState(app): AxumState>, Json(request): Json) -> Response { + let mut state = app.state.lock().await; + if !state.devices.contains_key(&request.device_id) { + return control_error(&ApiError::not_found("unknown device")); + } + state.push_to_device(request.device_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; + let updated = state.merge_config_fields(fields); + Json(json!({ "updatedDevices": updated })).into_response() +} + +#[derive(Deserialize)] +struct StaleConfigRequest { + device_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 device_id and revision")), + }; + let mut state = app.state.lock().await; + match state.replay_stale_config(request.device_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, + }) +} + +/// `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", + "device_revoked" => "device_revoked", + "device_unknown" => "device_unknown", + "certificate_expired" => "certificate_expired", + "signature_invalid" => "signature_invalid", + "clock_skew" => "clock_skew", + "invalid_request" => "invalid_request", + _ => 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) => state.update_channel_available(*b, &app.base_url), + _ => 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"), + }, + _ => 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() +} + +/// `POST reset` clears tokens, devices, nonces, faults, rotation and the clock offset. +/// It creates a fresh root but keeps `authority_id`, the TLS certificate and the admin token. +async fn reset(AxumState(app): AxumState>) -> Response { + let mut state = app.state.lock().await; + app.clock.reset(); + app.handshake_gate.send_replace(true); + match state.reset(app.now()) { + 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 0000000000..5495865cfb --- /dev/null +++ b/crates/agent-identity-mock/src/app.rs @@ -0,0 +1,53 @@ +//! Shared application handle and time helpers. + +use std::sync::Arc; + +use tokio::sync::{Mutex, watch}; +use uuid::Uuid; + +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, + pub admin_token: String, + pub unprivileged_token: String, + 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 { + 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(), + } +} + +/// Parses an RFC 3339 timestamp to Unix seconds. +pub(crate) fn parse_rfc3339(value: &str) -> Option { + use time::format_description::well_known::Rfc3339; + time::OffsetDateTime::parse(value, &Rfc3339) + .ok() + .map(time::OffsetDateTime::unix_timestamp) +} diff --git a/crates/agent-identity-mock/src/ca.rs b/crates/agent-identity-mock/src/ca.rs new file mode 100644 index 0000000000..cf2207c495 --- /dev/null +++ b/crates/agent-identity-mock/src/ca.rs @@ -0,0 +1,201 @@ +//! In-memory CA per CONTRACT.md §4: a P-256 root per rotation generation and +//! per-device 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; +pub(crate) const DEFAULT_LEAF_LIFETIME_SECS: i64 = 90 * 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