Skip to content

feat(agent): device identity enrollment and channel - #2000

Draft
Benoît Cortier (CBenoit) wants to merge 50 commits into
masterfrom
cbenoit-agent-identity-v1
Draft

Benoît Cortier (CBenoit) wants to merge 50 commits into
masterfrom
cbenoit-agent-identity-v1

Conversation

@CBenoit

@CBenoit Benoît Cortier (CBenoit) commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Devolutions Agent can obtain its own device identity from a management server such as Devolutions Server: a server-issued device ID and an X.509 certificate whose private key never leaves the machine.
One reusable enrollment token, deployable through msiexec or Microsoft Intune, enrolls any number of machines.
After enrolling, the agent renews its certificate on its own and keeps an authenticated, bidirectional gRPC channel to the server, so the server can reach the agent (for example, to ask it to renew).

This draft contains the product-neutral contract and its conformance suite; the agent implementation follows in the same PR:

  • docs/agent-identity/CONTRACT.md: the agent-facing HTTP API, the RFC 9421 request-signature profile, the gRPC channel and its proof-of-possession handshake, and the agent's local files and configuration.
  • docs/agent-identity/test-vectors.json: shared signature, channel-proof and CSR vectors for every implementation.
  • agent-identity-channel-proto: the channel schema for Rust, also published as the Devolutions.AgentIdentity.Channel NuGet package for server implementers.
  • agent-identity-mock and agent-identity-conformance: a mock server and a conformance tester that checks a server (the mock, or a real DVLS) and the agent against the contract; the testsuite runs both.

Adds the approved agent identity contract (v0.3), the gRPC channel
definition as a shared crate, and RFC 9421 / channel-proof test vectors.

Issue: TBD

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: TBD

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds Devolutions.AgentIdentity.Channel, a NuGet package generated from the
same channel.proto as the Rust crate, so server implementers get the gRPC
service base and client types without copying the schema.

Issue: TBD

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
In-memory implementation of the agent identity contract used as the
conformance oracle: agent-facing API, admin API, gRPC channel with the
challenge/proof handshake, root rotation and a fault-injection API. The
RFC 9421 profile is verified by hand and pinned by the shared test vectors.

Issue: TBD

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the conformance tester binary (protocol tests acting as a raw client,
agent tests spawning the real agent and standing in for the MSI) and wires
it into the testsuite against two mock instances. Hardens the mock's
signature parameter parsing.

Issue: TBD

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: TBD

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds live digest-integrity and revoked-before-Hello channel checks, anchors
certificate chains to published roots, distinguishes an absent channel_url
from null, guards Windows machine-key cleanup with the server-issued
authority ID, and gives a cold agent more time to enroll.

Issue: TBD

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@CBenoit

Copy link
Copy Markdown
Member Author

Implementation notes (Phase 1: contract and conformance suite)

Contents

  • docs/agent-identity/CONTRACT.md v0.3 (approved), including the §10.1 clarification that extra_trusted_root is a PEM bundle.
  • docs/agent-identity/test-vectors.json, produced by a throwaway generator that builds the signature bases by hand and signs deterministically (RFC 6979).
    It was cross-checked with Python cryptography and includes RFC 9421 Appendix B.2.4.
  • agent-identity-channel-proto: tonic/prost code generated with vendored protoc, passed through prost_build::Config::protoc_executable rather than set_var.
    The same .proto builds the Devolutions.AgentIdentity.Channel NuGet package (net10.0, Google.Protobuf + Grpc.Core.Api).
    CI builds the package and publish-libraries.yml publishes it; the new package ID may need nuget.org trusted-publishing setup.
  • agent-identity-mock: the conformance oracle.
    It verifies the RFC 9421 profile by hand with p256, without an RFC 9421 library, applying the §6 checks in contract order and committing each nonce only after its signature verifies.
    Authenticated handlers take an AuthenticatedDevice that only the verifier can construct.
    A unit test runs every entry of test-vectors.json through the server's own verifier.
  • agent-identity-conformance: a custom harness that exits non-zero on failure.
    • Protocol tests act as a raw client, with their own signer written independently of the mock.
    • Agent tests spawn devolutions-agent run with DAGENT_CONFIG_PATH and stand in for the MSI through the pending file (Unix 0600; Windows DPAPI machine scope with a protected DACL).
    • Observation order: admin API first, agent files second, logs last.
    • Mock-only tests are skipped with --target dvls.
    • Rotation tests against DVLS need --disposable-dvls-target.
  • The testsuite starts two mocks under /mock and runs the tester against them.

Current results (Windows, local)

  • 47/47 protocol tests pass against the mock (about 15 s).
  • Expected to fail until the agent implementation lands: the 22 a_* tests below, which need devolutions-agent identity support.
    a_file_backend_permissions is Unix-only and skips on Windows.
    Until then, the testsuite test agent_identity::conformance is red in CI; fmt, clippy, the existing tests and the protocol tests are green.
    • a_pending_file_enroll_success, a_pending_file_deleted_on_permanent_error, a_pending_file_kept_on_transient_error, a_token_never_logged
    • a_same_token_no_enrollment, a_same_token_rejected_identity_no_enrollment, a_different_token_replaces_identity, a_cli_identity_enroll_writes_pending_file
    • a_renewal_happy_path, a_renewal_lost_response_retried, a_channel_connected_and_metadata, a_make_before_break_on_renewal
    • a_request_renewal_connected, a_request_renewal_while_offline, a_reconnect_make_before_break, a_revocation_stops_agent
    • a_device_unknown_recorded, a_no_channel_when_absent, a_multi_authority, a_key_non_exportable
    • a_rotation_migrates_connected_agent, a_rotation_migrates_on_schedule

Dependencies

  • The workspace resolves p256 0.14.0-rc.14 and ecdsa 0.17.0-rc.22, because ironrdp-connector 0.10 → sspi 0.21 → picky =7.0.0-rc.25 pins those release candidates.
    The mock and tester therefore pin p256 =0.14.0-rc.14.
  • The agent's signing layer, in the next phase, holds httpsig at =0.0.24 for the same reason.
    httpsig is used only to build and sign the agent's own requests, never to parse incoming headers, because 0.0.24 can panic on malformed Signature-Input.
  • Follow-up: once IronRDP releases on sspi 0.22 (picky rc.26), bump the picky pins to rc.26 and httpsig to 0.0.26, and drop the duplicate p256/ecdsa generation.

Suite decisions

  • Enroll idempotence is checked before token expiry and exhaustion, so a lost response on a maxUses = 1 token can be retried.
  • Chain checks:
    • The last chain element must equal a published root byte for byte.
    • Links are checked by name, and their signatures are verified for P-256 issuers.
    • Validity is checked at evaluation time.
    • There's no full RFC 5280 path validation (§4, invariant 10).
  • Windows test cleanup deletes a machine key only when the key didn't exist before the test, its name matches the contract pattern, and its authority is the server-issued authority_id.

Review
Each suite task went through a coder ↔ reviewer loop using the repository code-review skill.
The tester task ended after 3 rounds (19 → 14 → 8 findings); a follow-up fix task addressed the remaining 8 and was approved in round 2.

Note

LLM-assisted content (no human feedback).

GitHub-hosted Windows runners run as the RID 500 administrator, which
SDDL renders as LA, so the literal SID comparison rejected a correct
DACL. Also declare the uuid serde feature the tester relies on.

Issue: TBD

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Applies the Phase 1 gate clarifications C1-C13: enroll and renew key
reuse, strict signature window, framework error bodies, creation-ordered
listing, rotation limits, pending-file ownership and mock controls.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Implements contract v0.4 (C1-C14) in the mock: key-reuse rules, strict
signature window, framework error bodies, creation-ordered listing,
bounded rotation pushes, response-failure injection and channel events.
Strengthens the tester per the Phase 1 gate verdict, including wire-level
negatives, admin write authorization, boundary deadlines, make-before-break
from event ordering, restart durability and C14 metadata checks. An
incomplete run now fails unless --allow-incomplete.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The enrollment key must exist before enroll returns the authority ID and
NCrypt keys cannot be renamed, so key names use a random key UUID. Also
records the enrollment key across retries so a lost enroll response is
recovered through idempotence.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Uses a per-run key name prefix for cleanup and orphan audits, adds the
enrollment-key retry and revocation cases, source-valid cross-tag probes,
full admin authorization coverage, per-send metadata overrides, exact
rotation deadline checks with a frozen mock clock, and process-tree
cleanup on outer timeouts.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…(C9)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Lets the Windows and Linux test jobs both finish while the agent identity
conformance tests are expected to fail. To be reverted before the pull
request leaves draft status.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Moves the hand-written RFC 9421 profile verifier, the channel-proof check
and the CSR check into a pure oracle module with a narrow API and a small
dependency set, independent of the httpsig crate used by the agent.
Removes two unit tests already covered end to end by protocol tests, and
creates the Unix key fixtures with mode 0600.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
C17 replaces promote-on-first-use with a POST confirm signed by the new
key, the only operation that promotes a pending certificate. C18 moves
the channel URL under config.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adopts contract C19 (config revisions, ConfigUpdate and the GET config
fallback) and C20: the crate is now agent-channel-proto, the proto package
devolutions.agent.channel.v1 and the NuGet package Devolutions.Agent.Channel.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Covers expiry recovery within grace, confirm-expiry recovery with the
pending key, server concurrency guarantees, exact covered components,
side-effect-free replay, deleted-token tombstones, per-token pending
files, transport hardening, debug-build-only knobs, size caps and a
16 KiB metadata ceiling. Adds a two-digest-member renew vector.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
C32 makes check-in the only HTTP fallback for devices without a working
agent channel; C29 is revised so the debug knobs apply in every build with
a start-up warning. Replaces the config vectors with check-in ones.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…rding

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The agent identity crates that only have an INTENT.md so far would break
the crates/* workspace glob; the exclusions go away when the crates land.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ue items

Updates the mock and the conformance tester to contract v0.7: confirm as
the only promotion point, check-in as the channelless fallback, config
revisions pushed over the channel, early rotation completion, server
concurrency and replay guarantees, per-token pending files, transport
hardening and size caps. Removes the debug-build guard so the suite also
runs against release builds, and a redundant Windows unit test.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Runs HTTP probes and channelless renewal and rotation independently of
the channel, proves an invalid channel URL behaves as absent, tolerates
unknown response fields and DVLS admin nulls, and checks lastSeenAt and
config revision behavior without timing races.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Fixes unused_mut on Linux: the agent process and the key set are only
mutated in Windows-specific code.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds over-covered, reordered and duplicated component cases with the
correct tag for each operation, all correctly signed and rejected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Refreshes metadata on renew replays, checks key ACL principals instead
of exact ACEs, narrows the identity tree audit to key material and
leftover temporary files, and adds the missing pending-file, pending-key,
channelless confirm, per-send metadata, transient-error, rotation,
negative-matrix, covered-component and original-token cases.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the agent-identity-keys crate: P-256 device keys that never leave
their backend, either non-exportable Microsoft Software KSP machine keys
restricted to SYSTEM on Windows, or owner-only PKCS#8 files elsewhere.
Keys expose typed RFC 9421 and CSR signers.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the four valid requests with alg before keyid, the order the agent's
signing library emits. RFC 9421 verifiers build the signature parameters
from the received member, so the order is not significant.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Makes the elevated-only spike test fail with its observations so the
result is visible in the CI log; to be reverted once read.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
RFC 9421 verifiers build the signature parameters from the received
member, and the agent's signing library emits a non-template order.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the agent-identity-httpsig crate: the RFC 9421 signing profile for
renew, confirm, check-in and the agent channel opening, built on the
httpsig crate with the device key, plus a tower layer that signs each
channel opening and applies the authority's base path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…t in CI"

The spike result is recorded: a non-SYSTEM administrator can read a
SYSTEM-only machine key's DACL only through the creating handle, and
cannot reopen the key by name afterwards.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Device keys can fail in the Windows key store, where Signer::sign would
panic; a crate-level clippy rule makes try_sign mandatory.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the agent-identity crate: token parsing and redaction, CSR building,
metadata collection, per-authority identity.json storage, pending enrollment
files (Unix 0600, Windows DPAPI with protected DACLs and an administrator
write-only drop box), rejected-token markers for permanent enroll outcomes,
and the state lock. Removes its workspace exclude entry.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…din enrollment

Runs the knob-off key-store subcase as SYSTEM through a scheduled task when
the tester is an elevated administrator (not applicable otherwise), submits
the token through the CLI, and audits keys, pending state and logs as SYSTEM
before cleanup. Adds same-token replay cases after exhausted, invalid,
malformed and revoked enrollments, and the interim identity enroll - case.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds the agent identity REST client (OS trust store plus an optional extra
root, no redirects, typed contract errors) and a service task that enrolls
each pending token independently with backoff, records permanent failures,
and stores the resulting identity. Adds the Identity configuration section,
the __debug__.identity controls with a start-up warning, and the
identity enroll <token> command, which also reads the token from stdin
when given -.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant