Repository navigation
feat(agent): device identity enrollment and channel - #2000
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
Each test, vector case and mixed assertion now names its source with an oracle marker: the contract's signature profile, URL prefix and channel sequence, the crate intent, or the RFCs and tower documentation that decide the value. The path expected for a base URL whose path ends with a slash is left unmarked: no document states how that slash is treated. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…aches the transport The test only checked that the call returned an error, so a layer that forwarded the unsigned opening and then returned the signing error passed. It now also records whether the inner service was called and requires that it was not, since a channel opening carries signature metadata. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…y rule The vector test compares Signature-Input and the signature base byte for byte, including a parameter order the contract leaves free; the shared intent's conformance rule is what requires reproducing them. The structured-field bound test also relies on expires being created plus 60 seconds, which the contract's signature profile sets. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…eral trailing slashes The contract removes every trailing slash from agent_channel_url before prepending its path, but the tests only used a single slash, so a layer that dropped just one passed. The channel-layer test now also opens `https://example.test/dvls//` and `https://example.test//`, which must give the same path as without the slashes. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract's URL and path-prefix section now says the agent removes trailing slashes from agent_channel_url before prepending its path, so it decides the path expected for `https://example.test/dvls/` and for the site root `https://example.test/`. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Several checks can find a completion due at once; the log records the rotation once, so it counts rotations rather than checks. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A first channel message that fails to decode carries no readable proof, so it fails authentication with signature_invalid, ahead of the metadata checks, as authentication errors do on HTTP. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Tests, code comments and other documents cite contract sections; an anchor keeps each citation valid when a heading is reworded or a section renumbered. Anchors only; no rule changes. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
After the handshake, a message the server cannot decode ends the stream with invalid_request rather than a clean close, so a broken peer is visible; the agent reconnects as after any closure. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… as no Hello The contract closes a stream whose first agent message cannot be decoded with UNAUTHENTICATED and error-code signature_invalid, like any other first message that is not a Hello, and leaves the identity's state unchanged. The mock already does so; the test pins it for invalid UTF-8 in a string field and for a truncated message, each sent as a raw gRPC frame. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…th invalid_request The contract closes an authenticated channel stream whose agent message cannot be decoded with INVALID_ARGUMENT and error-code invalid_request, so that a broken peer does not look like a clean close. The mock ended such a stream with OK, because tonic's prost codec fails the stream with the same kind of status for an undecodable message as for a broken transport. The channel now uses its own codec, which passes each agent message to the stream decoded or with its decoding error, so that only an undecodable message gets invalid_request; a transport failure still closes the stream with OK, and an undecodable first message is still signature_invalid. 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 side accepts envelopes up to 64 KiB of serialized protobuf and sends none larger, matching the request body limit. The server treats a larger AgentMessage as one it cannot decode, so the existing outcomes apply: signature_invalid before Welcome, invalid_request after it. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An authority never holds an effective config whose ConfigUpdate envelope would exceed the 64 KiB channel limit, so enroll, check-in and the channel deliver the same configs. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A citation names the file with the anchor, so an anchor only needs to be unique within its file; the agent-identity- prefix repeated the file name. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Each test runs on the first authority that offers what it needs, else SKIP naming the missing capability; a failed agent test keeps its data directory and logs; usage errors never echo a credential. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…sted After a restart the anchor uses the load time, which still never passes notBefore. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A token whose config would not fit one ConfigUpdate is refused. If a later change outside the module makes an identity's config too large, that identity keeps its last config, the others still update, and administrators are warned. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Rename UntrustedAuthorityDirectory to UntrustedStoredIdentity and the authority-directory check to check_stored_identities, so that one error type and one check can also cover an untrusted identity.json. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An identity.json that other users could access, or that was not a regular file, read as absent: its identity's runner stopped until the next restart, and only a fallback in the identity listing kept its token from enrolling again. The trust check of the authority directories now covers each directory's identity.json with the same rules, on Windows exactly the DACL the agent provisions, so such a file stops all identity processing through the existing stop path, is never repaired, and processing resumes once it passes. The service log names the path and the fix, and the operator guide lists that message. Issue: DGW-478 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The non-canonical base64 test looks for a CSR whose canonical encoding has padding and a + or /, and retried by signing the same request with the same key. ECDSA signing here is deterministic (RFC 6979), so every attempt gave the same CSR and the test gave up in a large share of runs before sending anything. Each attempt now takes a new key, and the test keeps the key it found for the control enrollment. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent's refusal of a leaf naming another URL now also covers a URL differing from the token's only by a trailing slash or by an upper-case scheme and host, which a URL parser would read as the same. The check that an authority ignores other requested extensions places the extra extension both before and after the enrollment URL, so a reader taking the first or last extension of the request fails it. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng CSR URL An enrollment CSR without the enrollment URL extension, or naming another URL than the token's u, now gets the permanent code token_url_mismatch rather than invalid_request, which the agent retried as its own bug, since a token used at an address it does not name can never work there. The authority checks expect 403 token_url_mismatch for every such CSR, ahead of the key lookup and the token's state, and invalid_request for a CSR that is also invalid, which the contract orders first. The relayed enrollment is therefore a permanent refusal: the agent sends it once, deletes the pending file, in-progress record and enrollment key, and records token_url_mismatch in a rejection marker, while the authority creates nothing. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntract The trailing-slash and upper-case cases of the enrollment URL checks rested on how a URL parser reads them. The contract decides them directly: the authority refuses a CSR whose value is not the token's u without trailing slashes, and the agent a leaf whose value, read as text, is not. The docs now quote those sentences and §2.1 in full. The case that upper-cased the scheme and host is named for the scheme alone, since the mock's IP host has no case; it now upper-cases only the scheme, through one helper both sides share. The CSR vectors with a bad self-signature or SHA-384 are not refused first for their missing enrollment URL extension: §2.1 orders invalid_request before token_url_mismatch, so the enrollment test that sends them isolates their rules. The coverage record drops that gap and lists the new leaf cases. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DVLS names the enrollment token's lifecycle field `status`, the same name identities and certificates use. The mock emits `status` in its token responses and names the Rust accessor and its tests the same way; the values and their precedence are unchanged. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DVLS names the enrollment token's management-API member `status`, the same name identities use. The suite parses that member and the Rust items and failure messages follow. No assertion changes. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its four base URLs are rows of the malformed-token test, which also checks that the error never holds the token. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t URL OID The contract now states the enrollment URL extension OID itself, so the constant's documentation names the contract as the source of the value instead of describing a stand-in for another OID. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… their rules The `bad_self_signature` and `ecdsa_with_sha384` vectors carry an enrollment URL extension naming `https://example.test/dvls`, which is not the target's `u`. At enrollment they would draw `token_url_mismatch` if `invalid_request` did not come first, and §2.1 orders it first, so the coverage note names the URL mismatch rather than a missing extension. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract now names 1.3.6.1.4.1.32473.1.1 as the enrollment URL extension OID, so the constants no longer stand in for another one. Their doc comments cite the contract, and the mock README drops the note that its OID differs from the contract's. The agent's CSR test compared the extension's OID with the constant that produced it, so a wrong constant passed; it now compares it with the dotted OID the contract states. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract has the agent's CSR carry one attribute, an extensionRequest holding only the enrollment URL extension, and has enrollment refuse a CSR without it. csr.valid had no attributes, so it no longer showed what an agent sends, and the refusal vectors failed the URL rule as well as their own. csr.valid, bad_self_signature and ecdsa_with_sha384 now request https://example.test/dvls, named in a new enrollment_url field, and are what the cryptography package's CSR builder generates from seeded keys with RFC 6979 nonces. verify_vectors.py regenerates them, checks them with its own DER reader, and checks that its hand-built DER matches the builder's. valid_with_empty_extension_request keeps its bytes. A CSR's outcome now depends on the endpoint, so each entry's expected holds an enroll and a renew outcome: the pinned entry is token_url_mismatch at enrollment and accepted at renewal. The mock checks the renewal outcome and the URL its oracle reads against the vectors, and runs every vector through enrollment. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Readers outside this repository take a csr entry's expected as the string outcome of the section 4 CSR checks. Each entry keeps that string, and its outcome at enrollment and at renewal moves to a new expected_at object, so the per-endpoint outcomes are an additive change. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… URL A CSR that requests the enrollment URL extension more than once, in one extensionRequest or several, or not as an IA5String, names no enrollment URL, so an authority refuses it at enrollment with token_url_mismatch even when every instance holds the token's u. The enrollment URL test now sends each such shape, with the IA5String and UTF8String instances in both orders so that a server reading only the first or only the last instance is caught. A renewal CSR requesting the URL twice still renews, because renewal ignores the value. The CSR builder takes one extension list per extensionRequest attribute so a CSR can carry several. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A CSR that requests the enrollment URL extension more than once, in one extensionRequest or several, or not as an IA5String, names no enrollment URL, so its enrollment is refused with token_url_mismatch and creates nothing. The CSR reader took the first well-formed instance, which let a CSR naming the right URL once and another elsewhere enroll. Renewal still ignores the value. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token's u holds only ASCII characters, since certificates carry it as an IA5String, so a mock whose --path-prefix holds a non-ASCII character now answers token creation with 422 and creates no token, as it does for a base URL with an @. Before, it created the token and every enrollment with it failed with 500 when the leaf could not carry the URL. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hoice The contract doesn't decide how an attribute that isn't DER affects reading the enrollment URL, so the test that the reader stops at such an attribute pins the mock's choice and is marked as a mutant guard rather than citing the contract. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CSR reader stopped at the first attribute it could not frame and counted only the enrollment URL instances before it, so a CSR holding one instance, then such an attribute, then another instance named a URL and enrolled, while it requests the extension twice. Every container the reader walks to count instances, the attribute set, an extensionRequest's fields, its values and each Extensions list, must now be framed to its end, or the CSR names no URL. Unrelated extensions' values stay unread, and the BER bounds are unchanged. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al URLs The enrollment refusal test quotes the contract's rule that a CSR requesting the enrollment URL extension more than once or not as an IA5String names no URL, and the renewal case that repeats the extension names two different URLs, so renewal is seen to ignore both. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CSR reader takes one-byte tags, so it read a high-tag-number tag such as BF 81 nn as tag BF with a long-form length that swallowed the next extensionRequest or extension, and a CSR requesting the enrollment URL twice then counted one instance and enrolled. A first tag byte with the low 5 bits set (X.690 section 8.1.2.4) now makes the item unreadable, so the CSR names no URL. The test of items the reader cannot frame now covers each level it walks: the attribute set, an extensionRequest's fields, its values, an Extensions list, and an attribute or extension whose type cannot be read. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The renewal case that repeats the enrollment URL extension names two URLs that both differ from the mock's, so a renewal that read the first instance instead of ignoring the value would still be caught. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ccepts The reader also refuses indefinite lengths and lengths of more than four bytes, which BER allows. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The head moved from
New untested properties, one per
Note Human-tuned, LLM-assisted content. |
…ity-v1 Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The outer-timeout cleanup selected agent processes by the `sess` column of `ps`, which is not portable: on macOS it prints the kernel session pointer from `struct eproc` instead of a session ID, so no agent matched and the agents outlived the runner. List PIDs only and keep those whose `getsid` equals the runner, which is the check the cleanup already ran before each kill. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`ps` lists the kernel as PID 0 on macOS, and getsid and kill treat 0 as the caller itself, so the session sweep must never pass it on. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The head moved from
The untested-properties list from the earlier comment loses one line, because CI now compiles and tests the macOS code:
These two still hold, because no test sets "Ignore ownership on this volume" or adds an extended ACL:
Note Human-tuned, LLM-assisted content. |
An enrollment token still gives an identity no config by default, but an authority may let it carry assignments it recognizes, such as tunnel agent of one Gateway, and apply them to each identity the token enrolls as an administrator's assignment right after enrollment. This lets an administrator pre-configure a small batch of machines known to be online, and suits only a short-lived token whose uses match those machines, because anyone holding the token receives the assignments. Issue: DGW-478 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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