Repository navigation
fix(dotnet-agent-identity)!: harden issuance and nonce retention - #2164
Conversation
`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>
Acceptance evidenceChecked at the pull request head with
Test run: 1805 passed, 0 failed. Deviations and open questions
Size
Note LLM-assisted content (no human feedback). |
Intent drift audit:
|
| 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 theRenewalRules.Decideand renewalIssuesignatures.
Note
LLM-assisted content (no human feedback).
Untested properties
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>
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.Issueoverloads 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.
RequestAuthenticatorreads the clock again just before recording a nonce, refuses withclock_skewa 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.Createrefuses a non-ASCIIagent_channel_url, which the contract reads as text like the token'su, anduholds only ASCII; both now share one check that points to thexn--form.The package version is 2026.10.27.
A host moving to it changes the following:
RenewalRules.Decide(renewing, csr, keyMatch).RenewalDecisioninstead of theAuthenticatedIdentityto the renewalIdentityCertificateIssuer.Issue, and only anIssuePendingCertificateone.Issueoverloads the CSR that the decision was made from.MaxUses, or, stricter, still equalsEnrollmentDecision.ExpectedIdentitiesCreated, in the transaction that creates the identity; when either refuses the write, decide again and act on the new decision.INonceStorekeep pairs until 120 s afterexpires, instead of 60 s.IdentityConfig.Createto throw for a non-ASCIIagent_channel_url.BREAKING CHANGE:
RenewalRules.Decidetakes the CSR, and the renewalIdentityCertificateIssuer.Issuetakes theRenewalDecisioninstead of theAuthenticatedIdentity.Issue: DGW-479
Security: yes
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com