Repository navigation
fix(agent): harden the Agent Identity client - #2177
Conversation
A token passed as an argument shows up in process listings and shell history, so `identity enroll` takes it only from standard input, and a token kept in a file is passed by redirection. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resetting the owner of an identity directory cannot revoke a handle its creator opened before the reset, so such a directory stops identity processing until it is fixed instead of being repaired in place. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…te file An untrusted rejection marker or in-progress record is treated as absent and never deleted as cleanup; the agent writing its own file at that path replaces it, which opens nothing, since whoever wrote it could delete it anyway. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tificate Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A URL parser removes tab, carriage return and line feed anywhere in a URL and trims leading and trailing control characters and spaces, so a token's `u` or a config's `agent_channel_url` holding them was accepted as a different URL than its text. Both values are read as text, so the agent now refuses any character outside printable ASCII before parsing them: a token with such a `u` is malformed, and such an `agent_channel_url` is treated as absent with a warning. The ASCII check that only `u` had moves into the shared parser, so `agent_channel_url` gets it too. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent channel classified a closure as terminal or expired from its `error-code` trailer alone, whatever gRPC status came with it. The Agent Identity contract pairs each code with one status: `PERMISSION_DENIED` with `identity_revoked`, and `UNAUTHENTICATED` with `identity_unknown` or `certificate_expired`. The channel now ends the identity or renews only on those exact pairs, as the HTTP client already does, and treats any other pair as a transient outage. Likewise, a `clock_skew` refusal adjusts the clock only when it comes with `UNAUTHENTICATED`, the status of an authentication failure at opening. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token passed as an argument shows up in process listings and shell history, so `identity enroll` now accepts only `-` and reads the token from standard input. Any other argument is refused, and the error never repeats it because it may be a token. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent's `identity enroll` command reads the token only from standard input and refuses any other argument without echoing it. The suite now submits every token through `identity enroll -`, and a new test checks that a token passed as an argument, alone or after `-`, is refused with a nonzero status, leaves no pending-enrollment or in-progress file, and appears on neither output stream, while the same token on standard input is accepted. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a `clock_skew` answer, the agent retried a signed identity request dated by the `server_time` the answer carried, however far that was from its own clock. The agent now retries only when the offset is at most one day either way; beyond that it returns the `clock_skew` error, a transient failure, without a second request. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new renewal of an identity starts no sooner than ten minutes after the previous renewal of the run was confirmed, so that an authority cannot keep the agent renewing in a tight loop, for example with certificates whose lifetime is zero. The wait applies however the renewal is triggered: by the schedule, by an authority request, or after `certificate_expired` on a stream or a check-in. Retries and the confirmation of a renewal already started are not delayed, and the first renewal of a run is never held back. A pending-key recovery that starts again before any renewal is confirmed waits the renewal retry backoff, which grows until a `confirm` succeeds, up to the ten-minute wait, so that it stays within the renewal grace of a leaf of at least 15 minutes. The first recovery still starts at once, because the expired pending certificate stays within its renewal grace only for its own lifetime. `renewal_after_secs` keeps its exact schedule for tests, and the new `__debug__.identity.min_renewal_interval_secs` setting replaces the wait, so that tests can renew several times in one run. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent logged the authority's text as received: the message of the gRPC status that closes a stream, the `reason` of `RenewRequested` and `Reconnect`, and the `error-code` trailer. That text could hold line breaks or terminal escapes, and its length was unbounded. - The status message and both reasons are now escaped as Rust's `Debug` does, quotes aside, and cut at 256 characters with a `[truncated]` marker, without splitting a character. - An `error-code` trailer is kept only in the form of an error code: snake_case ASCII starting with a letter, at most 64 bytes, and not starting with the enrollment token prefix, the rule the HTTP client already applies. Any other value is recorded as invalid, without its text. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the authority refuses a channel opening with `clock_skew`, the agent signs one retry offset by the authority's `server-time`. It accepted any offset, however far from its own clock. The agent now retries only when the authority's time is within one day of its own; beyond that, the refusal stands and the channel reopens after its usual backoff, as the HTTP client does. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At start-up, the agent repaired its identity directories, `identity`, `authorities`, `keys`, `rejected` and `pending`, whatever their owner, taking ownership of one another account owned. That account can keep a handle opened before the repair, which still changes the directory afterwards, so a reset doesn't make it private. private-fs gains `Existing::RepairAccess`, which brings an existing directory's access into line with its rule but refuses an owner the rule doesn't allow before changing anything, deciding through the same handle it repairs through. On Windows, a directory whose owner can't be read is left unchanged too. Provisioning uses it: a directory with wrong permissions is still reset, and one owned by another account fails start-up with an error naming the directory. Start-up keeps retrying on its usual interval without repairing, so identity processing stays stopped until an administrator fixes the owner, then resumes without a restart. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e renewal wait The agent waits between renewals of one identity in one run, so that an authority cannot keep it renewing in a tight loop. `a_renew_transient_failures_retry_without_state_change` renews three times in one run without `renewal_after_secs`, which that wait would push past the case's deadline. The case now sets `__debug__.identity.min_renewal_interval_secs` to 0 in its agent configuration, so its renewals still follow one another; every other case keeps the default wait. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the current user on Windows Some cases write a pending-enrollment file before the agent's first start, so the suite creates the identity and pending directories itself. Under UAC elevation, a new directory's default owner is Administrators rather than the current user, and the agent refuses an identity directory owned by another account and never repairs it. On Windows, the suite now creates each missing directory owned by the current user, with the protected DACL it already gives pending files, inherited by what is created inside. Existing directories, such as those the agent provisioned, keep their owner and DACL. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Acceptance evidence (head
|
The test withholds SYNCHRONIZE from the directory so that private-fs can't open it for a check, but reading the directory's security through `std` asks for SYNCHRONIZE too, so an elevated run failed before reaching the repair. The test now reads that security under the backup privilege, as its neighbour does. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t-only lifetime The certificate lifetime setting is at least 15 minutes, so the expiry-boundary test's leaves of 10 to 30 seconds come only from the test-only lifetime DVLS uses in its unsafe test mode. `--leaf-lifetime-secs` therefore matches the certificate lifetime setting, or the test-only lifetime when DVLS uses it. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent starts a renewal no sooner than a set wait after its previous renewal completed, so that an authority cannot keep it renewing in a tight loop. The new mock case shortens that wait to 15 seconds through `min_renewal_interval_secs` and lets one requested renewal complete. Once the agent has held one connected stream with no further authenticated connect for 1.5 seconds, the case pushes another request, at least 4 seconds before the wait ends. The reference for the wait is the later of the moment before the first request and the start of the last poll of the mock's requests that showed no confirm, since the agent completes the renewal only after its confirm arrives. The next renew must arrive no sooner than the wait after that reference and within the wait plus 10 seconds after the second request. In the mock's event order after the second request, no stream may open or authenticate and no check-in may arrive before the first renew, so that only the pushed request can have triggered it. Once that renewal settles, the agent must have acknowledged the request within the suite's usual 20-second wait; the case does not require the acknowledgment before the wait between renewals ends, since the contract sets no deadline for it. `poll!` now evaluates its wait once, so that a wait computed from the clock gives the whole duration rather than shrinking on every attempt. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The conformance suite now checks the wait between renewals for a renewal requested on the channel, so the note on the untested wiring names the part that stays untested: a scheduled renewal. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Update (head
|
…nt's keys The `agent-identity` crate takes the extra trusted root, the access grant for the current user and the renewal schedule override as plain parameters; the agent's `__debug__.identity` section that supplies them is defined by the agent, which names each key. The crate's intent and documentation now describe the settings by what they do, so they hold for any caller. Issue: DGW-478 Security: yes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tightens how the Devolutions Agent handles what an Agent Identity authority sends it, how often it renews, and the state it keeps on disk, and records each rule in the intent files and the contract.
error-codethe contract defines; any other pair is a transient outage, and onlyUNAUTHENTICATEDwithclock_skewcarries a clock offset.clock_skewretry, for signed HTTP requests and for channel openings, applies the authority's time only when it is within one day of the agent's clock, so that a signed request is never dated far from the agent's own time.RenewRequestedandReconnect) is escaped and cut to 256 characters, and anerror-codethat isn't a valid code is logged as invalid without its text.uandagent_channel_url, refuse white space, control characters and non-ASCII, so that each URL means exactly its text; a malformedagent_channel_urlis treated as absent.confirmsucceeds waits a growing backoff capped at ten minutes, which keeps it within the renewal grace of a leaf of at least 15 minutes; the contract now requires that minimum. The test-only__debug__.identity.min_renewal_interval_secsreplaces the wait.identity enrollreads the token only from standard input (identity enroll -), so that a token never appears in a process listing or shell history; a token argument is refused without being echoed.private-fsgainsExisting::RepairAccessfor this.A new mock case shortens the wait to 15 seconds and checks that a renewal requested during it starts no sooner than the wait and still starts once it ends.
Its README takes the short leaves of the DVLS expiry-boundary test from DVLS's test-only lifetime, since the certificate lifetime setting is at least 15 minutes.
Issue: DGW-478
Security: yes
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com
🤖 Generated with Claude Code