Repository navigation
feat(agent): device identity enrollment and channel - #2000
Benoît Cortier (CBenoit) wants to merge 351 commits into
Conversation
|
Implementation notes (Phase 1: contract and conformance suite) Contents
Current results (Windows, local)
Dependencies
Suite decisions
Review Note LLM-assisted content (no human feedback). |
a2184c8 to
12dfd95
Compare
aa0acdc to
c987cd8
Compare
21736c7 to
3d2365e
Compare
DVLS's management API authenticates a request by the tokenId that its login returns for an application identity's key and secret, and expires that tokenId 7 minutes after the login however often it is used, so no static credential lasts a 15-minute run. The DVLS module now takes a key and secret per application identity: --app-key, --second-app-key and --unprivileged-app-key, with secrets from --app-secret, --second-app-secret and --unprivileged-app-secret or the AGENT_IDENTITY_CONFORMANCE_APP_SECRET, AGENT_IDENTITY_CONFORMANCE_SECOND_APP_SECRET and AGENT_IDENTITY_CONFORMANCE_UNPRIVILEGED_APP_SECRET environment variables. A session logs in on first use and again before the lifetime it assumes elapses: DVLS's 7 minutes, or the age of a refused tokenId if shorter. On a 401 it logs in once more and retries the request once, and the run logs out at its end. Re-logins are logged at info level without the tokenId, key or secret. A 401 without a body now counts as unauthenticated, because DVLS answers a request without a tokenId that way. On the mock, the module derives the keys of application identities with fewer permissions from the privileged key, and the testsuite starts each mock with a key and secret and passes the secrets to the runner through the environment. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A usage error named the argument it rejected, so a credential given as --app-secret=<secret> or as a stray argument ended up in the runner's output. Usage errors now name only known flags and give the position of any other argument. DVLS answers a request without a tokenId with a 401 and no body, but a request with a tokenId it refuses with a PublicApiError, as its document gives every 401. Only a request without a tokenId now accepts a body-less 401; a session still logs in again on any 401 before its retried response is checked. Sessions read token ages from a clock that the module's tests move by hand, so they check that a session logs in again strictly before the age at which the target refused its previous token. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A malformed login response failed with serde's decoding error, which quotes the response and so could print the token it held. The error now says only that the response is not a LoginResult. A session's first 401 was replaced by the response to its retry, so a target could answer a request with a token with a 401 without a PublicApiError body and still pass. That first refusal is now checked after the session logs in again and retries the request once. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The verifier now runs every signature case and sequence through a contract §6 evaluator written from the contract alone, with its own strict structured-field parser, and checks that each negative case fails only at the step it targets. It also checks the CSR vectors and builds step-1 violations no vector covers yet, so the claim that it recomputes every vector holds. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… unordered On success the agent deletes the pending file and in-progress record, but nothing orders those deletions before it stores identity.json. Two tests checked the in-progress record once, right after observing the stored identity, and failed whenever the deletion was still under way. They now wait, with the usual 20-second bound, until each token's pending file and in-progress record are gone, as the last-wins test already did; the three share one helper. The dropped-enrollment helper read the mock's request counters before the management listing, so an enrollment committed between the two reads looked unrecorded. The mock's documentation also does not order the counters or the consumption of drop_next_response against the commit. The helper and the lost-renewal test now wait for the recorded request and the consumed fault once they see the commit, and still fail when either never happens. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When the agent did not finish a revoked enrollment's cleanup in time, the timeout named the whole cleanup, so a failure did not show which step was missing. The error now lists each unmet condition: the recorded identity_revoked rejection, the deleted pending file, in-progress record and enrollment key, and no stored identity. The conditions and the bound are unchanged. The pending-cleanup helper's rustdoc now links the agent intent it cites. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A bare CONTRACT.md becomes ambiguous once more contracts exist; the name parallels INTENT.agent-identity.md. Paths and citations change, and comments pushed past the line limit are re-wrapped; nothing else changes. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The Agent installer takes an enrollment token in the hidden, secure IDENTITY_ENROLLMENT_TOKEN property. On an install or a major upgrade that sets it, a deferred action running as SYSTEM hands the token to `DevolutionsAgent.exe identity enroll -` through standard input before the service starts, and the install fails if the agent rejects the token's format or can't store it. The token stays out of the MSI log and off the command lines the installer starts. A full uninstall deletes the agent data directory's identity folder and every machine key with Agent Identity's prefix in the Microsoft Software Key Storage Provider, without contacting any authority. It logs each failure and goes on, so the product always uninstalls. A major upgrade, a repair or a maintenance run keeps both. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
On Windows the agent locks its identity directory with an unshared handle on the directory itself rather than a lock file; the intent now says so, since the lock's location is not an observed interface. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The 1 h no-stream count also restarts when the last authenticated stream is lost, check-in keeps its running schedule after the URL is removed, and dropping a channel sends the acknowledgements already queued before ending the stream. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The state tests created their data directories under the crate's target directory, so from a long checkout path a deleted key's tombstone passed Windows' 260-character limit and 11 tests failed. They now create them under the system temporary directory. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
On Windows, replacing an identity file fails with ERROR_ACCESS_DENIED while another process has the file open, even one that shares deletion as the standard library does. The runner then retried its whole step after its local backoff, so a promotion or a rejection was recorded seconds late. The replacement now retries for up to 5 s. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Each stored identity with a usable agent_channel_url now keeps an authenticated stream through agent-channel-client, and its runner acts on the stream as the contract's channel and renewal sections require: - Hello carries the metadata collected when it is sent and the stored config revision. - Check-in runs only while no stream is authenticated, and resumes 1 h after the last authenticated stream is lost. - RenewRequested is acknowledged and starts a renewal, unless one is in progress. - ConfigUpdate is stored if newer, acknowledged, then applied: a removed URL closes the stream, a changed URL replaces it, and a URL that check-in adds opens one. - Reconnect and a confirmed renewal open a new stream before the old one closes. - identity_revoked and identity_unknown closures reject the identity; certificate_expired renews first and reconnects after confirm. No stream opens while the outcome of a confirm may be unknown: confirm waits until no stream is opening, and the streams stay held until a 204. The streams live in their own task, so that a stream that must not reopen, such as one of a retired key, is dropped even while the runner waits on a state lock. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Dropping a Channel aborted its task, so an Ack queued just before, as the runner queues one when a ConfigUpdate removes the channel URL, was never sent, and the authority saw the stream reset instead. Dropping a channel still stops an opening or a wait to reopen at once. An authenticated stream now sends the acknowledgements queued before, ends the agent's side, and lets the authority end its side for at most 5 s, since dropping the response first resets the stream and discards what is still queued. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A terminal closure only reached the runner as a notice, which it reads once it isn't busy. Until then the streams still wanted a stream: a reconciliation answered that one must be opened, and when the new stream of a renewed key drew the terminal outcome, the stream in use stayed and kept reopening. The streams' task now ends the identity's channel as soon as a stream closes with a terminal outcome: it closes every stream, wants none again, and starts a requested stream only if it still needs one when it handles the request. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…integration The channel integration was written before the contract was renamed; its citations and links now use the new name, and comments pushed past the line limit are re-wrapped. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The start-up repair test of a too-broad identity directory now places an identity file and a key file beneath it, and checks that both keep their bytes once the repair resets the directory's permissions. The module's test directories move from the crate's target directory to the system temporary directory, so their paths stay under Windows' 260-character limit wherever the repository is checked out. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
docs/agent-identity/README.md shows administrators how to enroll a device with Agent Identity and run it: the identity enroll command and the installer property, the Identity section of agent.json, where the state lives and what to leave alone, renewal, the channel and check-in, terminal outcomes, upgrade and uninstall, and troubleshooting from the service log, system event 1100, proxies, TLS trust and clock skew. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A revocation reaches a connected agent when the authority closes its channel stream, not only in the answer to its next request. An identity ended as key_lost is recorded as rejected, but the guide no longer promises the rejection log line, which start-up key loss does not write. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
No CI job runs tests on macOS, so nothing checks that a volume ignoring ownership or a file with extended ACL entries is detected; the comments name the cases to check on a Mac when this code changes. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… ones A token whose `u` carries a user name or password is a malformed token, so the malformed-token check now includes such tokens, which also checks that the error never contains the token. A stored identity's base URL is the token's `u`, so the stored-format check gains a row with a user name and password. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
URL parsing reads `https://@host` and `https://:@host` as having no user name or password, so the agent accepted such a `u`. An `@` in the authority of `u`, the text after `//` and before the next `/`, now counts as a user name, as it does for the authority, so the agent and the authority judge the same text the same way. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A user name or password in `agent_channel_url` could make the address read as one host while it reaches another, the same reason a token's `u` never carries them. The agent now treats such a channel URL as absent and logs a warning, as it does for one with a query or a fragment. The channel URL and the token's `u` share one check, so an `@` anywhere in the channel URL's authority also counts as a user name. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An enrollment token adds nothing to the config of the identities it enrolls: an administrator assigns product settings after enrollment. Token creation therefore refuses a `config` member like any other member its schema does not define, and a token record carries none. Product-specific fields still reach identities through the mock's `__mock__/config` control, which the conformance suite uses. An enrollment config now holds only `version`, `revision` and the channel URL, so only a base URL close to 64 KiB long can push it past one channel envelope; enrollment still refuses that case. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token that is no longer wanted is revoked, not deleted, so that it
stays stored with its name: a lost enrollment response stays
recoverable, and the identities it enrolled keep showing it. DVLS keeps
its tokens the same way.
`POST .../enrollment-token/{id}/revoke` takes the Manage permission
and is 204, or 404 for an unknown token; revoking a revoked token
changes nothing, as for identities. The DELETE route is gone, and the
token list and read return revoked tokens too.
Issue: DGW-478
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The token-creation 422 for a base URL with credentials rests on DVLS's OpenAPI document and the contract. It was read from DVLS's source, never observed on a running DVLS, so it cites no reference build. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An authority revokes an enrollment token rather than deleting it, so the step that keeps a revoked device out names revocation. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A revoked token's state is Revoked in its record and in the token list, winning over Expired and Exhausted, as in DVLS, so an administrator sees why the token no longer enrolls. The README notes that the mock revokes a token where DVLS's OpenAPI document still deletes it. A revoked token that has also expired and used up its uses still answers token_invalid, the first of those outcomes in the contract's order, and a DELETE on a token leaves it readable. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DVLS's intent decides that an enrollment whose config cannot be built answers unavailable and creates nothing, and the contract's error table gives that 500; the contract counts token uses in §2.1, not §5.4. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
URL parsers disagree on forms such as https://@host, so the contract reads a token's u as text: it begins with https:// and contains no @. Token creation applies the same reading to the mock's base URL, so an @ anywhere, even in the path, or a missing https:// is 422 and creates no token. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tion tests The expired and exhausted states a token reports before revocation follow from the contract's token rules, and DVLS's intent says tokens are never deleted, which is what the no-delete test checks; the shared intent decides neither, so its marker goes. The token URL doc and the README state the textual rule as the contract does. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
URL parsers disagree on forms such as `https://@host`, `https:host` or `https:\@host`, which the `url` crate accepts as `https` URLs without a user name. A token's `u` and `agent_channel_url` must now begin with `https://` and contain no `@` anywhere before the parsed URL is checked, so the agent judges them by the same text the authority wrote. The prefix is compared as written, so `HTTPS://host` is refused, and an `@` in the path is refused too. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The link used the heading's generated slug, which changes when the section is renumbered or renamed; the section carries the stable `enroll` anchor. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stored base URL is the token's `u`, which is read as text, so the stored-identity check states the text-only rule with its own row instead of relying on the predicate it shares with token parsing. The rule's doc says the prefix is compared as written. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An enrollment token adds nothing to an identity's config: settings follow an administrator's decision about each device, not the token it used. Token creation has no config member, so the suite no longer sends one or checks that a token's config reaches the identities it enrolls. Product-specific config fields stay covered through the mock's config control. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token that is no longer wanted is revoked, not deleted: it stays
stored with its name, so that a lost enrollment response stays
recoverable and its identities keep showing it. The management API
revokes a token with `POST .../enrollment-token/{id}/revoke` and has no
DELETE route for tokens.
The suite revokes where it deleted, and checks what revocation
promises: a revoked token stays readable and listed, its state is
`Revoked` whether it was exhausted or expired, revoking it again is
`204` and changes nothing, revoking an unknown ID is `404`, and the
replays and refused enrollments of a revoked token leave its record
unchanged. The state checks run last, so that a wrong state does not
hide the other outcomes.
Issue: DGW-478
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract authenticates a token by its secret alone, accepting only its canonical base64url form; it no longer speaks of the exact secret text. The secret check quotes the current rule and says that this rule is what refuses a rewritten secret decoding to the same bytes. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token's `u` carries no user name or password, and the agent rejects a malformed token locally as a permanent outcome. The permanent-error check gains a token whose bag decodes and whose secret is valid, but whose `u` names the authority with a user name: the authority must see no enrollment, and the agent must record the local permanent outcome. Every permanent case now also checks the code its rejection marker records: the contract error code, or `token_malformed` for a token the agent rejects locally. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An enrollment whose identity was revoked before the agent received the response is a permanent outcome, and its rejection marker records the contract error code. The check now reads the marker and expects `identity_revoked`, as the other permanent outcomes expect theirs. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract reads a token's `u` as text: it begins with `https://` and contains no `@`, and any `@` counts as a user name, because URL parsers disagree on forms such as `https://@host`. A `user@` host alone does not test that rule, since every URL parser already sees a user name in it. The permanent-error check gains a `u` with an empty user name and one with an `@` in its path, built by the same bag rewrite as the `user@` case, which keeps the valid bag fields and secret so that only the new `u` can make the token malformed. The coverage record names these forms, and records that no check covers an `agent_channel_url` holding an `@`, since no mock fault delivers one. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A revoked token stays stored with its name, so revoking it must keep its ID, name, creation time, expiry and use count; the check compares them with the record read before revoking, not the use count alone. The checks now cite what decides them: the contract's token rules for a second revocation changing nothing, and DVLS's intent for the `Revoked` state winning over expired and used up, beside the mock's intent, since the same checks run against DVLS. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The coverage row for a token's `u` names the forms its checks use, including the empty user name, and the known gaps form one list. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A check that reads a rejection marker now names the marker's path when the file cannot be read, so a failure says which token's marker is missing instead of only the operating system's error. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The `@`-in-path case appends a path to the target's base URL. A base URL given with a trailing slash would otherwise yield an empty path segment, which is a second malformation the case does not name, so trailing slashes are trimmed first. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The earlier pin of DVLS's intent says a token's deletion is its only change and that Manage deletes tokens, which contradicts the checks that now revoke them. Markers whose checks touch tokens move to a pin that says revocation is the only change and that Manage revokes tokens; every other statement they rest on reads the same there. The checks moved are the token record and states, token expiry, the friendly-name format, the secret disclosure helper, and the two route authorization checks, which call the token revocation route. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tokens are immutable after creation and revocation is their only change, so a revoked token's record must match the record read before revoking in every member but its state, including its use limit, creator and friendly-name format, not only five chosen members. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A known authority ID claimed from another base URL is accepted only through a chain to a held root, and no test decided whether that chain check counts a self-issued CA against a pathLenConstraint, refuses a critical keyUsage it cannot read, or verifies a signature under an algorithm other than the one the certificate names. Each new case fails when the check it names is dropped from the chain verification. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
They build their descriptors through the Windows SDDL conversion, a foreign call Miri can't run, like the crate's other tests that call Win32. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The head moved from
Correction to the System proxy gap listed above: identity requests and the channel fail rather than connect directly when a System proxy can't be read or used ( New untested properties, one per
Note Human-tuned, LLM-assisted content. |
Devolutions Agent can obtain its own device identity from an authority such as Devolutions Server: a server-issued identity ID and an X.509 certificate for a P-256 key that never leaves the machine.
One reusable enrollment token, deployable with
identity enroll -or the MSI'sIDENTITY_ENROLLMENT_TOKENproperty, enrolls any number of machines.After enrolling, the agent renews its certificate on its own and keeps an authenticated, bidirectional gRPC channel to the authority, so the authority can push config changes and ask for a renewal; while no channel works, the agent checks in over HTTPS instead.
The agent-facing protocol is product-neutral and specified in
docs/agent-identity/CONTRACT.agent-identity.md: the enrollment token, the HTTP API, an RFC 9421 request-signature profile, certificate rules, the gRPC channel and its proof-of-possession handshake, and renewal and root rotation.Design decisions shared by every component are in
docs/agent-identity/INTENT.agent-identity.md, and each crate has its ownINTENT.md.docs/agent-identity/test-vectors.jsonholds signature, channel-proof and CSR vectors for every implementation, checked independently byverify_vectors.py.Structure:
device-key: non-exportable P-256 device keys, in the Microsoft Software Key Storage Provider on Windows or as protected PKCS#8 files.agent-identity-httpsig: signs the agent's requests and channel openings withhttpsig, under the contract's profile.agent-identity: tokens, pending-enrollment files, stored identities and their trust checks, schedules, CSRs and the HTTP client.agent-channel-protoandagent-channel-client: the channel schema, also published as theDevolutions.Agent.ChannelNuGet package for .NET authorities, and the client that opens, authenticates and keeps the stream.devolutions-agent/src/identity: theidentity enrollcommand, theIdentityconfiguration section and the service task that enrolls, renews, checks in and runs the channel.package/AgentWindowsManaged: the MSI's enrollment property, and cleanup of identities and their machine keys on full uninstall.agent-identity-mockandagent-identity-conformance: a mock authority and a conformance tester that checks an authority (the mock or a real DVLS) and the agent against the contract; the testsuite runs both.http-client-proxy: proxy selection per URL, shared by identity requests, the channel and the agent's other outbound HTTP.The operator guide is
docs/agent-identity/README.md.Agent Tunnel's existing
enrollcommand and files are unchanged.Issue: DGW-478