Skip to content

fix(dotnet-agent-identity)!: harden issuance and nonce retention - #2164

Merged
Benoît Cortier (CBenoit) merged 8 commits into
masterfrom
claude/authority-lib-hardening-2e9b24
Oct 28, 2026
Merged

Benoît Cortier (CBenoit) merged 8 commits into
masterfrom
claude/authority-lib-hardening-2e9b24

Conversation

@CBenoit

@CBenoit Benoît Cortier (CBenoit) commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

An enrollment or renewal decision applies the rules to one CSR key and one stored state, so it now carries what it was made from.
A decision to create an identity carries the token's use count it was decided from and the SPKI SHA-256 of the CSR key; a decision to issue a pending certificate carries the key's digest and the renewing identity.
Both IdentityCertificateIssuer.Issue overloads refuse a CSR whose key is not the decision's, so a certificate is issued only for the key that the rules were applied to.
The README states the host rules that the concurrency properties rest on: a unique index on the SPKI SHA-256 of every issued key, a use count updated only while it is below the limit, renewals of one identity serialized by a lock on it, and deciding again from the stored state, acting on the new decision, when the index or the count refuses the write.

RequestAuthenticator reads the clock again just before recording a nonce, refuses with clock_skew a request whose freshness window closed in between, and asks the store to keep the pair 60 s past the end of the window, so that a signature can no longer be accepted once its nonce record may be discarded.
The shared intent now states that retention period as a minimum.

IdentityConfig.Create refuses a non-ASCII agent_channel_url, which the contract reads as text like the token's u, and u holds only ASCII; both now share one check that points to the xn-- form.

The package version is 2026.10.27.
A host moving to it changes the following:

  • Pass the request's validated CSR to RenewalRules.Decide(renewing, csr, keyMatch).
  • Pass the RenewalDecision instead of the AuthenticatedIdentity to the renewal IdentityCertificateIssuer.Issue, and only an IssuePendingCertificate one.
  • Pass both Issue overloads the CSR that the decision was made from.
  • Keep a unique index on the SPKI SHA-256 of every issued key, and count a token use only while the stored count is still below MaxUses, or, stricter, still equals EnrollmentDecision.ExpectedIdentitiesCreated, in the transaction that creates the identity; when either refuses the write, decide again and act on the new decision.
  • Store a renewal's pending certificate, insert its key and retire the identity's previous pending certificate in one transaction that first takes a write lock on the identity's record.
  • Let an INonceStore keep pairs until 120 s after expires, instead of 60 s.
  • Expect IdentityConfig.Create to throw for a non-ASCII agent_channel_url.

BREAKING CHANGE: RenewalRules.Decide takes the CSR, and the renewal IdentityCertificateIssuer.Issue takes the RenewalDecision instead of the AuthenticatedIdentity.

Issue: DGW-479
Security: yes

Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com

`RequestAuthenticator` read the clock once, at the window check of
CONTRACT.agent-identity.md §6 step 3, before the registry lookup, and
asked the nonce store to keep each pair until `expires + 60 s`, the last
instant at which that window accepts the signature. A request that
passed the window check just before that instant reached the store
after it, so a store that discards pairs at `retainUntil` could accept
the same signature twice when two copies raced to it across the
boundary.

The window is now checked again just before the nonce is recorded: a
request whose window has closed by then fails with `clock_skew`, the
error of step 3, and records nothing. Step 4 still judges the
certificate at the first reading. The pair is kept 60 s past the
window, `retainUntil = expires + 120 s`, as a margin for the time the
store call takes, so a store whose calls complete within 60 s can still
discard each pair at its `retainUntil`. The channel opening goes
through the same checks.

Hosts' nonce stores now hold each pair 60 s longer.

Issue: DGW-479
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CONTRACT.agent-identity.md §5.2 reads `agent_channel_url` as text like
`u`, which §2 limits to ASCII so that an internationalized host is
written in its `xn--` form. `IdentityConfig.Create` accepted a
non-ASCII URL and kept a host such as `https://bücher.example/dvls` as
given, so an identity's config could carry a channel URL that the
contract does not allow.

`BaseUrl.Normalize`, which checks both the token's `u` and the config's
`agent_channel_url`, now refuses a URL that is not ASCII, and
`EnrollmentToken.Create` drops its own copy of that check. The error
message is unchanged for tokens.

Issue: DGW-479
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CONTRACT.agent-identity.md §2.1 requires every issued key to stay unique
by SPKI SHA-256, concurrent creations to stay within `max_uses`, and one
of two concurrent enrollments or renewals with one CSR key to issue
while the other gets the replay. The enrollment and renewal decisions
are made from a snapshot of the stored state, yet carried neither the
use count nor the key they were made for, and
`IdentityCertificateIssuer.Issue` certified any CSR, so a host could
issue for a key whose lookup no decision saw.

A decision to create an identity now carries `ExpectedIdentitiesCreated`
and `SubjectPublicKeyInfoSha256`. `RenewalRules.Decide` takes the
request's CSR, and a decision to issue a pending certificate carries the
renewing identity and the key's `SubjectPublicKeyInfoSha256`. Both
`Issue` overloads throw `ArgumentException` for a CSR of another key.
The renewal overload takes the `RenewalDecision` instead of the
`AuthenticatedIdentity` and refuses a decision of another outcome; only
`RenewalRules.Decide` produces one, from an authenticated identity.

The README now asks hosts to keep a unique index on the SPKI SHA-256 of
every issued key, to count a token use with an update conditioned on
the expected count in the transaction that creates the identity, and to
decide again from the stored state when either refuses.

BREAKING CHANGE: `RenewalRules.Decide` takes the renew request's CSR,
and the renewal overload of `IdentityCertificateIssuer.Issue` takes the
`RenewalDecision` instead of the `AuthenticatedIdentity`.

Issue: DGW-479
Security: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API of the decisions and the renewal issuance changed, so the
package takes a new version rather than republishing 2026.10.26.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A host whose unique key index or use count refuses a decision decides
again from the stored state and acts on the new decision, discarding any
certificate issued from the old one; a renewal also retires the pending
certificate that the identity holds at commit time, in the same
transaction.
The tests now check that only a decision to issue carries a key digest
or a use count.

Issue: DGW-479
Security: yes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retiring the pending certificate in the renewal transaction leaves one
pending certificate only if concurrent renewals of the identity are
serialized, since the unique key index orders only requests with the
same key, so the host locks the identity's record first.
The use count update leads with the form that fails only at the limit,
and keeps the stricter compare with ExpectedIdentitiesCreated as an
option, because that one refuses every enrollment that another one
overtook and makes many devices enrolling with one token decide again
repeatedly.

Issue: DGW-479
Security: yes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The identity lock in the renewal transaction serializes renewals only as
a write lock held to commit, so the README and the remarks say so.
The re-decide outcome that creates a new identity occurs only under the
stricter use-count update, and the test comments match the nonce store's
boundary and what a wrong count does under that update.

Issue: DGW-479
Security: yes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CBenoit

Benoît Cortier (CBenoit) commented Oct 27, 2026 •

Copy link
Copy Markdown
Member Author

Acceptance evidence

Checked at the pull request head with build.ps1 (0 warnings, warnings as errors), dotnet test on the solution, and ci/check-oracle-markers.ps1.

Check Evidence
A creating enrollment decision carries the decided use count and the CSR key's SPKI SHA-256; refusals and replays carry neither EnrollmentRulesTests.CreateIdentityCarriesTheCountOfIdentitiesItWasDecidedFrom, CreateIdentityCarriesTheSpkiSha256OfTheCsrKey, CarriesNoNewIdentityWhenCreatingNone
A renewal decision to issue carries the CSR key's digest; the other outcomes carry none RenewalRulesTests.IssuePendingCertificateCarriesTheSpkiSha256OfTheCsrKey, and the empty-digest assertions in the replay and refusal tests
Both Issue overloads refuse a CSR with another key, and the renewal one refuses a decision of another outcome IdentityCertificateIssuerTests
A request whose window closes before its nonce is recorded gets clock_skew and records nothing; one still inside it is accepted RequestAuthenticatorTests.RequestWhoseWindowClosesBeforeItsNonceIsRecordedIsClockSkew, RequestInsideItsWindowWhenItsNonceIsRecordedIsAccepted
The nonce pair is kept until 120 s after expires RequestAuthenticatorVectorTests.OkCaseRecordsItsNonceOnceUntilAfterItsWindowCloses
A non-ASCII agent_channel_url or token u is refused UnacceptableUrls rows used by the IdentityConfig and EnrollmentToken tests
No change under docs/agent-identity/, intent files, contract files or crates/agent-identity-conformance git diff --stat origin/master...HEAD on those paths is empty

Test run: 1805 passed, 0 failed.

Deviations and open questions

  • Nonce retention vs. intent wording. The library asks the store to keep each nonce 60 s past the window, as a margin for the store call after the last clock reading. With the intent owner's approval, the shared intent now says it keeps each accepted nonce "at least until" 60 s after its signature expires (d16fa75).
  • Invisible formatting characters in metadata and friendly names are not in this pull request: rejecting them changes the contract, the conformance suite and the agent, so it gets its own pull request.
  • Decisions carry a digest, not the CSR. Holding the CSR itself on the decision would make a mismatch unrepresentable; the digest was kept because the host needs it for its unique index anyway, and the check makes a mismatch fail loudly.
  • DVLS already counts uses with UsedCount < MaxUses and keys issued certificates by SPKI SHA-256, so it meets those two README rules; when it takes 2026.10.27 it needs the API changes listed in the description and, unless it already serializes renewals of one identity, the lock on the identity in its renewal transaction.
  • Non-ASCII agent_channel_url. The contract reads it "as text like u", and u holds only ASCII, so the library refuses it, as the brief for this change asked. The agent's config parsing (crates/agent-identity/src/schedule.rs) applies u's text rules but not its ASCII rule, so it accepts such a URL and lets the url crate convert the host; either the agent or the contract wording needs aligning, outside this pull request.
  • Use count. The README leads with the update that fails only at the limit and keeps the compare with ExpectedIdentitiesCreated as a stricter option, because the compare refuses every enrollment another one overtook, which makes many devices enrolling with one token decide again repeatedly.
  • The renewal Issue overload takes the RenewalDecision rather than the AuthenticatedIdentity, so a renewal certificate, like an enrollment one, is issued only from a decision of the rules.

Size

Added Removed
Production (code and XML docs) 223 55
Tests 298 79
README 25 10

Note

LLM-assisted content (no human feedback).

@CBenoit

Benoît Cortier (CBenoit) commented Oct 27, 2026 •

Copy link
Copy Markdown
Member Author

Intent drift audit: dotnet/Devolutions.Agent.Identity.Authority, a66a019d0..d16fa75ce

Audited against: dotnet/Devolutions.Agent.Identity.Authority/INTENT.md (4 statements), INTENT.agent-identity.md (statements the diff bears on: Security invariants 2, Request authentication 2, 8, 9; the rest out of scope), docs/agent-identity/CONTRACT.agent-identity.md §2 u, §2.1, §5.2 agent_channel_url, §5.3, §6.
No violations or partials.
Paths are relative to dotnet/Devolutions.Agent.Identity.Authority/Devolutions.Agent.Identity.Authority/ unless they start with README.md.

ID Statement (abridged) Status Evidence Note
CONTRACT.agent-identity.md#enroll-agent_channel_url agent_channel_url is read as text like u (§2), so ASCII only Conforms (fixed) Protocol/BaseUrl.cs:25, Bodies/IdentityConfig.cs:100 was accepted before this change
INTENT.agent-identity.md#request-authentication-9 Keep each accepted nonce at least until 60 s after its signature expires Conforms Authentication/RequestAuthenticator.cs:318-320 kept 60 s longer, as a margin for the store call; the intent line now states a minimum
INTENT.agent-identity.md#request-authentication-2 A captured request authentication can't be replayed Conforms (introduced) Authentication/RequestAuthenticator.cs:310, :318-322 UNTESTED: at :306
CONTRACT.agent-identity.md#signatures-3 Step 3 window → else clock_skew; earliest failing step wins; step 6 only after 1–5 Conforms Authentication/RequestAuthenticator.cs:225, :310-315 the re-check is step 3 at a later reading, after 4 and 5 passed
INTENT.agent-identity.md#request-authentication-8 Record a nonce only after signature and body digest are valid Conforms Authentication/RequestAuthenticator.cs:295-322
INTENT.md#authenticated-identity-1 Code acting for an identity takes it as a type only the signature check creates Conforms (introduced) Certificates/RenewalDecision.cs:52-69, Certificates/RenewalRules.cs:54, Certificates/IdentityCertificateIssuer.cs:324, :342 the renewal Issue now takes a decision only Decide makes from an AuthenticatedIdentity
INTENT.md#failure-details-1 Failure details hold no request value Conforms Authentication/RequestAuthenticator.cs:314, Certificates/IdentityCertificateIssuer.cs:347-354, Protocol/BaseUrl.cs:25-30
CONTRACT.agent-identity.md#token-rules-concurrency-1 Every issued key unique by SPKI SHA-256 Conforms (library part) decisions carry the digest; Certificates/IdentityCertificateIssuer.cs:109, :339; README.md:46, :51 the index itself is the host's
CONTRACT.agent-identity.md#token-rules-concurrency-2 Never more than max_uses identities under concurrency Conforms (library part) Enrollment/EnrollmentRules.cs:104-108; README.md:48-49, :51 the conditional update is the host's
CONTRACT.agent-identity.md#token-rules-concurrency-3 Two concurrent same-key enrollments or renewals: one creates, the other replays Conforms (judgement) README.md:51-52, :93, :114 deciding again from the stored state yields the replay or the pending certificate; renewals of one identity take a write lock on it
CONTRACT.agent-identity.md#renew-4 A key never issued gets a new pending certificate, retiring the previous one Conforms Certificates/RenewalRules.cs:61-64, README.md:114
CONTRACT.agent-identity.md#enrollment-token-u u holds only ASCII Conforms Protocol/BaseUrl.cs:25, Enrollment/EnrollmentToken.cs:95 check moved, not changed
INTENT.agent-identity.md#security-invariants-2 The CSR supplies only a verified public key Conforms Enrollment/EnrollmentRules.cs:104-108, Certificates/RenewalRules.cs:63 decisions take only the key's digest from the CSR
INTENT.md (purpose) The library holds nothing about storage, hosting or management Conforms (judgement) README.md:46-52 the README states storage obligations the contract puts on every authority; the library stores nothing

Not covered by intent

  • The 60 s size of the nonce retention margin, which assumes a store call completes within 60 s of the last window reading.
  • New public surface: EnrollmentDecision.ExpectedIdentitiesCreated, EnrollmentDecision.SubjectPublicKeyInfoSha256, RenewalDecision.SubjectPublicKeyInfoSha256, and the RenewalRules.Decide and renewal Issue signatures.

Note

LLM-assisted content (no human feedback).

@CBenoit

Copy link
Copy Markdown
Member Author

Untested properties

  • RequestAuthenticator.AuthenticateAsync, the window re-check before step 6: it must stay the last clock reading and the last await before INonceStore.TryRecordAsync, because the 60 s retention margin covers only the store call. Work awaited between them would let a copy of the signature reach the store after its pair was discarded. When changing this code, keep the check immediately before the store call or widen the margin.

Note

LLM-assisted content (no human feedback).

An authority may keep an accepted nonce past the end of the freshness
window, as the .NET authority library does to cover the time its nonce
store call takes, so the retention period is a lower bound.

Issue: DGW-479
Security: yes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CBenoit
Benoît Cortier (CBenoit) merged commit b90b1ca into master Oct 28, 2026
52 checks passed
@CBenoit
Benoît Cortier (CBenoit) deleted the claude/authority-lib-hardening-2e9b24 branch October 28, 2026 05:29
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.

1 participant