Skip to content

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

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

Benoît Cortier (CBenoit) wants to merge 351 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 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's IDENTITY_ENROLLMENT_TOKEN property, 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 own INTENT.md.
docs/agent-identity/test-vectors.json holds signature, channel-proof and CSR vectors for every implementation, checked independently by verify_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 with httpsig, 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-proto and agent-channel-client: the channel schema, also published as the Devolutions.Agent.Channel NuGet package for .NET authorities, and the client that opens, authenticates and keeps the stream.
  • devolutions-agent/src/identity: the identity enroll command, the Identity configuration 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-mock and agent-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 enroll command and files are unchanged.

Issue: DGW-478

@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).

@CBenoit
Benoît Cortier (CBenoit) force-pushed the cbenoit-agent-identity-v1 branch 3 times, most recently from a2184c8 to 12dfd95 Compare October 19, 2026 19:01
@CBenoit
Benoît Cortier (CBenoit) added this pull request to stack #2126 October 20, 2026 14:01
@CBenoit
Benoît Cortier (CBenoit) force-pushed the cbenoit-agent-identity-v1 branch 3 times, most recently from 21736c7 to 3d2365e Compare October 21, 2026 20:42
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>
@CBenoit

Copy link
Copy Markdown
Member Author

The head moved from 17cfc500 to 7a81460d to address the review threads and the token and URL changes in the contract; CI run 40044160791 is green (50 jobs; skipped: Upload git-log output, OneDrive).

  • Conformance against the mock, locally on Windows at b932e049: file backend 180 PASS, 0 FAIL, 1 SKIP, 2 N/A, one test fewer now that a token carries no config; with the Windows software provider unelevated, 129 PASS and 52 SKIP needing elevation, which CI's elevated tests [windows x64] job runs.
  • Mutation testing on the moved security checks, before → after (caught/missed/unviable):
    • Windows owner, protected-DACL and allow-entry reading: 35/24/16 of 75 → the shared reader 11/0/7 of 18, its callers 32/20/13 of 65.
    • Unix directory chain, directory sync and effective UID: 16/24/3 of 43 → private-fs 9/6/2 of 17, its callers 13/10/2 of 25.
    • No mutant caught before whose code moved is missed after.
  • Mutation testing on the new code: the authority-conflict chain check and the URL rules 34/0/6 of 40, plus 13 hand-written domain mutants, all caught; the mock's token revocation and URL check 32/0/4 of 36, plus 9 hand-written, all caught.
  • Dependencies: one internal path crate, private-fs (camino and libc, both already in the tree); no new external package.
  • rejects_a_base_url_with_a_user_name_or_password in crates/agent-identity/src/token.rs repeats four rows of rejects_every_malformed_token_locally_without_exposing_it and can be deleted.
  • When an authority moves its base URL, the agent verifies only ecdsa-with-SHA256 signatures by P-256 CA keys on the new chain.
  • The conformance suite revokes tokens with POST …/enrollment-token/{id}/revoke, so a run against DVLS needs that operation in DVLS.

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 (ae50719f, bfec9f73); the remaining System-mode edge cases, ALL_PROXY against NO_PROXY and NO_PROXY=*, go with the PAC/WPAD ticket.

New untested properties, one per UNTESTED: comment added:

  • crates/agent-identity/src/state.rs: the held identity is read, checked, and replaced or kept under one state lock.
  • crates/private-fs/src/lib.rs: a directory that another process creates between the check and create counts as created.
  • crates/private-fs/src/lib.rs: each created directory and its parent are synced before the next one is created.
  • crates/private-fs/src/macos.rs: a volume with "Ignore ownership on this volume" set is detected, and the boot volume isn't.
  • crates/private-fs/src/macos.rs: a file with extended ACL entries is detected, and one without isn't.
  • crates/win-api-wrappers/src/security/descriptor.rs: an owned descriptor is freed exactly once, and a borrowed one never.
  • devolutions-agent/src/identity/mod.rs: the authority-conflict warning names the authority but no base URL and no token material.
  • CI compiles neither the agent crates nor private-fs for macOS; the macOS code was only type-checked locally with clippy for aarch64-apple-darwin.

Note

Human-tuned, LLM-assisted content.

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.

3 participants