Skip to content

feat(dotnet-agent-identity): add the authority library - #2125

Open
Benoît Cortier (CBenoit) wants to merge 92 commits into
cbenoit-agent-identity-v1from
cbenoit-agent-identity-authority-library
Open

Benoît Cortier (CBenoit) wants to merge 92 commits into
cbenoit-agent-identity-v1from
cbenoit-agent-identity-authority-library

Conversation

@CBenoit

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

Copy link
Copy Markdown
Member

Add Devolutions.AgentIdentity, the .NET library for what every Agent Identity authority must do the same way under the agent-identity contract, so that DVLS and PSU cannot drift apart on the agent-facing protocol.
The host keeps storage, hosting, management, permissions and the root key at rest, and supplies the interfaces the library calls: a certificate registry looked up by thumbprint, a nonce store, a root signer, a clock and a random number generator.
The library has no ASP.NET Core dependency and hosts no endpoint or gRPC service.

The library covers:

  • request authentication per contract §6, steps 1 to 6 in order, with the error precedence of §5.4:
    • a strict RFC 8941 parser that refuses repeated members, parameters and labels;
    • only canonical base64, keyid and nonce encodings;
    • Content-Digest;
    • a nonce recorded only after the signature verifies;
  • the agent channel handshake of §7.3 and §7.4:
    • a fresh challenge per stream and the proof;
    • a registry re-read when the handshake ends, so that a revocation, deletion or expiry since the opening closes the stream first;
    • one call for each way a handshake ends: a Hello, an undecodable first message, or no Hello in time;
  • enrollment tokens, whose u is ASCII with no @, the SHA-256 of the 32 bytes their secret encodes, accepted only in canonical form, and the enrollment decision of §2.1, which refuses a CSR that does not name the token's u with token_url_mismatch;
  • validation of metadata, request bodies and CSRs, including the enrollment URL a CSR requests and a confirm body, which must be empty;
  • identity certificates issued through the host's root signer, which must be a P-256 or P-384 CA root signing with the ECDSA algorithm of its curve, each naming the identity's enrollment URL in the extension of §4, and the renewal decision of §5.3;
  • the agent-facing response bodies, the identity config, which always fits one 64 KiB channel message, and the channel's push messages;
  • error bodies and gRPC statuses per §5.4 and §7.4, with failure details for host logs that never contain a value from the request;
  • friendly-name evaluation per the shared intent.

The public API is grouped as follows:

  • Authentication:
    • RequestAuthenticator, which returns an AuthenticationResult;
    • AuthenticatedIdentity;
    • SignedHttpRequest, AgentOperation, RegisteredCertificate and CertificateStatus;
    • the host interfaces ICertificateRegistry and INonceStore.
  • Channel:
    • ChannelOpening, with VerifyHelloAsync, HelloTimedOutAsync and FirstMessageUnreadableAsync;
    • ChannelOpeningResult, ChannelHandshake and ChannelHandshakeResult;
    • ChannelMessages, ReconnectReason and RenewalRequestReason.
  • Enrollment:
    • EnrollmentToken and EnrollmentTokenState;
    • EnrollmentAuthorization and its result;
    • EnrollmentRules, EnrollmentDecision, EnrollmentOutcome and IssuedKeyMatch;
    • FriendlyNameFormat.
  • Certificates:
    • CertificateSigningRequest and CsrValidationResult;
    • IdentityCertificateIssuer and the host interface IRootSigner;
    • RenewalRules, RenewalDecision and RenewalOutcome.
  • Bodies:
    • EnrollRequestBody, RenewRequestBody, ConfirmRequestBody and CheckInRequestBody, parsed into a RequestBodyParseResult;
    • AgentMetadata and MetadataValidationResult;
    • IdentityConfig;
    • ResponseBodies.
  • Errors and protocol:
    • AgentIdentityError, AgentIdentityErrorCode and its extensions;
    • AgentIdentityResult, the base of every result type;
    • AgentIdentityProtocol.

Only the signature check and the channel handshake create an AuthenticatedIdentity, the type that code acting for an identity takes, so an identity is never taken from a value the agent or a caller sent.
Likewise, a new identity's certificate is issued only from the enrollment decision that creates it, under the identity ID the library mints, so no public issuance path takes an identity ID.
The RFC 9421 profile is written over the BCL's ECDSA rather than NSign, because NSign's parser keeps the last of repeated parameters, which step 1 forbids, and its request verification is built for ASP.NET Core.
The library is derived from the contract, the shared test vectors and the shared intent alone, as one more independent implementation, and its tests run every shared vector.
Every test names the source of its expected value with an // oracle: marker.

dotnet/AgentIdentity/README.md tells a host developer how to integrate. It covers:

  • the host dependencies;
  • the call order for trust-anchor, enroll, the signed renew, confirm and check-in requests, and the agent channel, including gRPC message size limits;
  • the error mapping;
  • how to build, test and pack locally.

The package lives in dotnet/AgentIdentity/ and targets net10.0.
It is built and tested in the .NET library CI job, and publish-libraries.yml publishes it next to Devolutions.Agent.Channel, on which it depends.

Issue: DGW-479
Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com

@CBenoit

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

Copy link
Copy Markdown
Member Author

Acceptance evidence at db7fa950

  • db7fa950 only moves the sources into folders by contract area: 90 renames at 100% similarity, no content change; the results below hold at it.
  • Clean Release build (bin/obj removed): dotnet build dotnet/AgentIdentity/AgentIdentity.sln -c Release gives 0 warnings and 0 errors.
  • dotnet test dotnet/AgentIdentity/AgentIdentity.sln: 1480 passed, 0 failed.
  • Shared vectors at d9d3ee91, each its own test, all passing:
    • 92 of 92 http_signature cases and 3 of 3 sequences;
    • 16 of 16 ok signature bases byte-equal to signature_base;
    • 4 of 4 CSR vectors;
    • 8 of 8 channel_proof cases, plus the proof input equal to message_hex;
    • RFC 9421 B.2.4.
  • pwsh ./dotnet/AgentIdentity/build.ps1 produces Devolutions.AgentIdentity.2026.10.20 .nupkg and .snupkg, holding the DLL, its XML docs and the README. The only dependency is Devolutions.Agent.Channel 2026.9.25; nothing comes from ASP.NET Core.
  • dotnet/AgentIdentity/INTENT.md is byte-identical to the approved text and was committed once (d9fd8ba1). The INTENT.agent-identity.md symlink has the same blob as the other copies.
  • Intent drift audit of dotnet/AgentIdentity/: every applicable statement conforms. Two partials found at an intermediate head were closed before this head: response bodies, config and renewal rules were missing, and renewal issuance took a raw Guid.
  • Each commit range was reviewed by a model from a different family than the writer's, with a cap of three rounds; every final verdict is approve.

Decisions within the brief

  • Directory dotnet/AgentIdentity/ (accepted):
    • dotnet/ holds hand-written .NET components that no crate or OpenAPI document generates;
    • crates/* is a Cargo workspace glob that needs a Cargo.toml in every directory;
    • utils/dotnet/'s build.ps1 packs everything in its folder under one version cadence.
  • RFC 9421 profile written over the BCL's ECDSA instead of NSign (accepted): one strict parse drives both the checks and the signature base.
  • The handshake takes Devolutions.Agent.Channel message types (accepted).
  • Scope adjusted within the intent sentence. The library also parses the request bodies and writes the response bodies, IdentityConfig, the channel push messages and the §5.3 RenewalRules. A renewal issues through Issue(AuthenticatedIdentity renewing, ...). Every authority must produce these the same way.
  • VerifyHelloAsync and HelloTimedOutAsync re-read the registry, because a host cannot map a stream still in its handshake to an identity (invariant 5). Contract §7.4 adopted this order at d9d3ee91.
  • Classifications decided by a stated rule:
    • Request authentication:
      • a certificate is unexpired while now ≤ notAfter, and notBefore is not checked;
      • signature and digest lengths fail at step 5, as the vectors label them;
      • a method that is not an RFC 9110 token fails at step 5;
      • unavailable maps to gRPC Unavailable.
    • Enrollment:
      • the Authorization scheme is compared case-insensitively, surrounding white space is ignored, and the bag is never decoded;
      • the token's u and the agent_channel_url must not hold user information, and their trailing slashes are removed.
    • CSRs and certificates:
      • CSR subject and attributes are read only as DER elements;
      • the leaf's AKI is the issuer's SKI, so the issuer must carry one;
      • the leaf has an SKI by method 1, a critical KeyUsage and no BasicConstraints;
      • the serial is 16 random bytes, and notBefore is truncated to the second.
    • JSON and enums:
      • request bodies refuse a byte order mark and nesting deeper than 64;
      • config product fields are snake_case at every depth, with no repeated names and no null members;
      • public enums start at 1.

Deviations from the brief

  • No behavioural deviation; the scope adjustments above stay within the intent sentence, as the brief allows.
  • The test vector loader is strict: a new member in test-vectors.json fails the suite until the loader reads it, so that changes to the vector format on the base are noticed rather than skipped.

Open questions

  1. Devolutions.Agent.Channel is not on nuget.org yet, and this package depends on it, so the two must be published together.
  2. CONTRACT.md's Definitions call retired "replaced by a confirmed certificate", but §5.3 also retires a pending certificate when renew issues a newer one. CertificateStatus.Retired mirrors the Definitions wording.

UNTESTED properties

  • The values of the public enums stay the same across releases: AgentIdentityErrorCode, AgentOperation, CertificateStatus, EnrollmentOutcome, RenewalOutcome, RenewalRequestReason, ReconnectReason.
  • Every AgentIdentityError message is a constant sentence (AgentIdentityError.cs:16).
  • A CSR point coordinate of p or more is rejected (CertificateSigningRequest.cs:186).
  • Of two racing handshake calls, exactly one ends the handshake (ChannelOpening.cs:255).
  • The RFC 8941 parser runs in linear time (StructuredField.cs:139).
  • The Content-Digest comparison is constant-time (RequestAuthenticator.cs:261).

Note

Human-tuned, LLM-assisted content.

@CBenoit
Benoît Cortier (CBenoit) added this pull request to stack #2126 October 20, 2026 14:01
Copilot AI balanced review requested due to automatic review settings October 20, 2026 22:32
@CBenoit

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

Copy link
Copy Markdown
Member Author

Test-pruning pass over dotnet/AgentIdentity

The pass used test-pruner in editing mode, with test-oracle-reviewer on every test and Stryker.NET 5.0.0 for the mutants.
Before is db7fa950; after is 93297253.

Before After
Test methods 346 285
Test cases 1480 1569
Test lines 7459 7783
Stryker score (killed + timeout over tested) 75.25% (900/1196) 88.04% (1053/1196)
Stryker survived / no coverage 234 / 62 129 / 14
Domain mutants caught 69/71 71/71
Flaky tests (16 clean runs) 0 0

The Stryker and domain rows were measured at 6cb8e577.
The last three commits add one acceptance case, delete two tests that caught nothing alone, and restructure one theory.
Scoped Stryker runs before and after them show no loss.

  • Caught set: 154 mutants gained, 0 lost.
    One mutant showed as lost: IdentityConfig.IsSnakeCase reads name[i + 1] instead of name[i - 1].
    It is equivalent, because both versions reject exactly the names with two adjacent underscores.
    Applied by hand, it passes on both heads; its earlier timeout came from machine load.
  • Settings: both runs used the same Stryker settings: Standard level, --disable-bail, per-test coverage, the whole library.

Why the suite did not shrink more

Before the pass, the test methods divided into:

  • 324 independent: the contract, the shared vectors or an RFC justifies their expected values, and the skill never deletes those on mutant evidence;
  • 3 self-derived, all protected because they handle untrusted input, so they were strengthened rather than deleted;
  • 22 of unknown oracle: each was the only test catching some mutant, or survived a challenge.

So the size came down through consolidation: 346 → 285 methods, with every input and expected value kept.
Deletion removed only self-derived assertions and two tests of an unspecified single enumeration.
The pass's main yield was coverage: about 90 new cases catch 154 mutants that no test caught before.

Gaps closed

  • Malformed Signature-Input syntax: a non-list member, a trailing comma, a truncated parameter, an unclosed string, a bad escape, and a number or byte sequence that ends early.
    Seven of these mutants turned a malformed header into an unhandled exception or an endless loop; the tests now pin signature_invalid.
  • A covered component carrying a parameter, signed over the list with the parameter stripped, fails step 1.
  • A signature lifetime of exactly 300 s is accepted (§6 step 3).
  • An invalid method can't verify a signature over empty data.
  • Every RFC 9110 token character is accepted in a method.
  • 15-digit integers and negative integers in created.
  • An empty signature still fails the window check first (§6 steps 3 and 5).
  • The RFC 8941 parser and serializer on their own: strings, tokens, decimals, byte sequences, Booleans, and dictionary punctuation.
  • A CSR point with a coordinate outside the field is invalid_request, never a key-import exception.
  • CSR attributes out of DER order are accepted (§4).
  • snake_case boundaries in product fields, metadata membership and non-generic enumeration, and a config nested 64 levels deep in a response body.
  • Every error carries a non-empty message (§5.4).
  • The enrollment token's hash test now computes the SHA-256 of the secret text itself.
Oracle review after the pass

Every changed test cites its source.
The assertions that no independent source can fix, but that alone catch a fault, are marked self-derived:

  • the exact serial bytes;
  • the failure details of two signature_invalid handshake failures;
  • the construction restrictions on result types.
Surviving mutants after the pass

The remaining survivors are classed as follows:

  • unconstrained: wording of failure details and exception messages, which no requirement fixes;
  • equivalent;
  • bug leads: unreachable defensive exits in the parser and the authenticator, plus two Stryker anomalies, one in a static initializer and one replacement that does not compile alone.

Note

Human-tuned, LLM-assisted content.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The new authentication, certificate-issuance, and channel-handshake boundaries require final human security review.

1 open issue
What changed in this PR

Adds Devolutions.AgentIdentity to share agent-facing authority behavior between DVLS and PSU while leaving storage, hosting, and root-key ownership with each host.

Changes:

  • Adds authentication types, enrollment and renewal rules, certificate issuance, and protocol helpers.
  • Adds shared-vector tests and independent test fixtures.
  • Integrates the library into .NET builds, CI, and package publishing.
File Description
dotnet/​AgentIdentity/​INTENT.md Defines the library’s scope and identity boundary.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Protocol/​StrictBase64.cs Enforces canonical base64 encodings.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Protocol/​BaseUrl.cs Validates and normalizes HTTPS base URLs.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Protocol/​AgentIdentityProtocol.cs Defines shared limits, timestamps, and thumbprints.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Errors/​AgentIdentityErrorCodeExtensions.cs Maps errors to wire values and statuses.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Errors/​AgentIdentityErrorCode.cs Defines protocol error codes.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Errors/​AgentIdentityError.cs Builds error bodies and gRPC exceptions.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Enrollment/​IssuedKeyMatch.cs Represents previously issued key matches.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Enrollment/​FriendlyNameFormat.cs Parses and evaluates friendly-name templates.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Enrollment/​EnrollmentTokenState.cs Represents stored token state.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Enrollment/​EnrollmentToken.cs Creates tokens and hashes their secrets.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Enrollment/​EnrollmentRules.cs Applies enrollment and replay rules.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Enrollment/​EnrollmentOutcome.cs Defines enrollment outcomes.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Enrollment/​EnrollmentDecision.cs Carries enrollment decisions.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Enrollment/​EnrollmentAuthorizationResult.cs Carries token parsing results.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Enrollment/​EnrollmentAuthorization.cs Reads enrollment bearer credentials.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Devolutions.AgentIdentity.csproj Configures the package and channel dependency.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Channel/​RenewalRequestReason.cs Defines renewal push reasons.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Channel/​ReconnectReason.cs Defines reconnect reasons.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Channel/​ChannelProof.cs Builds and verifies channel proofs.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Channel/​ChannelOpeningResult.cs Represents opening authentication results.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Channel/​ChannelMessages.cs Builds server push messages.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Channel/​ChannelHandshakeResult.cs Carries handshake results and welcome messages.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Channel/​ChannelHandshake.cs Defines the Hello timeout.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Certificates/​RenewalRules.cs Applies renewal key-reuse rules.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Certificates/​RenewalOutcome.cs Defines renewal outcomes.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Certificates/​RenewalDecision.cs Carries renewal decisions.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Certificates/​IRootSigner.cs Defines the host’s root-signing interface.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Certificates/​IdentityCertificateIssuer.cs Issues identity certificates.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Certificates/​CsrValidationResult.cs Carries CSR validation results.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Certificates/​CertificateStatus.cs Defines certificate lifecycle states.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Bodies/​ResponseBodies.cs Serializes successful endpoint responses.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Bodies/​RequestBodyReader.cs Validates JSON, CSR, and metadata bodies.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Bodies/​RequestBodyParseResult.cs Carries body parsing results.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Bodies/​RenewRequestBody.cs Represents validated renewal bodies.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Bodies/​MetadataValidationResult.cs Carries metadata validation results.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Bodies/​EnrollRequestBody.cs Represents validated enrollment bodies.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Bodies/​CheckInRequestBody.cs Represents validated check-in bodies.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Bodies/​AgentMetadata.cs Enforces metadata syntax and limits.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​VerifiedCertificate.cs Holds verified certificate facts.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​SignedOperation.cs Defines operation-specific signature profiles.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​SignedHttpRequest.cs Represents request authentication inputs.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​RegisteredCertificate.cs Represents host registry entries.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​INonceStore.cs Defines atomic replay protection storage.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​ICertificateRegistry.cs Defines certificate lookup by thumbprint.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​FieldLines.cs Copies and combines HTTP field lines.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​EcdsaP256Signature.cs Verifies raw P-256 signatures.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​AuthenticationResult.cs Carries request authentication results.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​AuthenticatedIdentity.cs Encapsulates a proven identity.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity/​Authentication/​AgentOperation.cs Defines authenticated operations.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Usings.cs Imports xUnit globally.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​VectorData.cs Exposes named vector test cases.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​VectorChannel.cs Creates vector-backed channel fixtures.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​VectorAuthority.cs Runs vectors against a test authority.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​TestVectors.cs Loads and validates shared vector data.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​TestRequest.cs Independently builds signed test requests.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​TestJson.cs Provides independent JSON assertions.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​TestIdentity.cs Obtains identities through authentication.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​TestDoubles.cs Supplies controlled host dependencies.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​TestCsr.cs Builds valid and malformed test CSRs.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​TestChannelProof.cs Independently signs channel proofs.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Support/​TestAuthority.cs Provides signed-request test fixtures.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Protocol/​AgentIdentityProtocolTests.cs Tests timestamps, thumbprints, and limits.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Errors/​AgentIdentityErrorTests.cs Tests error serialization and statuses.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Errors/​AgentIdentityErrorCodeTests.cs Tests error-code mappings.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Enrollment/​EnrollmentTokenAssert.cs Detects token fragments in output.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Devolutions.AgentIdentity.Tests.csproj Configures tests and shared vectors.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​ConstructionTests.cs Checks validated-type construction boundaries.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Channel/​ChannelOpeningTests.cs Tests challenge freshness and retained state.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Channel/​ChannelMessagesTests.cs Tests push-message formats.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Channel/​ChannelHandshakeVectorTests.cs Exercises shared channel-proof vectors.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Certificates/​RenewalRulesTests.cs Tests renewal key decisions.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Authentication/​StructuredFieldTests.cs Tests structured-field parsing and serialization.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Authentication/​RequestAuthenticatorVectorTests.cs Tests signature vectors and nonce handling.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Authentication/​RegisteredCertificateTests.cs Tests rejection of undefined statuses.
dotnet/​AgentIdentity/​Devolutions.AgentIdentity.Tests/​Authentication/​AuthenticationTypeTests.cs Checks authentication construction boundaries.
dotnet/​AgentIdentity/​build.ps1 Builds and packs the library.
dotnet/​AgentIdentity/​AgentIdentity.sln Groups library and test projects.
.github/​workflows/​publish-libraries.yml Adds package publishing.
.github/​workflows/​ci.yml Adds library tests and package builds.

Review effort: Balanced

Comment thread dotnet/AgentIdentity/INTENT.md
@CBenoit
Benoît Cortier (CBenoit) force-pushed the cbenoit-agent-identity-authority-library branch 2 times, most recently from bdc6b96 to 7d13c2c Compare October 21, 2026 04:37
@CBenoit

Copy link
Copy Markdown
Member Author

Oracle markers

Every test, theory row and assertion in Devolutions.AgentIdentity.Tests now names the source of its expected value as // oracle: <kind> <reference>.
The markers follow test-oracle-reviewer as of #2136 d0e9d2fe: paths are written from the repository root, and a section is cited by its anchor where it has one.

Kind Markers
contract 378
spec 243
intent 34
mutant 21
reference 19
scaffold, property 0

That covers 289 test methods and 1649 cases.

Resolving the 59 scaffold markers

None of the markers that resolved them is scaffold.

  • The contract now decides them, as of 562cb02b, which arrives with the rebase onto feat(agent): device identity enrollment and channel #2000:
    • §1: a request body with more than 64 nested objects and arrays, counting the top-level object, or a string that is not valid Unicode, is invalid_request;
    • §4: the leaf carries no extension beyond the listed ones, so it has no basicConstraints.
  • The library intent now decides them (aef12ced):
    • §Failure details: no request value appears in a failure detail;
    • §Enrollment tokens: the bag holds u alone, written compactly.
  • An existing source decides them:
    • intent §Authenticated identity;
    • contract §agent-identity-errors, where unavailable is any 5xx;
    • RFC 5280 §4.2.1.2;
    • the Framework Design Guidelines' "Validating Arguments".
  • Kept as mutant, each named by the fault that only it catches:
    • a public constructor on a result type;
    • a non-empty handshake failure detail;
    • a public producer of an AuthenticatedIdentity;
    • the exact serial octets.
  • Deleted or narrowed to what a source decides. These choices are left to the implementation:
    • leaf-profile details the contract leaves open (the signature hash, the criticality of the extended key usage, the authority key identifier's optional fields, the method for the subject key identifier, no backdating, serial length);
    • entry, root and trailer order;
    • result-object presence checks;
    • which clock reading supplies server_time;
    • URL normalization, which now accepts either rejection or the sourced form;
    • the exception type of a repeated handshake call, now tested through its protocol effect;
    • correlation_id on unsolicited messages;
    • lone surrogates in friendly names;
    • assertions that could never fail.

Library changes

  • ConfirmRequestBody.Parse rejects a non-empty confirm body with invalid_request, as contract §5.5 requires as of 23fabf51.
  • Metadata failure details name an entry by its position. They never quote a key, even a valid one, nor give a control character's code point, as intent §Failure details requires.

Mutation evidence

Stryker.NET 5.0.0 ran over the whole library, with identical settings before and after each change:

Pass Caught before Caught after
Deletions 1035 1035
Narrowing 1036 1036

No caught mutant was lost.
Every domain mutant whose killers include an edited test is still caught.
Hand-applied mutants confirm each new mutant marker and each narrowed helper.

Reviews

Every review below used gpt-6.1-sol, a different model family from the authors.

  • test-oracle-reviewer round 1: nine blocking findings, all on assertions that went past their source, all fixed.
  • Round 2: eight of the nine were resolved. Two findings remained, one partly resolved and one new; both were fixed and checked by a fresh post-cap reviewer: approved, with no further findings.
  • Code review of the confirm and failure-detail changes: approved after one fix.

Note

Human-tuned, LLM-assisted content.

@CBenoit
Benoît Cortier (CBenoit) force-pushed the cbenoit-agent-identity-authority-library branch from 1078eb5 to 45f16fa Compare October 22, 2026 02:39
@CBenoit
Benoît Cortier (CBenoit) force-pushed the cbenoit-agent-identity-authority-library branch from dd95884 to 0fe5d6f Compare October 22, 2026 11:39
@CBenoit

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

Copy link
Copy Markdown
Member Author

Evidence

Head ea600507, rebased on #2000 15fc4910, CI 40165045879.

  • Tests: 1786 cases in 291 methods, all passing; the Release build has 0 warnings.
    • Every case and sequence of docs/agent-identity/test-vectors.json returns its expected value, the channel-proof vectors included, and every CSR its expected_at outcome at enroll and at renew.
    • Consolidating the duplicated tests went from 300 to 275 methods and kept every case; the case count rose from 1696 to 1786 with the token, URL, chain-profile and enrollment-URL rows and the two closures now run alone.
  • Oracle markers: every test, theory row and mixed-source assertion carries // oracle: <kind> <reference>. The 806 markers break down as:
  • Mutation testing: Stryker.NET 5.0.0 ran at Standard level over the whole library at 87a104a1. It caught 1,056 of the 1,227 mutants it tested, a score of 86.06%: 1,045 killed, and 11 that make a parser or a retry loop never end. Of the 1,756 mutants generated, 331 were ignored as already covered and 198 did not compile.
    • The chain-profile and enrollment-URL changes were measured on the files they touch, with identical settings before them (175 of 189 caught, 92.59%) and after their review fixes (212 of 235, 90.21%); no caught mutant was lost, and the new survivors are equivalent mutants or exception text. Seven mutants that the earliest run reported killed survive when applied by hand on both sides, so that run's kills did not reproduce; the token changes replaced the code of every other mutant missing from the later run, and its counterpart is caught.
  • Domain mutants:
    • All 71 hand-written domain mutants were rerun on the final code. 70 are caught; the 71st, hashing the decoded secret bytes, is now the specified behaviour.
    • Four new ones are caught: a created token hashed as its text, a read token hashed as its text, a secret whose pad bits are ignored, and an @ accepted in a URL.
    • Each earlier feature's own hand mutants were caught when it landed:
      • for FirstMessageUnreadableAsync, skipping the closures, skipping the single-use slot, claiming the slot without refusing a later call, and an empty detail;
      • for the config cap, >= in place of >, and measuring the JSON alone;
      • for the URL rule, writing the URL as given;
      • for the closure order, checking expiry first.
  • Pruning: a test-pruner pass with oracle review went from 346 to 285 test methods, and raised the Stryker score on the library code of that time from 75.25% to 88.04%.
    • The library has grown since, so those figures and the ones above are not comparable.
    • No caught mutant was lost at any pruning, consolidation or narrowing step, each measured against a run of identical settings.
  • Reviews:
    • Every change was reviewed by a model other than its author's.
    • A final review of the whole diff by Fable 5.1 approved it at ea600507, with no blocking finding.

Known gaps

  • Unmeasured code: Stryker reports the mutants of IdentityCertificateIssuer.Issue and RequestAuthenticator.LoadRegisteredCertificate as compile errors, so mutation testing reaches those paths only through their helpers.
    • The leaf certificate profile is pinned by tests against contract §4 and RFC 5280.
  • Survivors: in the whole-library run at 87a104a1, 157 mutants survive and 14 have no coverage, triaged as:
    • equivalent mutants;
    • unconstrained text such as message wording, including empty failure details;
    • unreachable defensive exits.
  • No fuzz target: the strict parsers (RFC 8941, CSR DER, JSON bodies) have tests and a sweep of about 60,000 malformed inputs, but no fuzz harness, since the repository has none for .NET.
  • Untested properties, each marked UNTESTED: at the code:
    • the single-use handshake slot under two racing threads, which relies on one Interlocked.Exchange;
    • the digest comparison taking the same time whichever bytes differ;
    • RFC 8941 parsing taking time linear in the input's length;
    • every AgentIdentityError message being a constant sentence;
    • each value of the public enums (AgentOperation, CertificateStatus, RenewalOutcome, ReconnectReason, RenewalRequestReason, EnrollmentOutcome, AgentIdentityErrorCode) staying the same across releases;
    • certificate validity converted to UTC exactly in the hour a daylight-saving fall-back repeats, which holds because X509Certificate2 returns local DateTime values that keep their ambiguous-DST bit; checked by hand on .NET 10 with America/New_York at 05:30Z and 06:30Z, and no test pins it without swapping the process's time zone.
  • Host duties: documented in the README, not enforced by the library:
    • counting a token use and creating the identity atomically;
    • storing a pending certificate atomically;
    • retaining nonces;
    • adding a stream to the live streams before the handshake ends;
    • closing streams at a certificate's notAfter.
  • No live agent: no end-to-end test runs against a live agent in this repository; DVLS's conformance run exercises the library downstream.

Lines changed

The change adds 111 files, about 16,660 lines:

Area Files Lines added
Library code 57 6,205
Tests 45 9,774
README and intent files 3 224
Project files and CI 6 456

The library code and tests are about 37% and 59% of the added lines.

Note

LLM-assisted content (no human feedback).

@LucFauvel Luc Fauvel (LucFauvel) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review used several reviewers, covering security, performance, test quality, slop, and conformance with the intent files and the contract. Below are the medium-severity findings as inline comments: two intent or contract questions, one API gap, three gaps in the host guidance, one performance item, a test that fails on macOS, and the two largest simplification opportunities.

No Critical or High issues were found. The tests run every shared vector, and 43 of 48 mutations of security-relevant lines were caught by the tests.

Note

Human-tuned, LLM-assisted content.

/// Parses RFC 8941 structured field dictionaries, and serializes the values that a signature base holds.
/// </summary>
/// <remarks>
/// Parsing follows RFC 8941 §4.2, except that a repeated dictionary key or parameter key fails it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conflicts with the shared intent's reason for choosing RFC 9421.
docs/agent-identity/INTENT.agent-identity.md (Request authentication) says: "Requests are signed with RFC 9421 HTTP Message Signatures, a standard that both the agent and a .NET authority implement with maintained libraries."

This library deliberately hand-writes the RFC 8941 parser and the signature base, and the PR description explains why: NSign keeps the last of repeated parameters, which contract §6 step 1 forbids, and its verification is built for ASP.NET Core. That reasoning holds up, but the intent statement is no longer true for the .NET side.

Since intent files are human-owned, someone needs to pick one:

  • reword the intent (for example: RFC 9421 is a standard that tooling understands, and the .NET authority implements the profile itself because the contract's raw-field strictness rules out NSign); or
  • revisit the approach.

Note

Human-tuned, LLM-assisted content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Settled in the intent rather than the code: #2000's 909e0a91 rewords the shared intent's §Request authentication so that an authority "may use a library or, as the .NET authority library does because NSign keeps the last of repeated parameters, which §6 step 1 forbids, implement the profile itself from the RFC."

Note

Human-tuned, LLM-assisted content.

// oracle: spec X.690 §10.1
[Theory]
[MemberData(nameof(RequestWithALongerLengthNames))]
public void RejectsLengthInMoreOctetsThanNeeded(string element)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests and contract §4 disagree on how strictly to parse the CSR subject.
CONTRACT.agent-identity.md §4 says the server "ignores the CSR's subject and attributes … and it does not require their contents in canonical DER form … since nothing it decides depends on them".

But the "subject" row here (non-minimal length octets, defined around line 81) and RejectsSubjectThatIsNotASequence (line 387) require invalid_request for a malformed subject. Their markers cite X.690 §10.1 and RFC 2986, and that contract sentence overrides or at least muddies those sources.

Another authority implementation that follows §4 literally would accept these CSRs, so the two implementations would behave differently. Either the contract should say that the subject's own tag and length must still be DER (only its contents are exempt), or these rows should be relaxed.

Note

Human-tuned, LLM-assisted content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c1f0b35: the subject is read as any BER SEQUENCE and the attributes element as any BER [0], since CONTRACT.agent-identity.md §certificates has the server ignore both, while a subject that isn't structurally a Name stays invalid_request and the rest of the request stays DER.

Note

LLM-assisted content (no human feedback).

/// <exception cref="ArgumentException">
/// The issuer certificate has no subject key identifier, or its <c>notAfter</c> is not after the current second.
/// </exception>
public static X509Certificate2 Issue(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This public Guid overload lets a host issue a certificate for any identity, bypassing AuthenticatedIdentity.
dotnet/AgentIdentity/INTENT.md (Authenticated identity) says: "Code that acts for an identity takes it as a type only that check can create; an identity is never taken from a value the agent or a caller sent."

The AuthenticatedIdentity overload (line 150) enforces that for renewals, but it just forwards here, and this overload is public. A host renew handler that calls Issue(identityId, …) with an ID from a route or from storage would certify a key for an identity the request never proved. The README points to the right overload, but nothing in the API prevents the wrong one. It also accepts Guid.Empty.

Suggestion: make the new-identity path take a value that only the enrollment decision produces (for example something carried by EnrollmentDecision), or at least rename it IssueForNewIdentity and reject Guid.Empty.

Note

Human-tuned, LLM-assisted content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d214561: the Guid overload is now private, and a new identity's certificate is issued only from the CreateIdentity decision of EnrollmentRules.Decide, which carries the NewIdentityId the library mints, so no public issuance path takes an identity ID.

Note

LLM-assisted content (no human feedback).

Comment thread dotnet/AgentIdentity/README.md Outdated
4. On success, replace the identity's metadata with `Metadata`, send `Welcome`, and only then treat the stream as authenticated for `Identity`.
5. Send `ChannelMessages.RenewRequested` if the identity's request-renewal flag is set, and `ChannelMessages.ConfigUpdate` if the `Revision` of its config is higher than `applied_state_versions["config"]` of the `Hello` (CONTRACT.agent-identity.md [§7.3] steps 5 and 7).
6. If `Identity.CertificateStatus` is `CertificateStatus.Retired`, the certificate was retired during the handshake: also send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)`, and close the stream with `OK` 60 s later, as for `confirm` (CONTRACT.agent-identity.md [§7.4]).
7. Close the stream no later than `Identity.CertificateNotAfter` with `AgentIdentityErrorCode.CertificateExpired`, and when the identity is revoked with `AgentIdentityErrorCode.IdentityRevoked` (CONTRACT.agent-identity.md [§7.4]).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guidance should close the window where a revocation during the handshake is missed.
VerifyHelloAsync reads revocation once (ChannelOpening.cs:310). If a revocation commits after that read, but before the host has added the stream to whatever list its revocation path walks, a host following these steps never closes that stream. It stays authenticated until Identity.CertificateNotAfter, which can be up to 90 days. That breaks invariant 5 ("closes live streams").

Suggestion: say that the host must register the stream in its live-stream table before sending Welcome, and then re-check revocation (or take the same lock its revocation path takes). Alternatively, the revocation path can mark the identity so that any later registration closes immediately.

Note

Human-tuned, LLM-assisted content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7897f72 and 36745fc: the host adds the stream to the live streams its revocation path closes before it ends the handshake, and a test pins that the handshake reads the registry inside that call, so a revocation is caught either by that read or by the revocation path.

Note

LLM-assisted content (no human feedback).

Then send `ChannelMessages.Reconnect(ReconnectReason.CertificateRotated)` on each stream that the retired certificate opened, and close those streams with `OK` 60 s later (CONTRACT.agent-identity.md [§7.4]).
Answer `204`, also when the certificate was already current (CONTRACT.agent-identity.md [§5.5]).

### `check-in`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The host guide never mentions last_seen_at.
The shared intent (Configuration and metadata) is precise about this field:

  • creating the identity initializes it;
  • every accepted renew, every confirm (including an already-current retry), every check-in, every authenticated Hello and every later agent message on an authenticated stream sets it;
  • an enrollment replay and a stream closure do not.

The library has no storage, so this falls to the host, but none of the per-endpoint steps here say it. DVLS and PSU could then handle it differently, which is the drift this library exists to prevent.

Suggestion: one line in each of the enroll, renew, confirm, check-in and channel sections.

Note

Human-tuned, LLM-assisted content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7897f72: each section of the host guide now says when to set last_seen_at, as the shared intent's §Configuration and metadata defines it.

Note

LLM-assisted content (no human feedback).


- `ICertificateRegistry` finds, by thumbprint, a certificate that the authority issued, in any status including `Retired`, or returns null when there is none or its identity was deleted.
Index certificates by `AgentIdentityProtocol.ComputeThumbprint`, not by the SHA-1 `X509Certificate2.Thumbprint`.
Each `RegisteredCertificate` holds the DER exactly as issued, and the current `Status` and `IdentityRevoked`, so that a revocation takes effect from the next request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hosts get no guidance on registry and nonce-store load.

  • Every request that passes parsing, the tag and the time window costs one FindByThumbprintAsync, and all three of those checks are attacker-controlled. That means an unauthenticated caller with random keyids can drive registry queries.
  • Each channel connection costs two registry reads (the opening, and the re-read at Hello that §7.4 requires) plus one nonce write. At 10k reconnects/s after an authority restart, that is 20k reads/s and 10k atomic inserts/s.

This line ("so that a revocation takes effect from the next request") also tends to discourage caching.

Suggestion: state the per-request and per-connection rates, and say what can be cached safely:

  • the DER by thumbprint, since it is immutable;
  • a negative cache or per-source rate limit for misses.

Optionally, split the DER lookup from a lightweight state-only lookup for the Hello re-check.

Note

Human-tuned, LLM-assisted content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7897f72: the host dependencies now state the registry and nonce-store load per request and per stream, with the indexed lookup and the bounded caching that keep revocation effective from the next request.

Note

LLM-assisted content (no human feedback).

// which .NET writes for a key imported with explicit parameters.
["specifiedCurve"] = key =>
{
using ECDsa explicitKey = ECDsa.Create(key.ExportExplicitParameters(includePrivateParameters: false));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This row fails on macOS.
ExportExplicitParameters throws PlatformNotSupportedException: Only named curves are supported on this platform there, so dotnet test reports 1678 passed and 1 failed for anyone developing on macOS. CI runs this job only on windows-2022, so it doesn't catch it.

Suggestion: build the specified-curve SubjectPublicKeyInfo by hand (with AsnWriter or the existing TestCsr builders), so the fixture doesn't depend on the platform.

Note

Human-tuned, LLM-assisted content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d77a3ce: the specified-curve row now writes the RFC 3279 ECParameters itself with AsnWriter, so it no longer calls ExportExplicitParameters.

Note

LLM-assisted content (no human feedback).

/// <summary>
/// The outcome of authenticating a signed request.
/// </summary>
public sealed class AuthenticationResult

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are seven result types with the same shape (simplification).
AuthenticationResult, MetadataValidationResult, RequestBodyParseResult<T>, CsrValidationResult, EnrollmentAuthorizationResult, ChannelOpeningResult, ChannelHandshakeResult and the private Verification in RequestAuthenticator all have:

  • Succeeded with [MemberNotNullWhen];
  • a value;
  • Error and FailureDetail;
  • internal Success/Failure factories.

RequestBodyParseResult<TBody> is already the generic form.

Suggestion: one AgentIdentityResult<T>, where ChannelHandshakeResult becomes AgentIdentityResult<AuthenticatedStream> with a record holding Identity, Metadata and Welcome. That is roughly 300 lines less and a smaller public API, at no compatibility cost since the package is unreleased.

  • The authenticated-identity intent is unaffected, because AuthenticatedIdentity keeps its internal constructor.
  • The tradeoff is that hosts read result.Value instead of named properties, and the README's call-order section needs updating.

Note

Human-tuned, LLM-assisted content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9476269 as a shared base, AgentIdentityResult, holding Error, FailureDetail and the failure annotation on Succeeded, while each type keeps its named value members, so hosts and the README's call order read as before.

Note

LLM-assisted content (no human feedback).

public X509SignatureGenerator SignatureGenerator => X509SignatureGenerator.CreateForECDsa(key);
}

private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test duplication (simplification, about 600 lines across the test project).
Here, FixedTimeProvider, AdvancingTimeProvider and SequenceRandomNumberGenerator redefine what Support/TestDoubles.cs already provides (SteppingTimeProvider, ScriptedRandomNumberGenerator). EnrollmentTokenTests.cs:279 has another copy.

The larger items elsewhere:

  • CertificateSigningRequestTests: about 8 dictionary-plus-theory groups and about 10 facts all run AssertInvalid(Validate(bytes)), and could be one InvalidRequests theory (about −130 lines).
  • The same BaseUrl rule table is tested twice, in EnrollmentTokenTests.cs:106-154,233-274 and IdentityConfigTests.cs:60-127,374-400.
  • ChannelHandshakeTests: EarlierFailureWins and TwoSignatureFailuresAreSignatureInvalid have identical bodies, and five single-failure facts could be one theory over Failures.Keys.
  • Channel-opening and dispatch setup appears three times (TestAuthority, VectorAuthority, ChannelOpeningTests.OpenAsync, ChannelHandshakeTests).
  • VectorData.KeyNames and VectorData.RegisteredCertificateNames are unused.

Note

Human-tuned, LLM-assisted content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ced3d39..d60c047: the tests reuse the shared doubles, the CSR rejections are one RejectsInvalidRequest theory, the base URL table is shared, the handshake failures are two theories, the channel-opening setup is shared and the unused members are gone, keeping every case and every caught mutant (300 to 275 methods).

Note

LLM-assisted content (no human feedback).

@CBenoit
Benoît Cortier (CBenoit) force-pushed the cbenoit-agent-identity-authority-library branch from 0fe5d6f to 87a104a Compare October 24, 2026 11:51
The authority library holds what every Agent Identity authority must do
the same way, so that DVLS and PSU cannot drift apart on the
agent-facing protocol. The feature's shared intent is linked beside it.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Add the Devolutions.AgentIdentity library, for the agent-facing protocol
rules that every Agent Identity authority shares, and its test project,
laid out like utils/dotnet.

The library references Devolutions.Agent.Channel, which becomes its
package dependency, and its build fails on any warning, including a
missing doc comment. The test project links
docs/agent-identity/test-vectors.json into its output so that vector
changes apply without a copy. build.ps1 builds and packs the library
alone.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract gives every base64 and base64url value one canonical
encoding, and an authority rejects any other form. The BCL decoders
skip white space and ignore the pad bits of the final quantum, so they
accept several encodings of one value.

Add internal decoders that accept only the canonical RFC 4648 form,
optionally of an exact decoded length, and a base64url encoder.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every authority must answer with the same error codes, statuses and
bodies, because the agent decides from them whether to retry, discard a
pending enrollment or stop using an identity.

Add AgentIdentityErrorCode with its wire values and its HTTP and gRPC
statuses, including unavailable for an overloaded or failing server,
and AgentIdentityError, which writes the JSON error body and the
RpcException with the error-code and server-time trailers. Each message
is a fixed English sentence, so it never repeats request data.
AgentIdentityProtocol holds the 64 KiB body limit, the timestamp format
and the certificate thumbprint.

The tests read the shared vector file into typed records and yield one
theory row per vector name, so that each vector is its own test.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Run the Devolutions.AgentIdentity tests and build its package in the
.NET utils job, and add the package to the NuGet publish matrix next to
Devolutions.Agent.Channel, which it depends on.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The library and test projects treat warnings as errors, so an advisory
published later against any direct or transitive package would fail
every build of them, including unrelated pull requests and the scheduled
publish run, while the rest of the repository only warns. Exclude the
NuGet audit warnings NU1901 to NU1904 from warnings as errors so that
advisories stay visible without breaking builds.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…tore

Request authentication resolves a keyid only through a certificate that
the authority registered, and accepts each nonce once, so the host
provides both through interfaces over its own storage.

RegisteredCertificate holds the certificate DER as issued, from which
the validity and the key are read, with the certificate status and the
identity's revocation. It refuses an undefined status, which would
otherwise draw identity_unknown and end the identity for the agent.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Every authority must accept and refuse the same signed requests with the
same errors, so the library runs the RFC 9421 profile of the contract
for renew, confirm, check-in and the agent channel opening.

RequestAuthenticator refuses a body over 64 KiB first. It then parses
Signature-Input, Signature and Content-Digest with an RFC 8941 parser
that fails on a repeated key instead of keeping the last one, checks
the profile, the tag and the time window against one reading of the
clock, resolves the keyid through the registry, verifies the digest and
the signature, and records the nonce only once all of these pass.
@signature-params is the serialization of the parsed member, so one
parse drives both the checks and the signature base.

Only AuthenticateAsync creates an AuthenticatedIdentity, and it refuses
Connect. AuthenticateChannelOpeningAsync returns a ChannelOpening with
a fresh challenge and no identity, since a stream is authenticated only
once the agent signs that challenge.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The contract places an anchor before each section heading, and a marker
cites the anchor when the file has one, because it survives a renamed
heading or a renumbered section.
Every contract marker and contract link under dotnet/ now uses the
anchor, such as `§errors` for §5.4, instead of the section number or
an earlier prefixed form of the anchor.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The README linked most contract sections through fragments derived from
their headings, which break when a heading is renamed or renumbered.
Every contract link now uses the anchor the contract places before the
section.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The authority ignores the CSR's subject and attributes, and the contract
does not require them in canonical DER form, since nothing it decides
depends on them. The parser still read the subject and the attributes
element as DER, so a subject or an attribute set whose length took more
octets than DER allows failed the request with invalid_request.

The subject is now read as a BER SEQUENCE and the attributes as a BER
constructed [0], neither parsed further. The rest stays DER: a CSR is
DER, the self-signature covers the request info as received, and each
key must have a single SubjectPublicKeyInfo. Tests now also pin that the
request info, the version and the SubjectPublicKeyInfo stay DER.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The specified-curve row of RejectsKeyWithoutANamedCurve took its key
from ExportExplicitParameters, which throws PlatformNotSupportedException
on macOS, so the suite could not pass there.

The row now writes the ECParameters of RFC 3279 section 2.3.5 itself,
with the secp256r1 values of SEC 2 section 2.4.2; these are the bytes
.NET writes on Windows. The request is still signed by the key whose
point it carries, so it fails only for its curve parameters.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… from its enrollment decision

Issuing a new identity's certificate for a caller-supplied Guid let a
host certify a key for an identity that no enrollment created. A new
identity's certificate is now issued only from the EnrollmentDecision
that creates it, and no public issuance path takes an identity ID.

EnrollmentRules.Decide mints a random identity ID for each decision to
create an identity and exposes it as EnrollmentDecision.NewIdentityId,
which is null for every other outcome. The new Issue overload takes
that decision and throws ArgumentException for a decision of another
outcome. The Guid overload becomes the private implementation that the
enrollment and renewal overloads share. Only the library constructs an
EnrollmentDecision, so a host cannot forge one for another ID.

BREAKING CHANGE: IdentityCertificateIssuer.Issue(Guid, ...) is removed;
issue a new identity's certificate with
IdentityCertificateIssuer.Issue(EnrollmentDecision, ...), passing the
CreateIdentity decision of EnrollmentRules.Decide, and create the
identity under its NewIdentityId.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…n_at and load

A revocation that commits after the handshake's registry read, but
before the host tracks the stream, left the stream authenticated until
its certificate expired.
The host now adds the stream to the live streams its revocation and
deletion paths close before it ends the handshake, so a revocation is
caught either by that read or by the revocation path.
The stream is keyed by `ChannelOpening.CertificateThumbprint` until the
handshake reveals the identity, and that property's doc says so.

Each section now says when to set `last_seen_at`, as the shared intent
defines it, and the host dependencies state the registry and nonce-store
load per request and per stream, with the caching and indexing that keep
revocation effective from the next request.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… the call

A host closes a revocation race by adding the stream to its live streams
before it ends the handshake, which works only if the handshake reads
the registry within the call rather than reusing the opening's answer.
The test sets a flag before each call that ends the handshake and checks
that a registry lookup ran after it.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Seven public result types each declared the same Error and FailureDetail
properties and the same failure annotation on Succeeded. They now derive
from AgentIdentityResult, which holds those members once, and each type
overrides Succeeded only to name its own value members.

The override keeps the host's nullable flow analysis: [MemberNotNullWhen]
cannot name an inherited member, but the compiler applies the base
property's annotation and the override's together. A compile-only file in
the test project dereferences each type's members on both branches, so
losing either annotation fails the build.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…bles

The certificate issuer tests defined a fixed clock, an advancing clock
and a generator of whole draws, and the enrollment token tests a
generator of one byte stream. They now use the doubles in
`Support/TestDoubles.cs`.

The stepping clock repeats its last reading where the advancing one
kept moving, and the scripted generator serves one byte stream where
the issuer tests' generator returned whole draws, so each test gives
the code under test the readings and bytes it gave before.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…heory

About twenty facts and theories each built a malformed request and asserted only that validation rejects it with invalid_request.
They are now named rows of one `RejectsInvalidRequest` theory, each keeping the explanation and markers of the rule it breaks.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The enrollment token and identity config tests listed the same URLs that are not an absolute `https` URL without user information, a query or a fragment.
Both now draw them from `Support/UnacceptableUrls.cs`, whose rows cite the contract section of each field.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…heories

`EarlierFailureWins` and `TwoSignatureFailuresAreSignatureInvalid` had the same body over two halves of the failure pairs, so one theory now runs every pair.
The facts and theories that each checked one failure's code and status are now `FailureClosesTheStreamWithItsCode`, run over every named failure, with each failure's markers where it is defined.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Several tests and helpers each authenticated a channel opening from a signed request or from the vector connect case, and two signed the matching Hello.
`TestAuthority.OpenChannelAsync` and `TestAuthority.ValidHello` now open a stream of `device-a` and answer it, `VectorAuthority.OpenChannelAsync` authenticates a connect case, and `VectorChannel.OpenAsync` opens the vector stream over any registry.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
No test draws its rows from `VectorData.KeyNames` or `VectorData.RegisteredCertificateNames`.

Issue: DGW-479
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ReadsTheClockOnce gives the issuer three distinct readings, so an
issuer that took both validity bounds from readings after the first
fails it.
TestAuthority.ValidHello signs over the nonce of the opening it
answers rather than the default one, VectorAuthority builds the
signature lines only for the requests that use them, and stray blank
lines are fixed.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An authority stores SHA-256 of the 32 bytes a token's secret encodes
and accepts the secret only in its canonical base64url form
(CONTRACT.agent-identity.md §2), so that every authority stores the
same hash for the same token.

EnrollmentToken.SecretHash and the hash ReadBearerToken returns now
cover the decoded bytes rather than the secret's text.
ReadBearerToken answers token_invalid for a secret whose pad bits are
not zero, since such a secret decodes to the bytes of a canonical one.

A hash stored from an earlier build of the library no longer matches
its token.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token's product base URL carries no user name or password
(CONTRACT.agent-identity.md §2), which could make an address read as
one host while it reaches another.
The library already refused any user information; rows with a password
alone and with a user name and password now pin it, and the user name
rows cite the contract.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…leted

An authority revokes a token that is no longer wanted instead of
deleting it (CONTRACT.agent-identity.md §2.1). A revoked token keeps
its secret hash, so a lost enrollment response stays recoverable, and
gets token_invalid for anything but that replay, as a deleted token
did.

EnrollmentTokenState's deleted parameter and Deleted property become
revoked and Revoked, and the docs and failure details say revoked.

BREAKING CHANGE: EnrollmentTokenState(bool deleted, ...) is now
EnrollmentTokenState(bool revoked, ...), and EnrollmentTokenState.Deleted
is now EnrollmentTokenState.Revoked.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token's u and a config's agent_channel_url are read as text: each
begins with https:// and contains no @, and any @ counts as a user
name (CONTRACT.agent-identity.md §2 and §5.2), because URL parsers
disagree on forms such as https://@host.

The library refused only an @ that .NET's parser placed in the user
information, so https://host/dvls/a@b made a token whose u holds an
@. It now refuses any @ in the URL text.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Certificate validity reaches the library as local DateTime values from
X509Certificate2, and it converts each straight to UTC. The conversion
is exact in the hour that a daylight-saving fall-back repeats only
because that DateTime keeps its ambiguous-DST bit, which no test pins
without swapping the process's time zone. An UNTESTED comment at each
conversion says so, and what would break it.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token's u holds only ASCII characters, so that certificates can carry
it as an IA5String (CONTRACT.agent-identity.md §2); an internationalized
host is written in its xn-- form. EnrollmentToken.Create now refuses a
product base URL that is not ASCII, which also refuses a host holding a
character such as the fullwidth commercial at.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…file

Every certificate in a chain is signed with ecdsa-with-SHA256 by a P-256
key or with ecdsa-with-SHA384 by a P-384 key, never with RSA, and every
CA certificate, the root included, carries basicConstraints with cA set
and key usage with keyCertSign (CONTRACT.agent-identity.md §4). The
issuer signed with SHA-256 and whatever generator the host's IRootSigner
gave, so an RSA root, another curve, or a root that is not a CA produced
chains the contract forbids.

IdentityCertificateIssuer.Issue now picks the hash from the issuer key's
curve and throws ArgumentException, with ParamName issuer, unless the
issuer certificate has a P-256 or P-384 key, basicConstraints with cA
set and key usage with keyCertSign, and its generator's algorithm
identifier for that hash is exactly the matching ECDSA one without
parameters. IRootSigner's docs and the README name the profile.

BREAKING CHANGE: IdentityCertificateIssuer.Issue refuses an issuer
outside that profile; a host whose root is RSA, another curve, or not
a CA certificate must replace it.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An enrollment whose CSR names no enrollment URL, or not the presented
token's u, gets token_url_mismatch with HTTP 403, a permanent outcome
for the agent (CONTRACT.agent-identity.md §5.4). It takes the next
unused value, and maps to UNAUTHENTICATED like the other token codes.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent's CSR requests the enrollment URL extension in an
extensionRequest, naming the base URL it sends the request to, and the
server reads it there while ignoring every other attribute and requested
extension (CONTRACT.agent-identity.md §4).

CertificateSigningRequest.EnrollmentUrl holds the value of the one
enrollment URL extension that the attributes request, as an IA5String
read as text, or null when there is none, there are several, its value
is not one IA5String, or the attributes cannot be read; a CSR stays
valid either way, since enrollment, not validation, refuses it.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token works only at the base URL it names: at enrollment, a CSR that
names no enrollment URL, or not the presented token's u without
trailing slashes compared as text, gets token_url_mismatch, right after
invalid_request and before the key lookup (CONTRACT.agent-identity.md
§2.1 and §4), so that a server relaying a token from another address is
refused.

EnrollmentTokenState takes the token's u, which EnrollmentToken now
exposes as ProductBaseUrl for the host to store, and
EnrollmentRules.Decide takes the request's CSR. A decision to create an
identity carries the token's u without trailing slashes as
EnrollmentUrl, which every certificate of the identity names.

BREAKING CHANGE: EnrollmentTokenState takes the token's product base
URL after revoked, and EnrollmentRules.Decide takes the request's CSR
after the token state.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…icate

Every certificate issued for an identity carries the enrollment URL
extension once, non-critical, holding as an IA5String the u of the
token that enrolled it, without trailing slashes, so that an agent can
refuse a certificate from an authority at another address
(CONTRACT.agent-identity.md §4).

The enrollment overload of IdentityCertificateIssuer.Issue takes the
URL from the decision, and the renewal overload takes the identity's
enrollment URL, which the host keeps from that decision; it throws
ArgumentException for one that is empty, ends in a slash or is not
ASCII. The extension's OID is one named constant.

BREAKING CHANGE: the renewal overload of IdentityCertificateIssuer.Issue
takes the identity's enrollment URL after the renewing identity.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server ignores every CSR attribute but the enrollment URL, so the
parser now skips the values of other attributes unread, and a
malformed value of another attribute no longer hides a well-formed
enrollment URL.
IdentityCertificateIssuer.Issue refuses a generator over another key
than the issuer certificate's, whose certificates would not chain, and
EnrollmentTokenState refuses a product base URL that is empty once its
trailing slashes are removed.

Tests pin a critical flag on the requested extension, an attribute
that is not a SEQUENCE and an attribute set of indefinite length, and
the docs say the attributes are read for the enrollment URL.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The enrollment URL extension's OID sits under the enterprise number
that RFC 5612 reserves for documentation, and the contract moves it
under Devolutions' own enterprise number before release.

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shared vectors now give each CSR's outcome at enroll and at renew
in expected_at, and name the enrollment URL a valid CSR carries. The
loader reads both members, and a theory runs every CSR vector through
validation and then EnrollmentRules.Decide, with a token whose u is
the entry's enrollment URL, or RenewalRules.Decide; so a CSR with an
empty extensionRequest is refused at enrollment with
token_url_mismatch and accepted at renewal.

A CSR row also pins that the enrollment URL extension requested in two
extensionRequest attributes names no enrollment URL
(CONTRACT.agent-identity.md §4).

Issue: DGW-479
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CBenoit
Benoît Cortier (CBenoit) force-pushed the cbenoit-agent-identity-authority-library branch from 0bad2a8 to ea60050 Compare October 25, 2026 05:18

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