Repository navigation
feat(agent): device identity enrollment and channel - #2000
Draft
Benoît Cortier (CBenoit) wants to merge 55 commits into
Draft
Benoît Cortier (CBenoit) wants to merge 55 commits into
Benoît Cortier (CBenoit) wants to merge 55 commits into
Conversation
Member
Author
|
Implementation notes (Phase 1: contract and conformance suite) Contents
Current results (Windows, local)
Dependencies
Suite decisions
Review Note LLM-assisted content (no human feedback). |
Benoît Cortier (CBenoit)
force-pushed
the
cbenoit-agent-identity-v1
branch
2 times, most recently
from
October 19, 2026 16:40
eb39f01 to
cbcb825
Compare
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>
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>
Benoît Cortier (CBenoit)
force-pushed
the
cbenoit-agent-identity-v1
branch
from
October 19, 2026 19:01
a2184c8 to
12dfd95
Compare
…outcome From experiment 1 (RFC 9421, DPoP, and DPoP with authority-issued nonces, built as probes against a shared proxy harness and judged blind), the maintainer kept RFC 9421 and recorded the two decisions the evidence settled, each with the alternative it excludes: request authentication never covers the host or path, and the agent supplies each request's freshness. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…d hit Found by the blind judge of experiment 1 and approved by the maintainer: - a verifier detects repeated parameters, members and labels on the raw field, since RFC 8941 parsers keep only the last occurrence; - byte sequences in `Signature` and `Content-Digest` use canonical padded base64; - a refused channel opening carries a `server-time` trailer, so the agent's single clock-skew retry works on the channel too. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The rule-to-test mapping, the review-only claims and the known gaps describe the conformance suite, not something the agent and an authority agree on, and they drift as tests are renamed. The contract keeps what conformance means; the mapping moves to the suite's COVERAGE.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract's rules define conformance; the suite is independent evidence derived from them, so defining conformance as passing the suite inverted that. §6 still requires the shared vectors, and the suite's role is stated in its own intent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The vectors are evidence for the §6 rules, not a rule both parties agree to, so their role, the signer's pass condition and the independent verifier move to the shared intent's Conformance section. The vector file no longer claims a signer reproduces signature bytes, which contradicted that condition. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract no longer defines friendly names, so §5.2 drops its §3 reference, and the DVLS intent points at the shared intent's Enrollment section, where the format is defined. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…adapter The device-key file backend signs through RustCrypto's Signer and the httpsig adapter's tests implement it; Signer::sign panics on failure, so both crates keep calling try_sign. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A hard link keeps the regular-file type, owner and ACL of the key it aliases, so the reparse-point and symlink checks let it through. The file backend now refuses to open a key file whose link count is not one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The software provider persists a key only at finalization, so two creators of one name could both succeed and the later one replaced the earlier key. Creation now takes a global named mutex for the key name, checks that the key does not exist, and holds the lock through finalization. The wait is bounded at five seconds, like the file backend's lock. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The DVLS intent keeps decisions and leaves exact routes, fields and codes to DVLS's OpenAPI document. It adds the root's creation, custody, expiry and failure rules, the channel offer as a hosting property, renewal load shedding, the grace identity status, four management permissions, and a suggested 90-day token expiry with an expiry warning. The suite reaches each authority's management API through a module per authority, and N/A covers tests that module declares unsupported. The mock replicates DVLS's management API as it is. The channel client backs off up to 1 h when the path cannot carry gRPC. Only the caller's account and SYSTEM can block a Windows key's creation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An independent verifier found points the profile left open. Step 1 now names one member in each of Signature-Input and Signature, parameter types, no parameters on the Signature member, a covered component or the Content-Digest member, the keyid and nonce formats, and canonical base64. A malformed keyid fails there, so an agent bug never draws a terminal identity_unknown. Unexpired follows RFC 5280 (now <= notAfter), and uncovered headers are ignored. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Each byte string has one canonical base64url encoding; a padded, standard-alphabet or non-zero-pad-bit keyid or nonce fails step 1 like non-canonical base64 does. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A future notBefore can only come from the authority's own clock, so rejecting it would lock devices out over an authority-side fault. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Request bodies reject repeated JSON member names at any depth, every base64 and base64url value is canonical, only the exact token string authenticates, and a CSR key must be an uncompressed P-256 point so each key has one SPKI SHA-256. A friendly name is trimmed again after truncation, so it never ends in white space. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
During pending-key recovery the expired pending key sits in `previous` and the fresh key in `pending`, so a restart resumes recovery. A runner checks in when it starts, including after a new enrollment, and the channel's 1 h timer restarts when the URL changes. A rejected identity keeps its keys until replaced or uninstalled, an unusable pending key is defined, and the agent never judges certificate validity itself: the server's clock decides. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract gains a transient `unavailable` code for 5xx answers, which overload shedding and an unusable root key need. DVLS's management API becomes part of its public API and follows that API's contract, with settings readable under View and changeable under Manage. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When the authority's clock is ahead, notBefore lies in the agent's future and a schedule anchored on it renews late by the skew. Anchoring on the earlier of notBefore and the moment the agent stored or loaded the certificate keeps renewal at two thirds of the authority's lifetime without storing anything new. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The bag is unsigned routing data and the authority keeps only the secret's hash, so an altered bag authenticates like the original and no authority needs to store the full token. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The route spelling now follows DVLS's public API contract, so the intent keeps only the naming decision: records are identities, and "device" stays free for a later machine view. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An empty extensionRequest attribute violates RFC 5280's SIZE (1..MAX), so a strict PKCS#10 parser at an authority rejects the request. The server still ignores any attribute it receives. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
RFC 2986 requires the attributes field, so "no attributes" means an empty set encoded A0 00, not an omitted element. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Common CSR builders emit an extensionRequest with an empty Extensions list. The agent no longer sends one, but an authority ignores CSR attributes, so it must not reject that form with a strict parser. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… recovery When pending-key recovery has to start again, the newest expired pending certificate is the one the server holds as pending or current, so it replaces the older one in `previous` and still fits three slots. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract now names the authority's record an identity, so the mock serves `identity_id`, `identity_revoked` and `identity_unknown` on the wire, `/identities` and `activeIdentitiesOnOldRoot` in the admin API, and `identity_id` throughout its control surface. The mock refuses a non-loopback listen address because its control surface is unauthenticated. Every refused or failed channel stream carries a `server-time` trailer next to `error-code`. A repeated `Authorization` header on enroll is `token_invalid`. Supporting tests pin the body-limit and authentication order for each agent-facing endpoint, and the oracle's rejection of non-canonical base64 and repeated labels or members. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
Point friendly-name evaluation and the rotation ticker at the sections that specify them, and wrap comment lines at the 120-column limit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
The identity contract names the authority's record an identity, with `identity_id`, `identity_revoked` and `identity_unknown` on the wire. Rename the enroll response field, the terminal error codes, the `identity.json` field and the rejection-marker code to match. Nothing has shipped, so no migration or alias is kept. Point the retry-delay test at the documents that now own the rule. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
The identity module owns the identity service task, the `identity` command and the `Identity` and `__debug__.identity` configuration, so that every Agent Identity change in the agent binary happens under one directory and its intent. Move the `identity enroll` command into `identity::cli` and the configuration types, their normalization and the debug-settings warning into `identity::config`. Move the command dispatch out of `main.rs` into a generic `cli.rs` that routes `identity` to the identity module; every other command behaves as before. `config.rs` keeps only the fields, each pointing to the identity module. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
A missing current key used to delete the identity, so re-running the same install command enrolled the token again as a new identity. Record the identity as rejected with the code `key_lost` instead: the same token stays a no-op, and recovery needs a different token, as after a revocation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
When the configured device-key backend is unavailable, the identity task used to log and retry forever. Agent Identity must instead fail loudly and never fall back to another backend, because a different backend would silently change where the device's keys live. The store already probes the backend before it touches any pending file, identity or directory. On device-key's distinguishable `BackendUnavailable` error, the task now logs one error with the backend name and error chain, raises the system event `IDENTITY_KEY_BACKEND_UNAVAILABLE` (1100, a new Agent Identity range), and returns, so the identity feature stays stopped for the rest of the process lifetime while the other agent tasks keep running. Other initialization errors keep retrying as before. The agent gains a shared system event sink that mirrors the Gateway's: the Event Log source `Devolutions Agent` on Windows, syslog with the daemon facility and ident `devolutions-agent` on Unix, and a no-op sink when neither can be opened. Unlike the Gateway, it emits in debug builds too, so that a debug agent still reports the failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
The contract defines config version 1 only, and a config with any other version is a transient failure that keeps the stored config. The agent used to accept any version. Enroll and check-in responses now require `config.version` to be 1 and otherwise fail as an invalid response, which the agent retries without storing anything. Updating a stored identity's config refuses another version and leaves the stored config as it was. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
The agent now reports system events in debug builds too, and the installer registers the agent executable as the Event Log message file, so a debug executable without the catalog leaves its events without a description. Embed the catalog in every Windows build. Without a developer shell, look for mc.exe in the Windows SDK's default installation directory, so that debug builds keep working from a plain shell. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
The catalog test needs a builder for every declared Agent event code, so that each message keeps one insertion per field. Cover `IDENTITY_KEY_BACKEND_UNAVAILABLE`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
Another account could create the global mutex a key's creation waited on and make every creation of that key time out. The lock now lives in a private namespace whose boundary holds the caller's SID and whose DACL grants only the caller and SYSTEM, so no other account can create or open it. Creators running as the same account still share the lock and serialize. A namespace that disappears between a failed create and the open is created again, a bounded number of times. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
Wait time-outs advance in system clock ticks, so a five-second wait can end slightly before five seconds of monotonic time have passed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
Rename the authority's records to identities on the wire, in the admin API and in test names. Close the coverage gaps for malformed enroll authorization, error precedence, invalid Hello metadata, Z timestamps, refused-opening trailers, and non-canonical or repeated signature fields, with matching negative vectors. Accept any strictly increasing config revision and any root profile, verify signer output under the vector keys, and report N/A only for platform-inapplicable tests. Move the SKIP and N/A exit checks and the Agent Identity binary helpers into the testsuite's agent_identity module. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
… paging oracles Verify issued chains with RFC 5280 path rules only and report an issuer algorithm the tester cannot verify as incomplete. Let an overlapping page walk miss or repeat concurrent enrollments, and check exact-once listing in a separate walk. Reject repeated labels and members within one Signature-Input, Signature or Content-Digest field, with matching negative vectors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
…ssions Identity status follows DVLS's terminology: an unexpired current or pending certificate makes an identity active, one within renewal grace makes it grace, and the identity filter accepts all four statuses. Each management API route requires DVLS's View, Manage, Revoke or Rotate root permission and answers 403 without it. Credentials derived from the admin token grant any subset of the permissions, so tests can exercise each denial. The mock now says "management API", and the device-key creation lock cites the intent sentence it realises. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
… step 1 rules Each negative vector now fails only at the step it targets, so a verifier missing that one check returns a different code. Cross-tag vectors keep the original Content-Digest header, so a verifier missing both the component and tag checks can rebuild the signed base. connect_replayed_to_renew replays the connect's (keyid, nonce) in a correctly signed renew, which reaches the nonce store. Over-covered vectors cover a content-type header the request carries instead of an absent @path. The repeated-tag vector is signed over the member an RFC 8941 parser keeps, with the first occurrence before created, so only duplicate detection rejects it. New vectors cover a padded keyid, a nonce with non-zero pad bits, a string created, parameters on the Signature member, a covered component and the Content-Digest member, the notAfter and grace-end boundaries, and an uncovered Content-Digest on confirm. Renew grace is cited from the section 6 table and section 8.2. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
A config whose version is not 1 is a transient failure, and the agent keeps its stored config. One test delivers version 2 through check-in and requires the stored config to stay unchanged while the agent keeps checking in, then requires the next supported config to be stored. Another delivers version 2 on enrollment and requires the pending file to stay and no identity to be stored until the version is supported. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
The conformance suite needs the mock to deliver a config whose version is not 1, to check that the agent treats it as a transient failure and keeps its stored config. The config_version fault replaces config.version in every delivered config: the enrollment and check-in responses, live ConfigUpdate pushes and the ConfigUpdate that follows Welcome. Setting or clearing it bumps every identity's config revision and pushes ConfigUpdate, so the delivered config is always newer than the agent's stored one. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §6 keeps a certificate unexpired while now ≤ notAfter and never checks notBefore, because a future notBefore reflects only the authority's own clock. The oracle's step 4, the identity statuses and the old-root rotation count now share that rule, and renewal grace stays now < notAfter + lifetime as the §6 table states. A stream still closes at notAfter, as §7.4 requires, so the channel refuses an opening at that second with certificate_expired instead of opening a stream it must close at once. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §5.4 maps an unexpected server failure under the agent-facing API to 500 unavailable, a transient code with the usual error body. The dispatcher rewrites every 5xx that a handler did not inject, including a panic it catches, to that response, and the fail_next_response fault accepts unavailable like the other §5.4 codes. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §1 rejects a request body that repeats a member name at any depth, and serde_json keeps only the last occurrence, so request bodies now go through a parser that fails on a repeated name. The csr must already be canonical base64, and only a well-formed token with the canonical secret text reaches the token lookup, so only the exact token string authenticates (§2). A CSR key must be an uncompressed P-256 point, so that each key has exactly one SPKI SHA-256 (§4). A friendly name is trimmed again after truncation, so it never ends in Unicode White_Space. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…notAfter CONTRACT.md §6 accepts a connect whose certificate is at now == notAfter, and §7.3 step 2 then sends Challenge. The opening at that second is no longer refused; the stream receives its Challenge and then closes with certificate_expired, because §7.4 closes a stream no later than its certificate's notAfter. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §2 lets only the exact token string authenticate, so enrollment now compares the presented token with the one the mock issued for that secret, bag included, before the token lookup. The management API follows the §1 protocol conventions as well, so its request bodies are parsed with the same rejection of a member name repeated at any depth. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CONTRACT.md §2 authenticates a token by its exact secret text and never checks the bag, which carries only routing data. Enrollment no longer compares the bag with the one the mock issued; a token whose secret text differs, including a non-canonical encoding of the same bytes, stays token_invalid. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
x509-cert's RequestBuilder always adds an extensionRequest attribute, which is an empty SEQUENCE when no extension is requested and breaks RFC 5280's SIZE (1..MAX). Build the CertificationRequestInfo directly with an empty attribute set, sign its DER with the device key, and assemble the request. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
RFC 2986 makes the attributes field mandatory, so an empty set must be encoded as A0 00 rather than omitted. Assert that exact element in the encoded request and link the contract section from the doc comment. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The agent's CSR carries no attributes (contract §4), so `csr.valid` now has an empty attributes set. Its key is new and stored with the vector, so the CSR can be regenerated. The previous CSR, with an empty extensionRequest, stays as `valid_with_empty_extension_request`. It is still accepted, because the server takes only the public key and the self-signature from a CSR. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Issue: DGW-478
…rough its module Management APIs are product-specific, so every route, field, paging rule, enum and status code of DVLS's management API now lives in `src/dvls.rs`, and tests use its typed operations and records. A test that needs mock controls reports N/A on DVLS instead of SKIP, because that module declares it unsupported. Identity statuses follow DVLS's terminology: an identity with an unexpired pending certificate is active, and one within renewal grace is in grace. Each management API route group is checked with a credential lacking only its permission. New checks cover repeated JSON members, non-canonical CSR encodings, compressed-point CSRs, exact token secret matching and friendly-name white-space trimming. Expiry boundaries treat a certificate as unexpired at its notAfter, and Retry-After checks respect the agent's maximum retry delay. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…bers tests use A token, certificate or rotation record may omit the members the suite only compares, since DVLS's intent leaves the exact model to its OpenAPI document. A token record discloses its token only if it holds the secret text, and the full-view check no longer depends on how a metadata selector combines with it. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…es too A token record that names a member after the token discloses it as much as one that holds it as a value. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Revocation, deletion and the certificate's notAfter close a stream that still awaits Hello, ahead of the handshake failures, so authorities agree on the outcome. A certificate retired during the handshake still completes it, then gets Reconnect like any retired stream. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Benoît Cortier (CBenoit)
added this pull request to stack #2126
October 20, 2026 14:01
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
msiexecor 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 theDevolutions.AgentIdentity.ChannelNuGet package for server implementers.agent-identity-mockandagent-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.